diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfec385d..6831cb5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1042,6 +1042,11 @@ jobs: - name: pnpm install run: pnpm -C web install --frozen-lockfile + - name: Validate Web dependency licenses + run: | + python3 -B -m unittest discover -s scripts -p 'test_check_license_inventory.py' + python3 -B scripts/check_license_inventory.py + - name: pnpm build run: pnpm -C web build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 60ff5790..5f340e6c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -155,6 +155,13 @@ jobs: rf"(?:-{identifier}(?:\.{identifier})*)?$" ) tag = os.environ["RELEASE_TAG"] + event_name = os.environ["GITHUB_EVENT_NAME"] + if event_name == "workflow_dispatch": + expected_version = "1.0.0-beta.4" + expected_wix_version = "1.0.0.4" + else: + expected_version = "1.0.0-beta.5" + expected_wix_version = "1.0.0.5" if "+" in tag: raise SystemExit("SemVer build metadata is unsupported for updater asset URLs") if SEMVER_RE.fullmatch(tag) is None: @@ -170,15 +177,19 @@ jobs: } if versions != {version}: raise SystemExit(f"tag/version mismatch: {tag} != {sorted(versions)}") + if version != expected_version: + raise SystemExit( + f"release identity mismatch for {event_name}: {version} != {expected_version}" + ) wix_version = tauri["bundle"]["windows"]["wix"]["version"] - if wix_version != "1.0.0.4": + if wix_version != expected_wix_version: raise SystemExit(f"unexpected Windows installer version: {wix_version}") notes = Path("docs/releases") / f"{version}.md" if not notes.is_file() or not notes.read_text(encoding="utf-8").strip(): raise SystemExit(f"release notes are missing or empty: {notes}") prerelease = "-" in version.split("+", 1)[0] - if version == "1.0.0-beta.4" and not prerelease: - raise SystemExit("OpenTake 1.0.0-beta.4 must remain a prerelease") + if version == "1.0.0-beta.5" and not prerelease: + raise SystemExit("OpenTake 1.0.0-beta.5 must remain a prerelease") if not prerelease: raise SystemExit("this release workflow publishes prereleases only") @@ -215,6 +226,7 @@ jobs: env: TARGET_SHA: ${{ needs.validate.outputs.source_sha }} RELEASE_TOOLING_SHA: ${{ needs.validate.outputs.tooling_sha }} + OPENTAKE_EXPECTED_RELEASE_VERSION: ${{ needs.validate.outputs.version }} CI: true steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -321,7 +333,7 @@ jobs: > "$tooling_root/test_provision_ffmpeg_sidecars.py" git cat-file blob "$RELEASE_TOOLING_SHA:.github/workflows/release.yml" \ > "$tooling_root/release.yml" - git cat-file blob "$RELEASE_TOOLING_SHA:docs/releases/1.0.0-beta.4.md" \ + git cat-file blob "$RELEASE_TOOLING_SHA:docs/releases/1.0.0-beta.5.md" \ > "$tooling_root/release-notes.md" test -s "$tooling_root/check_release_workflow.py" test -s "$tooling_root/test_check_release_workflow.py" @@ -356,6 +368,22 @@ jobs: - name: Install locked Web dependencies run: pnpm -C web install --frozen-lockfile + - name: Validate Web dependency licenses + run: | + set -euo pipefail + case "$OPENTAKE_EXPECTED_RELEASE_VERSION" in + 1.0.0-beta.5) + python3 -B -m unittest discover -s scripts -p 'test_check_license_inventory.py' + python3 -B scripts/check_license_inventory.py + ;; + 1.0.0-beta.4) + node -e 'const d=require("./web/package.json").dependencies??{}; if(Object.keys(d).some((name)=>name==="codemirror"||name.startsWith("@codemirror/"))) process.exit(1)' + ;; + *) + exit 1 + ;; + esac + - name: Rust formatting run: cargo fmt --all --check diff --git a/.superpowers/sdd/2026-08-13-beta5-agent-conversation/task-1-report.md b/.superpowers/sdd/2026-08-13-beta5-agent-conversation/task-1-report.md new file mode 100644 index 00000000..af55213b --- /dev/null +++ b/.superpowers/sdd/2026-08-13-beta5-agent-conversation/task-1-report.md @@ -0,0 +1,148 @@ +# Task 1 report — ordered Rust chat block protocol + +Date: 2026-08-14 (Asia/Shanghai) + +Base: `26c6182` + +Branch: `release/v1.0.0-beta.5` + +## Result + +- `ChatMessage.blocks` is authoritative. Ordered constructors and mutations preserve interleaved text/tool blocks, consolidate only adjacent text deltas, and derive Beta 4 `content` / `toolCalls` fields one-way through `refresh_legacy_fields`. +- Legacy messages without blocks migrate deterministically to text followed by their persisted tool-call order. Native tool-result text/image blocks retain their exact serialization order. +- `LoopEvent` now exposes block-addressed `BlockDelta` and `BlockUpsert` variants and an ID-addressed `Done`. The normal provider loop mints the message ID before streaming and reuses the active ID for completion, cancellation, and provider errors. +- The Tauri normal-provider and Official Codex paths emit `sessionId`, `messageId`, and `blockIndex`. Codex tool updates and final text share one pre-minted message ID. Cancellation, history-save errors, and other terminal failures reuse the active message ID. +- During the Beta 4 development-window transition, Tauri retains the existing `chat_delta` / `chat_tool_call` event names and the legacy `toolCall` field for tool-use upserts; Beta 5 decoders additionally consume the authoritative block address and block payload. + +## TDD evidence + +RED was observed before implementation. The plan's literal command has two positional Cargo test filters and Cargo rejected the second filter. Running the filters separately produced the intended compile failures: missing ordered constructors/mutations, missing `LoopEvent::BlockDelta` / `BlockUpsert`, and missing `Done.message_id`. + +GREEN verification on the final tree: + +- `cargo test -p opentake-agent chat:: -- --nocapture` — 47 passed, 0 failed. +- `cargo test -p opentake-tauri chat::tests --lib` — 19 passed, 0 failed. +- `cargo fmt --all -- --check` — passed. +- `cargo clippy -p opentake-agent --all-targets -- -D warnings` — passed. +- `cargo clippy -p opentake-tauri --lib -- -D warnings` — passed. +- `git diff --check` for the four owned chat files — passed. + +The combined Tauri all-target clippy gate was also attempted. It is blocked outside Task 1 by the concurrent unowned change at `src-tauri/src/commands.rs:2997` (`clippy::field_reassign_with_default`). This task did not modify or revert that file. + +## Scope review + +Owned changes are limited to: + +- `crates/opentake-agent/src/chat/session.rs` +- `crates/opentake-agent/src/chat/loop.rs` +- `crates/opentake-agent/src/chat/mod.rs` +- `src-tauri/src/chat.rs` +- this report + +Concurrent core, project, media, render, commands, home, and audit changes were not staged or reverted. + +## Review fix round 1 + +Commit target: `fix(agent): preserve provider block order` + +### Result + +- The live Anthropic SSE decoder now treats provider `content_block_start`, `content_block_delta`, and `content_block_stop` indices as authoritative. It emits block upserts at start/stop and indexed text deltas between them, so `text A → tool use → text B` remains in that order through loop events and persistence. +- Anthropic `input_json_delta.partial_json` is accumulated on its addressed tool block. The live HTTP path and the deterministic SSE regression test share the same decoder. +- OpenAI and Anthropic request builders now derive text, tool calls, tool-use IDs, and native tool-result content directly from `ChatMessage.blocks`. In particular, the next Anthropic round serializes interleaved assistant blocks in their original order instead of reconstructing `text + tools` from Beta 4 compatibility fields. +- The deserialize wire uses `Option>`: only a missing `blocks` property migrates legacy fields. Explicit `blocks: []` remains empty, clears stale flat fields/tool metadata, and serializes back as a Beta 5 empty array. +- Large inline tests moved to `chat/session/tests.rs` and `chat/loop/tests.rs`; production `session.rs` is 533 lines and `loop.rs` is 610 lines. + +### RED evidence + +- The explicit-empty tests first failed with a missing serialized array (`null` versus `[]`) and with stale legacy text/tool calls being migrated into non-empty blocks. +- The authoritative Anthropic body test first failed with `text AB → tool` instead of `text A → tool → text B`. +- The interleaved SSE test first failed to compile because the indexed Anthropic decoder and shared loop event application path did not exist; after implementation it exercises split raw SSE chunks through loop events into the next-round request body. +- The explicit-empty tool-message test first failed because stale `toolCallId` survived an authoritative empty block array. + +### Fresh verification + +- `cargo test -p opentake-agent chat:: -- --nocapture` — 53 passed, 0 failed. +- `cargo test -p opentake-tauri chat::tests --lib -- --nocapture` — 19 passed, 0 failed. +- `cargo clippy -p opentake-agent --all-targets -- -D warnings` — passed. +- `cargo clippy -p opentake-tauri --lib -- -D warnings` — passed (only Cargo's existing future-incompatibility notice for `block v0.1.6`). +- `cargo fmt -p opentake-agent -- --check` — passed. +- Owned-file `git diff --check` — passed. +- `cargo fmt --all -- --check` was also run; its only diff is the concurrent unowned `crates/opentake-render/src/plan/build.rs` formatting change, so Task 1 did not rewrite it. + +### Round 1 scope + +- `crates/opentake-agent/src/chat/llm.rs` +- `crates/opentake-agent/src/chat/session.rs` +- `crates/opentake-agent/src/chat/session/tests.rs` +- `crates/opentake-agent/src/chat/loop.rs` +- `crates/opentake-agent/src/chat/loop/tests.rs` +- this report + +## Review fix round 2 + +Commit target: `fix(agent): reject incomplete provider streams` + +### Result + +- Anthropic stream completion is now fail-closed: every opened content block must receive exactly one matching `content_block_stop`, the stream must receive exactly one `message_stop`, and EOF cannot finalize a partial text/tool block. Premature/repeated stops, deltas after block stop, and events after message stop return `LlmError::Stream`. +- A failed/truncated provider turn never reaches the loop's persistence or tool-dispatch phase; those phases remain after successful decoder finalization. +- `llm.rs` is now a 228-line façade. Provider production code lives in `chat/llm/openai.rs` (235 lines) and `chat/llm/anthropic.rs` (490 lines); tests live in `chat/llm/tests.rs` (401 lines). Every file is below 800 lines. +- Every Rust block delta/upsert/done carries a per-message monotonic `sequence: u64`, starting at 0 and increasing in emission order. Provider rounds, tool-result messages, guide/error/cancel terminal messages, save failures, and Official Codex events use the same contract. +- The Tauri payloads expose `sequence` in camelCase JSON and reject duplicate or gapped sequences per message before emitting to the window. + +### RED evidence + +- Seven Anthropic lifecycle tests initially accepted invalid partial streams: truncated text/tool blocks, missing `message_stop`, premature `message_stop`, repeated block/message stops, and a delta after block stop all returned partial `TurnResult` values. +- Sequence tests initially failed to compile because `LoopEvent`, `LoopError`, and the three Tauri payloads had no sequence field; the strict duplicate/gap gate did not exist. + +### Fresh verification + +- `cargo test -p opentake-agent chat:: -- --nocapture` — 60 passed, 0 failed. +- `cargo test -p opentake-tauri chat::tests --lib -- --nocapture` — 20 passed, 0 failed. +- `cargo clippy -p opentake-agent --all-targets -- -D warnings` — passed. +- `cargo clippy -p opentake-tauri --lib -- -D warnings` — passed (only Cargo's existing future-incompatibility notice for `block v0.1.6`). +- Direct `rustfmt --check` on the seven owned Rust chat files — passed. +- Owned-file `git diff --check` — passed. + +### Round 2 scope + +- `crates/opentake-agent/src/chat/llm.rs` +- `crates/opentake-agent/src/chat/llm/openai.rs` +- `crates/opentake-agent/src/chat/llm/anthropic.rs` +- `crates/opentake-agent/src/chat/llm/tests.rs` +- `crates/opentake-agent/src/chat/loop.rs` +- `crates/opentake-agent/src/chat/loop/tests.rs` +- `src-tauri/src/chat.rs` +- this report + +## Cross-layer tool-result finalization follow-up + +Commit target: `fix(agent): finalize streamed tool result messages` + +### Result + +- Each normal-loop tool-result message is persisted and then emits its own ordered stream: one `BlockUpsert` per authoritative block beginning at sequence 0, followed immediately by `Done` at the next sequence (sequence 1 for the current single-block tool result) before the next assistant round. +- The tool-result upsert and terminal event share the same session ID and message ID, and the terminal payload contains the exact persisted role=`tool` message. +- Tauri admits a terminal role=`tool` only when `blocks` is non-empty, every block is `ToolResult`, every `toolUseId` matches the message `toolCallId`, block/message error markers match, and the legacy `toolCalls` list is empty. System/user terminal messages, tool-only fields on assistants, and tool-result blocks on assistants remain rejected. + +### RED evidence + +- The loop regression test first failed to compile because no persistence-and-finalization path existed; the production path only emitted `BlockUpsert { sequence: 0 }` and pushed the tool message. +- The Tauri terminal-contract test first failed to compile because no role-aware terminal gate existed. A second RED run then showed that a mismatched message/block error marker was still accepted before the exact-match check was added. + +### Fresh verification + +- `cargo test -p opentake-agent 'chat::' -- --nocapture` — 61 passed, 0 failed. +- `cargo test -p opentake-tauri 'chat::tests' --lib -- --nocapture` — 22 passed, 0 failed. +- `cargo clippy -p opentake-agent --all-targets -- -D warnings` — passed. +- `cargo clippy -p opentake-tauri --lib -- -D warnings` — passed (only Cargo's existing future-incompatibility notice for `block v0.1.6`). +- Direct `rustfmt` on the three owned Rust chat files and owned-file `git diff --check` — passed. +- The combined all-target clippy command was attempted and is blocked by the concurrent unowned change at `src-tauri/src/commands.rs:2997` (`clippy::field_reassign_with_default`). This follow-up did not modify that file. + +### Follow-up scope + +- `crates/opentake-agent/src/chat/loop.rs` +- `crates/opentake-agent/src/chat/loop/tests.rs` +- `src-tauri/src/chat.rs` +- this report diff --git a/.superpowers/sdd/2026-08-13-beta5-agent-conversation/task-2-report.md b/.superpowers/sdd/2026-08-13-beta5-agent-conversation/task-2-report.md new file mode 100644 index 00000000..4f25c197 --- /dev/null +++ b/.superpowers/sdd/2026-08-13-beta5-agent-conversation/task-2-report.md @@ -0,0 +1,131 @@ +# Task 2 report — session- and block-safe chat store + +Date: 2026-08-14 (Asia/Shanghai) + +Base: `d44787f` + +Branch: `release/v1.0.0-beta.5` + +## Result + +- Replaced proximity-based assistant updates with drafts keyed by the exact + `(sessionId, messageId)` pair. The visible `messages`, `streaming`, and + `streamingId` fields are now derived only from the selected session. +- Added ordered, immutable block reducers: `beginMessage`, + `appendBlockDelta`, `upsertBlock`, and ID-addressed `finalize`. Text/tool/text + and multi-round tool streams retain their authoritative block positions; + final messages replace drafts wholesale. +- Added bounded stream IDs, block indices, and deltas; strict Tauri event + decoding returns a discriminated event or a structured malformed-payload + failure. Gaps and malformed messages stop the affected stream and enqueue one + authoritative-history re-sync per session, without retry loops. +- Retained Beta 4 store and `toolCall` adapter methods temporarily for an + already-open window. Task 3 must wire the new identity reducers, malformed + callback/re-sync queue, session-history replacement, and listener teardown in + `AgentPanel`. +- Added tests for block order, multiple rounds, duplicate delivery, gaps, + bounded indices, authoritative final replacement, inactive/deleted/late + session isolation, explicit no-nearest-assistant merge, and decoder validity. + +## Review fix — ordered event sequences (`931edb9` protocol) + +- Mirrored Rust's per-message `sequence` on every decoded stream event. The + reducer accepts only the exact next sequence and poisons only the addressed + message on a gap or stale out-of-order event. Two identical deltas at + consecutive valid sequences are both preserved; exact retry validation is + detailed in review fix round 2 below. +- Separated per-message poison from per-session history re-sync de-duplication. + A bad message cannot stop a sibling message in the same session, while at + most one history reload request remains pending until authoritative history + is installed. +- Made block discriminants and tool identities immutable at an occupied block + index (`toolUse.id/name`, `toolResult.toolUseId`). Final assistant messages + are deeply validated, bounded, exact-ID replacements. +- Bounded retained inactive histories, draft/final sequence records, deleted + session tombstones, blocked keys, and pending re-sync state. Authoritative + history clears all poison and sequence state for its session. + +## Review fix round 2 — exact retry validation + +- Retained a bounded 64-event replay fingerprint window on each message draft. + Fingerprints cover the event discriminant, session/message address, block + index, and canonical payload while storing only fixed-size hashes. +- Exact delayed retries inside the window are idempotent. Reusing a retained + sequence with a different event kind, address, index, or payload poisons only + that message with `sequence_conflict`; retries older than the retained window + fail closed as `sequence_out_of_order`. +- Added focused regressions for delayed identical text, cross-kind/index/payload + conflicts, and an exact retry outside the bounded replay window. + +## Review fix round 3 — bounded exact canonical replay + +- Replaced the two 32-bit hashes with the collision-free canonical event text. + Canonicalization uses deterministic UTF-16 code-unit key order, rejects + sparse arrays and non-JSON containers, and streams into a writer that aborts + above the 1 MiB aggregate event limit. Image content uses the same individual + ceiling, and each draft retains at most 64 entries and 1 MiB of canonical + replay text in total. +- Split sequence-address preflight from payload comparison. Gaps and events + older than the retained window now fail closed before deep payload validation + or canonical construction; only a current event or a retained retry pays the + bounded canonicalization cost. +- Extended terminal decoding and exact final replacement to Rust `role: "tool"` + messages. Tool terminals require non-empty `toolResult` blocks, an empty + `toolCalls` list, exact `toolCallId`/`toolUseId` identity, and aligned error + state. Assistant terminals continue to reject tool-only blocks and fields. + +## Review fix round 4 — exact optional tool error state + +- Matched Rust's `Option` terminal contract by comparing each + `toolResult.isError` directly with `message.toolIsError`. An omitted marker is + no longer treated as equivalent to an explicit `false` marker. +- Added regressions for both asymmetric cases. Terminals with both markers + omitted and terminals with both markers explicitly `false` remain valid. + +## TDD and verification + +The initial Task 2 RED was 8 failing reducer/decoder tests. For the first +sequence review fix, the expanded 18-case suite was run against the pre-fix +production code and produced 15 failures / 3 passes. Failures covered all newly +requested sequence, identity, deep-validation, and retention behaviors. For +review fix round 2, the three retry-window regressions were added before the +production change; the focused suite then produced 2 failures / 18 passes, +covering delayed exact retry and conflicting sequence reuse. Round 3 initially +produced 7 failures / 21 passes across the new bounded-canonical and tool +terminal cases; the corrected sparse-collision fixture was also run alone and +failed with the old implementation silently treating it as a retry. Round 4's +two optional-error mismatch regressions both failed against the coalescing +validator while the 28 existing/aligned cases passed. + +GREEN verification on the final tree: + +- `pnpm -C web exec vitest run src/store/chatStore.test.ts --reporter=verbose` + — 30/30 focused tests passed. +- `pnpm -C web test -- src/store/chatStore.test.ts` + — the earlier round-2 full run passed 1277/1278 tests. Its sole integration + failure was the expected Task 3 + migration point: `AgentPanel.persistence.test.tsx` still expects legacy + `finalize(message)` to append an unaddressed Done event. The reviewed store + now deliberately fails that overload closed unless it exactly matches an + active message ID; Task 3 owns listener/reducer migration and the test update. +- `pnpm -C web build` — passed (`tsc -b` and Vite production build). +- `git diff --check` — passed. + +The build retains pre-existing Vite warnings about ineffective dynamic imports +and a chunk above 500 kB; neither warning is introduced by Task 2 and both +commands exit successfully. + +## Scope review + +Owned changes are limited to: + +- `web/src/lib/types.ts` +- `web/src/lib/api.ts` +- `web/src/store/chatStore.ts` +- `web/src/store/chatStore.test.ts` +- this report + +Concurrent Rust protocol, core/project/render, commands/home, and audit files +were not staged, changed, or reverted. A code-reviewer agent was requested but +the shared agent limit was full; the coordinator will run the independent +review after this task releases its slot. diff --git a/.superpowers/sdd/2026-08-13-beta5-agent-conversation/task-3-report.md b/.superpowers/sdd/2026-08-13-beta5-agent-conversation/task-3-report.md new file mode 100644 index 00000000..9db48480 --- /dev/null +++ b/.superpowers/sdd/2026-08-13-beta5-agent-conversation/task-3-report.md @@ -0,0 +1,232 @@ +# Task 3 report — continuous borderless Agent conversation + +Date: 2026-08-14 (Asia/Shanghai) + +Base: `64e3695` + +Integrated protocol/store follow-ups: `5c214b8`, `5015b02` + +Branch: `release/v1.0.0-beta.5` + +## Result + +- Replaced assistant bubbles and detached tool cards with one continuous, + borderless `AssistantTurn`. Authoritative `ChatMessage.blocks` render in + exact DOM order; only messages without `blocks` use the Beta 4 compatibility + fields. +- Grouped adjacent assistant/tool/assistant messages into one visible turn, so + native role=`tool` result blocks remain inline with the originating assistant + tool round and its follow-up text. User messages retain a quiet, separate + surface. +- Added inline tool disclosures through the shared `Reveal`: collapsed + running/complete/failed live status, accessible expansion state and controlled + region, readable arguments/results, bounded raster images with contextual alt + text, and fail-closed MIME/base64 handling. Tool details animate only block + size and opacity; reduced-motion disclosure is immediate and keeps trigger + focus. +- Removed Agent-local Chat/Motion mode state, controls, conditional rendering, + labels, and storage access. The selected chat session and unsent composer text + survive top-level AgentPanel unmount/remount. +- Migrated `AgentPanel` from the legacy proximity/finalize path to exact + `(sessionId, messageId, sequence, blockIndex)` reducers. All three listeners + subscribe independently, filter project identity, accept inactive-session + streams safely, and unsubscribe even when registration resolves after + unmount. +- Malformed/gapped streams request authoritative history once per session and + replace that session exactly. Re-syncs are bound to the project identity that + observed the event, so a queued old-project request cannot load into a Save As + replacement. The strict role=`tool` BlockUpsert/Done sequence finalizes without + leaving an orphan assistant draft. +- Session switching uses store-backed per-session history, closed sessions are + tombstoned, first-event/malformed handling releases the local pending-composer + lock, and local send errors are installed as exact block-backed messages. +- Added visible focus rings and accessible composer/action labels. Error and + tool status are expressed in text rather than color alone. + +## TDD evidence + +RED was observed before production changes with the brief's literal command: + +- The new conversation tests failed because `AssistantTurn` did not exist and + the old UI still rendered assistant bubbles, detached bordered tool cards, + and the Agent-local Chat/Motion switch. +- The persistence regressions failed against legacy unaddressed listeners and + finalize behavior, missing listener teardown/re-sync handling, and missing + navigation retention. +- Additional regressions were added and observed RED before their fixes for + native role=`tool` inline rendering, adjacent multi-round grouping, + authoritative user blocks, malformed first-event composer release, + old-project queued-gap isolation, composer accessibility, and strict + BlockUpsert(seq 0) / role=`tool` Done(seq 1) finalization. + +GREEN verification on the final integrated tree: + +- `pnpm -C web exec vitest run src/components/agent/AgentConversation.test.tsx src/components/agent/AgentPanel.persistence.test.tsx --reporter=verbose` + — 24/24 focused tests passed. +- `pnpm -C web exec vitest run src/components/agent --reporter=verbose` + — 25/25 Agent component tests passed. The existing MotionPanel test still + emits its pre-existing React `act(...)` warnings but passes. +- `pnpm -C web exec vitest run src/store/chatStore.test.ts --reporter=verbose` + — 28/28 ordered-store/decoder tests passed. +- `pnpm -C web test -- src/components/agent/AgentConversation.test.tsx src/components/agent/AgentPanel.persistence.test.tsx` + — 145/145 files and 1303/1303 tests passed (the current pnpm/Vitest command + form runs the complete suite). +- `pnpm -C web test -- src/components/agent` + — 145/145 files and 1303/1303 tests passed. +- `pnpm -C web build` — passed (`tsc -b` and Vite production build). +- `git diff --check` — passed. + +The build retains existing Vite warnings for ineffective dynamic imports and a +chunk above 500 kB; neither warning is introduced by Task 3 and the build exits +successfully. + +## Scope review + +Owned changes are limited to: + +- `web/src/components/agent/AgentPanel.tsx` +- `web/src/components/agent/AgentPanel.persistence.test.tsx` +- `web/src/components/agent/AgentConversation.test.tsx` +- `web/src/styles/components.css` +- `web/src/i18n/dict.ts` +- this report + +Concurrent Rust core/project/render, Tauri commands/home, audit artifacts, and +other task files were not staged, changed, or reverted. + +Commit target: `feat(agent): render tools inline in continuous replies` + +## Review fix round 1 — authoritative re-sync + +- Added `chat_history_authoritative`: a gap re-sync now waits for the exact + project/session turn to cross its terminal event boundary, without retaining + the turn-registry mutex across an async suspension, and only then reads the + durable history. Project replacement wakes the waiter and fails closed on + identity revalidation. +- Added project-generation and per-session version gates. A late startup + `chat_sessions` response or authoritative re-sync cannot overwrite a session + touched while the request was in flight, including an inactive session. +- Added atomic `resetProject(epoch, path)`, which clears all project-scoped + histories, drafts, sequence poison, re-sync state, versions, and tombstones + on a real identity change while preserving selection and composer state for + an ordinary same-project panel remount. +- Validated all `chat_history`, `chat_history_authoritative`, and + `chat_sessions` responses before they reach the store. Raster base64 is + bounded by the shared `MAX_CHAT_IMAGE_BASE64_CHARS` ceiling at both decode + and render boundaries. +- Tool disclosure status is now a live sibling described by the trigger; + Escape closes the disclosure, stops propagation, and retains trigger focus. +- Deleted the dead `MotionPanel` implementation/test and its obsolete + translation strings. + +Review RED was observed before each production fix: active-turn gaps installed +stale persisted history, startup snapshots overwrote touched inactive sessions, +project replacement retained same-ID state, the disclosure lacked the required +Escape/live-region relationship, oversized raster data rendered, and the dead +Motion panel remained reachable on disk. + +Final GREEN verification: + +- Focused Agent/store/API command-contract: 4 files, 64/64 tests passed. +- Rust `chat::tests`: 23/23 passed, including the exact terminal-boundary wait. +- Full Web suite: 144 files, 1313/1313 tests passed. +- `cargo clippy -p opentake-tauri --lib -- -D warnings` passed. +- `cargo build -p opentake-tauri` passed. +- `pnpm -C web build` passed (`tsc -b` and Vite production build). +- `git diff --check` passed. + +The existing Vite ineffective-dynamic-import and >500 kB chunk warnings, plus +the existing Rust `block v0.1.6` future-incompatibility notice, remain +non-failing and are outside Task 3. + +Review-fix scope additionally owns `src-tauri/src/chat.rs`, +`src-tauri/src/lib.rs`, `web/src/lib/api.ts`, `web/src/lib/types.ts`, +`web/src/store/chatStore.ts` and its test, and the two deleted MotionPanel +files. Concurrent editor audit artifacts were preserved and not staged. + +Review-fix commit target: `fix(agent): make conversation resync authoritative` + +## Review fix round 2 — exact-turn snapshots and resumable re-sync + +- Bound each authoritative request to the single `TurnCancel` owner observed at + request time. The owner now completes with an immutable clone of the durable + terminal history, so turn A returns its own snapshot even when turn B reserves + the same session before the waiting command resumes. +- Made authoritative store installation independent of `AgentPanel` mount + lifetime. Project-generation and per-session-version CAS still protect the + write; rejected writes requeue the same poisoned session, and a remount + resumes the queued request without allowing the startup session list to clear + or overwrite it. The selected session composer stays disabled while re-sync + is active. +- Split snapshot validation from the one-event ceiling: messages remain bounded + to 1 MiB, histories to 8 MiB per session, and session lists to 32 MiB / 256 + sessions. Aggregate bytes are counted incrementally without canonicalizing a + whole project-sized response at once. +- Integrated the deferred timeline-result gate needed by the empty-timeline + result path. The edit commits under the exact project identity lease, GPU + capture runs after that lease is released, and a final identity/cancellation + check discards any result captured across a project replacement. Capture + warnings remain non-transactional and never roll back the committed edit. + +Round 2 RED evidence was observed before production fixes: + +- The authoritative history regression timed out after turn A completed and + turn B registered, showing the old loop had rebound the request to B. +- The unmount regression left no installed snapshot, the CAS rejection left no + retry request, and the selected composer remained enabled during poison. +- A valid multi-message history above 1 MiB and a valid 256-session list were + rejected by the former event-sized decoder. +- The blocking capture fixture showed Save As timing out while the old chat gate + retained its project identity read lease across GPU work. + +Round 2 GREEN verification on the integrated tree: + +- `pnpm -C web exec vitest run src/components/agent/AgentPanel.persistence.test.tsx` + — 21/21 passed. +- `pnpm -C web exec vitest run src/store/chatStore.test.ts` + — 36/36 passed. +- `pnpm -C web test` — 144 files and 1317/1317 tests passed. +- `cargo test -p opentake-tauri --lib chat::tests` — 24/24 passed, + including exact-turn ownership and the deferred-capture project transition. +- `cargo clippy -p opentake-tauri --lib -- -D warnings` passed. +- `cargo build -p opentake-tauri` passed. +- `pnpm -C web build` passed (`tsc -b` and Vite production build). +- `git diff --check` passed for all owned round 2 files. + +`cargo test --workspace` advanced through the Agent, core, domain, media, and +motion unit suites before the live Chromium 4K smoke timed out after 180 seconds; +the two later Chromium cases then reported the shared gate as poisoned. These +three failures are confined to `opentake-motion/tests/chromium.rs` and do not +exercise the Task 3 chat/Web changes. + +Round 2 commit target: `fix(agent): bind resync to exact turn snapshot` + +## Final independent review — rejected authoritative request recovery + +- Replaced the empty authoritative-history rejection handler with a + mount-independent retry schedule. The request carries a bounded retry attempt + and is requeued after exponential backoff from 250 ms to a 4 s ceiling. +- Requeue still passes through the store's exact project-generation, + resyncing-session, and deleted-session guards. A project reset clears the + poison and makes an old timer a no-op; an ordinary panel unmount does not own + or discard the project-scoped repair. +- Added a component regression that rejects the first authoritative request, + verifies there is no immediate retry loop and the composer remains locked, + unmounts/remounts the panel, advances the retry clock, and observes the exact + terminal snapshot installation and poison cleanup. +- Updated the chat test bridge for the concurrent cancellable timeline-capture + trait signature; this is test-only plumbing with no chat behavior change. + +The regression was observed RED before production changes: after 5 seconds of +virtual time the authoritative API still had only one call and the resync state +remained poisoned. Final verification: + +- Focused Agent conversation/persistence/store: 3 files, 68/68 tests passed. +- Full Web suite: 144 files, 1318/1318 tests passed. +- `pnpm -C web build` passed (`tsc -b` and Vite production build). +- Rust `chat::tests`: 24/24 passed. +- `cargo clippy -p opentake-tauri --lib -- -D warnings` passed. +- `cargo build -p opentake-tauri` passed. +- Owned-file `git diff --check` passed. + +Final-review commit target: `fix(agent): retry failed history resync` diff --git a/.superpowers/sdd/2026-08-13-beta5-agent-conversation/task-4-report.md b/.superpowers/sdd/2026-08-13-beta5-agent-conversation/task-4-report.md new file mode 100644 index 00000000..382847a0 --- /dev/null +++ b/.superpowers/sdd/2026-08-13-beta5-agent-conversation/task-4-report.md @@ -0,0 +1,111 @@ +# Task 4 report — composited result after clearing the timeline + +## Outcome + +A successful admitted mutation now records authoritative visible-clip counts +before and after commit. A transition from `> 0` visible clips to `0` schedules +one post-commit capture. The tool result keeps its text summary first and then +adds a real `image/png` block. + +The empty result is rendered by the Rust GPU compositor at the project's bounded +canvas size. It contains a deterministic language-neutral empty-set marker and +the current clamped root-timeline playhead as an `HH:MM:SS:FF` bitmap timecode. +Non-empty inputs delegate to the existing strict authoritative compositor. + +Capture is deliberately outside the edit transaction. Capture failure preserves +the successful edit, emits only the fixed warning `Timeline preview unavailable.`, +and returns no image. Cancellation or a project/timeline revision change before +or after capture also prevents image publication. + +## Independent-review fixes + +Three medium-severity findings across the final independent-review rounds were +resolved in follow-up TDD cycles: + +- The original dispatch `MediaCancelToken` now crosses `finish_dispatch`, the + `MediaBridge` boundary, and the production Tauri bridge into + `render_timeline_result_png`. The post-render identity check remains in place, + and the dispatcher still performs its final cancellation/revision check before + publishing any image. +- Authoritative visibility-probe errors are no longer collapsed through + `.ok()?`. The dispatch receipt records a distinct sanitized-warning state; + completion appends `Timeline preview unavailable.` immediately after the text + summary without rolling back the committed edit or exposing bridge details. + Unchanged timelines short-circuit before the probe so unrelated metadata or + workflow mutations do not receive a false preview warning. + +The follow-up RED evidence was: + +- the visibility regression committed the deletion but failed because no warning + block was present; +- the production cancellation regression failed to compile because the bridge + contract accepted no request token; +- the independent-review unchanged-timeline regression failed because a + non-visual mutation incorrectly received the preview warning. + +## RED evidence + +The required tests were written before production implementation. + +- `cargo test -p opentake-agent mcp::dispatch::tests::timeline_image_ -- --nocapture` + failed to compile because `TimelineResultCaptureRequest` and the new + `MediaBridge` visibility/capture methods did not exist. +- `cargo test -p opentake-tauri render::tests::empty_timeline_ --lib -- --nocapture` + failed to compile because `EmptyTimelineCanvasInput`, + `render_timeline_result_png`, and the PNG bound/semantic canvas contracts did + not exist. + +## Implementation + +- `crates/opentake-agent/src/mcp/media_bridge.rs` + - Added mutation/capture receipts and the host-side authoritative visibility + and result-capture bridge methods. + - Added the 1 MiB base64 response ceiling shared with persisted chat. +- `crates/opentake-agent/src/mcp/dispatch.rs` + - Split dispatch into commit and post-commit completion phases so host project + gates can release lifecycle locks before GPU work. + - Schedules exactly one capture only for a successful admitted non-Undo + mutation whose authoritative count changes from nonzero to zero. + - Rejects canceled/stale/malformed/oversized capture output and inserts only a + fixed sanitized warning; successful output is ordered text then PNG. +- `src-tauri/src/render.rs` + - Reused the authoritative render-plan visibility semantics. + - Added bounded empty-canvas composition, deterministic semantic marker and + bitmap timecode, PNG encoding, and current root playhead tracking. + - Proved non-empty input is byte-identical to the strict authoritative + compositor path. +- `src-tauri/src/mcp.rs` + - Added the production bridge, bounded base64 conversion, and exact + epoch/path/version/timeline checks before and after GPU work. + - Changed the MCP project gate to commit under its project identity lease, + release the lease for capture, and recheck identity before returning. + +The chat project gate uses the same deferred sequence in its Task 3-owned +`src-tauri/src/chat.rs` integration: commit under the lease, release, capture, +then perform a short final project check. + +## Verification + +- `cargo check -p opentake-tauri` — passed. +- Focused mutation tests — 12 passed, 0 failed. Covers last-visible deletion, + deletion leaving content, non-visual mutation, rollback, pre/during-capture + cancellation, Undo, batch single capture with exact counts, stale revision, + sanitized capture failure, and sanitized visibility-probe failure. +- `cargo test -p opentake-agent mcp:: -- --nocapture` — 184 passed, 0 failed. +- Tauri production bridge focused tests — 3 passed, 0 failed (real bounded PNG, + request-token cancellation, and stale-project rejection). +- Tauri live-project MCP gate focused tests — 4 passed, 0 failed. +- `cargo test -p opentake-tauri --lib render::tests:: -- --nocapture` — 17 + passed, 0 failed, including the authoritative visibility and two Task 4 + compositor fixtures. +- Task 3-owned chat gate regression after deferred integration — 24 passed, 0 + failed, reported by that file's owner. +- `cargo fmt --all -- --check` — passed. +- `cargo clippy -p opentake-agent -p opentake-tauri --all-targets -- -D warnings` + — passed. Cargo emitted only the pre-existing future-incompatibility notice for + dependency `block v0.1.6`. +- `git diff --check` on the four owned implementation files — passed. + +The workspace-wide `cargo test` was intentionally not duplicated because the +Task 3 owner was already running it against the same shared build directory. +No `docs/audit/2026-08-07` path was edited, staged, or committed by Task 4. diff --git a/.superpowers/sdd/2026-08-13-beta5-external-mcp/task-6-report.md b/.superpowers/sdd/2026-08-13-beta5-external-mcp/task-6-report.md new file mode 100644 index 00000000..e3879196 --- /dev/null +++ b/.superpowers/sdd/2026-08-13-beta5-external-mcp/task-6-report.md @@ -0,0 +1,122 @@ +# Task 6 report — real restart and security matrix + +Date: 2026-08-14 + +## Status + +Complete. The opt-in integration test exercised the real loopback Streamable HTTP listener, formal `rmcp` client, temporary persisted catalog, and uniquely namespaced macOS Keychain credentials. No user-owned `docs/audit/2026-08-07/**` file was modified by this task. + +## Implementation + +- Added `src-tauri/tests/external_mcp_integration.rs` as a feature-required target; once selected, it retains a safe runtime skip and explicit `OPENTAKE_RUN_REAL_KEYCHAIN_MCP=1` opt-in. +- Added a narrow integration harness around the production `ExternalMcpState`, gated behind the disabled-by-default `external-mcp-integration` feature and a `required-features` integration target. Normal library and release builds contain no public harness surface. Network admission still uses the production bearer authorizer, Host/Origin checks, dispatcher, request scopes, live-project gate, and listener lifecycle. +- Used the formal `rmcp 2.2.0` Streamable HTTP client for initialization, discovery, and tool calls. Raw `reqwest` is limited to adversarial Host/Origin probes. +- Pinned `sse-stream = 0.2.4` as a dev dependency because rmcp 2.2's reqwest transport calls the compatibility alias introduced in that patch release. +- Captured tracing events, child-process output, matrix logs, and all temporary catalog files; compared their raw bytes against every complete generated bearer and required zero matches. +- Used one UUID-suffixed Keychain service per run, tracked only the exact accounts returned by this run's pairing receipts, deleted only those accounts, and verified each deletion by readback. +- Replaced the cancellation probe's lossy `notify_waiters` edge with a stored `notify_one` permit and added a 2,048-iteration concurrent signal/wait regression. +- Strengthened the live undo row to inspect folder state after creation, rejected foreign undo, and successful owner undo. + +## Matrix result + +- Authenticated restart using the same temporary catalog and Keychain namespace: PASS. +- Targeted revoke while a surviving credential and listener remain usable: PASS. +- Remote Host and Origin rejection with a valid bearer: PASS (HTTP 403). +- Project-switch cancellation of an in-flight rmcp `import_media`, with no media committed to the new project: PASS. +- Cross-session undo isolation with owner undo preserved: PASS. +- Fixed-port conflict without fallback binding, followed by recovery: PASS. +- Socket closure after disable: PASS. +- Socket closure after an actual child process exits while its listener is alive: PASS. +- Full generated bearer scan across captured logs/process output/catalog bytes: PASS, zero matches. +- Exact Keychain account cleanup and readback: PASS. + +## TDD evidence + +### RED + +The integration test was written before its public seam and dependencies. Its first `--no-run` build failed because `rmcp` was unresolved, `external_mcp` was private, and `ExternalMcpIntegrationHarness` did not exist. + +The next build exposed a version-specific dependency failure: rmcp 2.2 called `SseStream::from_bytes_stream`, while the lock selected sse-stream 0.2.3 with only the older `from_byte_stream` spelling. Exact 0.2.4 resolution fixed compilation. The first live test then reproduced a rustls no-provider panic; the test process now installs the existing ring provider before constructing the rmcp reqwest transport. + +### GREEN + +```text +OPENTAKE_RUN_REAL_KEYCHAIN_MCP=1 cargo test -p opentake-tauri --features external-mcp-integration --test external_mcp_integration -- --nocapture +2 passed; all live matrix rows passed; credential scan zero matches + +cargo test -p opentake-tauri --features external-mcp-integration --test external_mcp_integration -- --nocapture +2 passed; safe non-opt-in path + +cargo test -p opentake-tauri --features external-mcp-integration external_mcp::tests::integration_cancel_probe_never_loses_a_concurrent_entry_signal --lib +1 passed; 2,048 concurrent signal/wait iterations + +cargo test -p opentake-tauri --test external_mcp_integration --no-run +EXPECTED REFUSAL; target requires `external-mcp-integration` + +cargo test -p opentake-tauri --no-default-features --test external_mcp_integration --no-run +EXPECTED REFUSAL; target requires `external-mcp-integration` + +cargo test -p opentake-tauri external_mcp::tests --lib +40 passed + +cargo test -p opentake-agent mcp::server::tests -- --nocapture +30 passed +``` + +Final strict Clippy, fmt, diff, and repeated live-keychain results are recorded after their last execution below. + +### Final static checks + +```text +cargo clippy -p opentake-tauri --features external-mcp-integration --test external_mcp_integration -- -D warnings +BLOCKED outside Task 6: concurrent `render.rs` `dead_code` + +cargo clippy -p opentake-tauri --no-default-features --features external-mcp-integration --test external_mcp_integration -- -D warnings +BLOCKED outside Task 6: concurrent `render.rs` `dead_code` / `too_many_arguments` + +cargo clippy -p opentake-tauri --features external-mcp-integration --test external_mcp_integration -- -D warnings -A dead-code +PASS + +cargo clippy -p opentake-tauri --no-default-features --features external-mcp-integration --test external_mcp_integration -- -D warnings -A dead-code -A clippy::too-many-arguments +PASS + +rustfmt --check --edition 2021 src-tauri/src/external_mcp.rs src-tauri/tests/external_mcp_integration.rs +PASS + +cargo fmt --all -- --check +BLOCKED outside Task 6: concurrent composite files are not yet rustfmt-clean +``` + +Default and `--no-default-features` library builds were also inspected with +`nm`; neither contained `ExternalMcpIntegrationHarness` nor +`IntegrationCancelProbe` symbols. + +For the review fix, scoped formatting checks passed for all three owned Rust +files. Strict targeted Clippy reached only concurrent composite-renderer +diagnostics outside this task: `src-tauri/src/render.rs:270,275` (`dead_code`) +and the no-default build's `src-tauri/src/render.rs:1028` +(`too_many_arguments`). Re-running the same default and no-default Task 6 +targets while allowing only those exact unrelated lint classes passed. Full +workspace formatting was likewise deferred because the concurrently edited +composite files were not yet rustfmt-clean; no Task 6-owned formatting diff was +reported. The parent integration will rerun the unqualified workspace gates +after the composite owner converges. + +`cargo clippy --workspace --all-targets -- -D warnings` was run and did not reach a clean workspace result: it failed in concurrently landed Task 7 code at `src-tauri/src/commands.rs:2552-2553` (`field_reassign_with_default`). Task 6 neither owns nor modified that file. The parent task was notified so the workspace gate can be rerun after its owner fixes the unrelated lint. + +## API and security review + +The `external_mcp` module, public listener-state enum, and two harness DTOs are visible only with the disabled-by-default `external-mcp-integration` feature. The integration target declares that feature through `required-features`; ordinary default, no-default-feature, and release builds keep the module private and omit all harness code. Existing production status/client/pairing DTOs remain crate-private. The harness is not registered as a Tauri command, cannot obtain stored bearer values, and cannot bypass transport gates. It accepts a caller-selected Keychain service only so the integration test never shares the production service namespace. + +The test never formats a bearer into assertion failures or reports. Authentication errors are deliberately collapsed to `Result<_, ()>`. Cleanup is account-exact rather than service-wide. The fixed listener stays loopback-only. + +## Limitations + +- Real Keychain execution is opt-in because CI login sessions and macOS permission prompts are environment-dependent; it was actually run locally. +- The cancellation bridge is deterministic test plumbing around the real transport/dispatcher/project gate and does not invoke a real media decoder. +- The child-process case proves OS-level socket release on process exit, but does not boot the complete Tauri GUI event loop; the explicit application-shutdown lifecycle has separate unit coverage. +- Independent security review found and drove fixes for the public harness surface, cancellation-probe lost wakeup, and missing live undo state assertions. + +## Commit + +Review-fix commit message: `test(mcp): isolate persistent connection harness` diff --git a/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-2-report.md b/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-2-report.md new file mode 100644 index 00000000..a15a7301 --- /dev/null +++ b/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-2-report.md @@ -0,0 +1,64 @@ +# Task 2 implementer report + +Status: DONE + +## Scope + +- Removed the persisted `Theme` setting, actions, startup initializer, Appearance theme control, and obsolete `Dropdown` theme reference. +- Startup now removes legacy and versioned theme keys without setting a document theme marker; dark tokens are defined directly at `:root`. +- Replaced Appearance with the two requested dark-layout radio cells: `深色 · 标准` and `深色 · 紧凑`. They are equal-width, contain no selected-state icon, and preserve label geometry. +- Made window resize transactional: state updates for immediate feedback, persistence commits after native success, failures restore the previous selection and show a toast. +- Serialized startup and user native resize requests. A stale request cannot overwrite a later selection; a post-size positioning failure restores the original native size and position. +- Preserved unrelated audit and external-MCP worktree changes. + +## TDD evidence + +### RED + +Command: + +```text +pnpm -C web test -- src/store/settingsStore.test.ts src/components/settings/SettingsView.interaction.test.tsx src/components/settings/SettingsView.visual.test.ts +``` + +Observed result: exit 1. The legacy `theme` value remained, `setWindowSize` returned `undefined` and persisted optimistically, failure did not restore the standard choice, and Appearance contained no radiogroup. + +### GREEN + +Commands: + +```text +pnpm -C web exec vitest run src/store/settingsStore.test.ts src/components/settings/SettingsView.visual.test.ts +pnpm -C web test -- src/store/settingsStore.test.ts src/components/settings/SettingsView.interaction.test.tsx src/components/settings/SettingsView.visual.test.ts +pnpm -C web exec vitest run src/App.lifecycle.test.tsx +pnpm -C web build +git diff --check +``` + +Observed results: + +- Focused store/visual tests: 2 files, 16 tests passed. +- Web test command: 144 files, 1230 tests passed. The package script forwards selectors after `--` to Vitest, which executes the full suite. +- App lifecycle: 1 file, 12 tests passed. +- Production TypeScript/Vite build: exit 0. +- `git diff --check`: exit 0. + +## Self-review + +- Review found and this task fixed two native-geometry race/partial-failure issues: stale native operations are serialized, and a `setPosition` failure restores the original geometry. +- Tests cover migration, native-success persistence timing, failed resize rollback/toast, size/position partial failure, stale/later choices, startup-versus-selection serialization, keyboard radiogroup behavior, and visual geometry. +- The build retains existing ineffective-dynamic-import and >500 kB chunk warnings; no new build failure occurred. + +## Commit + +`fix(settings): keep only stable dark window layouts` + +## Concerns + +None blocking. + +## Follow-up review — legacy marker removal + +- A scoped re-review found the `data-theme="dark"` compatibility marker had no CSS consumer. Removed the write rather than retaining dead state. +- Updated migration and App lifecycle test naming to assert no document theme marker or initializer remains. +- Follow-up verification is recorded in commit `fix(settings): remove legacy theme marker`. diff --git a/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-3-report.md b/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-3-report.md new file mode 100644 index 00000000..3f8270d8 --- /dev/null +++ b/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-3-report.md @@ -0,0 +1,104 @@ +# Task 3 implementer report + +Status: DONE + +## Scope + +- Replaced direct model-confirmation insertion with the approved shared `Reveal` inside the fixed models row. +- Kept the normal clear control mounted and disabled while confirmation/deletion is active. +- Preserved disclosure content through its exit lifecycle; cancel restores focus to the model clear control and successful deletion focuses the next available clear control. +- Kept model-specific backend errors visible inside the still-open confirmation; successful/cancelled flows close through `Reveal`, including synchronous reduced-motion closure. + +## TDD evidence + +### RED + +Command: + +```text +pnpm -C web test -- src/components/settings/StoragePane.test.tsx +``` + +Observed result: exit 1 with five expected interaction/lifecycle failures. The pre-change direct conditional confirmation had no model-row disclosure wrapper, did not retain copy for exit, did not restore focus after cancel, and had no reduced-motion lifecycle. + +### GREEN + +Command: + +```text +pnpm -C web exec vitest run src/components/settings/StoragePane.test.tsx src/components/ui/Reveal.test.tsx +``` + +Observed result: exit 0; 2 test files and 20 tests passed. + +Command: + +```text +pnpm -C web build +``` + +Observed result: exit 0. Existing dynamic-import and bundle-size warnings remain unchanged. + +Command: + +```text +git diff --check +``` + +Observed result: exit 0, no whitespace errors. + +## Self-review + +- The disclosure is a child of the models category row, so sibling category structure remains stable and the measured wrapper owns vertical movement. +- Clear, remove, and cancel actions all obey the in-flight lock; failed model deletion leaves the confirmation active for an actionable retry. +- The tests cover disclosure placement/no duplicate confirmation, exit retention, cancel and success closure, deletion locking, error placement, focus behavior, and reduced motion. + +## Commit + +`a1d5100 fix(settings): animate model removal confirmation` + +## Review fix round 1 + +### Findings addressed + +- Added a mounted flag and monotonically increasing operation epoch. Resolve, reject, and `finally` paths now discard late results before any state or focus intent is written. +- Replaced the one-way post-model focus lookup with a stable order: later enabled clear actions, then earlier enabled clear actions in reverse proximity, then the programmatically focusable Storage pane. +- Preserved the existing `Reveal` enter/exit and reduced-motion behavior. + +### TDD evidence + +RED command: + +```text +pnpm -C web exec vitest run src/components/settings/StoragePane.test.tsx +``` + +Observed result: exit 1; 2 focus-fallback tests failed because focus fell to `body` when the `other` clear action was disabled. Unmount-before-resolve and unmount-before-reject lifecycle cases were also added to cover late completion without DOM/focus changes or React errors. + +GREEN command: + +```text +pnpm -C web exec vitest run src/components/settings/StoragePane.test.tsx src/components/ui/Reveal.test.tsx +``` + +Observed result: exit 0; 2 files and 24 tests passed. + +Build command: + +```text +pnpm -C web build +``` + +Observed result: exit 0. Existing dynamic-import and bundle-size warnings remain unchanged. + +Diff command: + +```text +git diff --check +``` + +Observed result: exit 0, no whitespace errors. + +### Review fix commit + +`fix(settings): stabilize model clear lifecycle` diff --git a/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-4-report.md b/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-4-report.md new file mode 100644 index 00000000..dab836af --- /dev/null +++ b/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-4-report.md @@ -0,0 +1,88 @@ +# Task 4 implementer report + +Status: DONE + +## Scope + +- Moved the sole Library Home action from the content header to the top of the left category rail. +- Passed the navigation callback into `CategoryTree`; category state remains owned by `libraryStore` and therefore survives a return to Home and Library re-entry. +- Added rail-specific styling that applies the shared `--titlebar-safe-top` token and uses the title-bar control-size token with a 26px fallback. +- Kept the right header limited to its title, search, and sort controls. There is no alternate/mobile Home rendering. + +## TDD evidence + +### RED + +Command: + +```text +pnpm -C web test -- src/components/media/LibraryView.test.tsx +``` + +Observed result: exit 1. The new navigation test failed exactly as expected: the rail's first button was `All`, while the lone `Back to Home` action was in the content header. + +The package-script invocation also discovered pre-existing concurrent MCP work: `ExternalMcpPane.test.tsx` could not resolve its companion component. That separate failure was not modified by this task. + +### GREEN + +Commands: + +```text +pnpm -C web exec vitest run src/components/media/LibraryView.test.tsx --reporter=verbose +pnpm -C web build +git diff --check +``` + +Observed results: + +- Focused Library suite: 1 file, 7 tests passed. +- Production TypeScript/Vite build: exit 0. +- Diff check: exit 0, no whitespace errors. +- The build retained the repository's existing ineffective-dynamic-import and >500 kB chunk warnings. + +The full `pnpm -C web test` suite was also run after concurrent MCP files appeared. It finished with 144 files / 1247 tests passing and 1 MCP-only file / 5 tests failing in `ExternalMcpPane.test.tsx`; those assertions concern pairing API error and receipt behavior, not Library navigation, and are outside this task's owned files. + +## Self-review + +- `CategoryTree` renders exactly one accessible Home button before all category buttons, below the title-bar safe area. +- The test exercises actual navigation and category controls, asserts the Home control stays absent from the header while each built-in category is selected, and confirms the active Video category after re-entry. +- The change is confined to the three assigned Library files plus this required task report; concurrent settings/MCP and audit files were preserved. + +## Commit + +`fix(library): place Home navigation in the global rail` + +## Review fix round 1 + +### Finding addressed + +- The rail's inline `padding: 0 ...` shorthand overrode the class-level safe-area padding in the rendered cascade. The inline shorthand now uses `var(--titlebar-safe-top)` directly, so its highest-precedence declaration preserves the title-bar clearance. +- Added a rendered CSS regression test that loads the real component stylesheet, assigns a concrete 44px safe-area token, and asserts the rail's computed `padding-top` is 44px. + +### TDD evidence + +RED command: + +```text +pnpm -C web exec vitest run src/components/media/LibraryView.test.tsx --reporter=verbose +``` + +Observed result: exit 1; the new computed-style test received `0px` instead of `44px`. + +GREEN commands: + +```text +pnpm -C web exec vitest run src/components/media/LibraryView.test.tsx --reporter=verbose +pnpm -C web build +git diff --check +``` + +Observed results: + +- Focused Library suite: 1 file, 8 tests passed. +- Production TypeScript/Vite build: exit 0; existing dynamic-import and bundle-size warnings remain. +- Diff check: exit 0, no whitespace errors. + +### Review fix commit + +`fix(library): preserve titlebar safe area` diff --git a/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-5-report.md b/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-5-report.md new file mode 100644 index 00000000..3fc356a0 --- /dev/null +++ b/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-5-report.md @@ -0,0 +1,101 @@ +# Task 5 implementer report + +Status: DONE + +## Scope + +- Removed Home's generation-activity state, request, region, dedicated tests, and unused front-end API wrapper; backend audit storage remains unchanged. +- Replaced the 48px project placeholder with responsive 16:9 semantic preview figures, covered real thumbnails, and a named structural fallback. +- Preserved card selection/open, keyboard Enter, loading, and context-menu flows. + +## TDD evidence + +### RED + +`pnpm -C web test -- src/components/home/HomeView.test.tsx src/components/home/HomeView.interaction.test.tsx src/components/home/HomeView.visual.test.ts` + +Exit 1: new assertions proved the old Home still called `generationLog`, retained its generation section, and rendered neither semantic 16:9 preview figures nor the named structured fallback. + +### GREEN + +- `pnpm -C web exec vitest run src/components/home/HomeView.test.tsx src/components/home/HomeView.interaction.test.tsx src/components/home/HomeView.visual.test.ts --reporter=verbose` — 3 files / 39 tests passed. +- `pnpm -C web test` — 144 files / 1255 tests passed. +- `pnpm -C web build` — TypeScript and Vite production build passed. +- `git diff --check` — no whitespace errors. + +## Self-review + +- `generationLog` had no remaining front-end consumer after this change, so its wrapper was removed while the core command/storage was untouched. +- Thumbnail rendering still requires native path validation and falls back after image failure, offline, or missing projects. +- The wrapper preserves right-click handling; native card focus, Enter-to-open, double-click, action locking, and unavailable-project guards remain covered by existing tests. + +## Commit + +`fix(home): simplify activity and show useful project previews` + +## Review fix round 1 — truthful preview metadata + +Review found that the first fallback invented a `16:9` project ratio and three +tracks even though Home had no corresponding project metadata. The fallback +now has two explicit states: + +- When native Home validation can decode explicit, positive canvas dimensions + and at most 64 valid track types from the retained, no-follow + `project.json`, the DTO/cache/card show that real ratio and those real track + kinds. A `1080×1920` two-track fixture verifies `9:16` plus exactly video and + audio rows. +- When metadata is missing, malformed, unsafe, oversized, or unavailable, the + DTO omits `preview` and the card renders only its actual project name plus a + non-quantified decorative structure. It claims neither a ratio nor a track + count. The card surface itself remains responsive 16:9. + +The localStorage startup cache validates positive i32 dimensions, the five +domain track kinds, and the same 64-track bound before retaining an optional +preview. Successful saves and opens also refresh the cached preview from the +authoritative in-memory Rust timeline snapshot, so the card does not have to +wait for a later Home filesystem probe. + +### RED + +- `cargo test -p opentake-tauri filesystem_probe_reports_explicit_canvas_and_actual_track_kinds --lib` — failed because `preview.canvasWidth` was null. +- `pnpm -C web test --run src/components/home/HomeView.test.tsx src/store/recentStore.test.ts` — 3 expected failures: the fallback still claimed `16:9`/three tracks, the portrait fixture still rendered `16:9`, and valid cached metadata was discarded. + +### GREEN + +- `cargo test -p opentake-tauri home::tests --lib` — 16 passed. +- `pnpm -C web test --run src/components/home/HomeView.test.tsx src/components/home/HomeView.interaction.test.tsx src/store/recentStore.test.ts` — 3 files / 31 tests passed. +- `pnpm -C web test --run` — 144 files / 1264 tests passed. +- `pnpm -C web build` — TypeScript and Vite production build passed (existing dynamic-import and chunk-size warnings only). +- `cargo fmt --check` — passed. +- `cargo clippy -p opentake-tauri --lib -- -D warnings` — passed (Cargo emitted only the existing future-incompatibility notice for `block 0.1.6`). +- `git diff --check` — no whitespace errors. + +### Commit + +`fix(home): use truthful project preview metadata` + +## Review fix round 2 — require complete preview metadata + +The follow-up Rust review found that serde's default for an absent `tracks` +field incorrectly looked like an authoritative empty track list. The partial +wire model now keeps `tracks` optional and emits a Home preview only when +`width`, `height`, and `tracks` were all explicit and valid. An explicit +`tracks: []` remains a truthful zero-track project. + +### RED + +- `cargo test -p opentake-tauri filesystem_probe_omits_preview_when_tracks_are_not_explicit --lib` — failed because a `{ width, height }` project still serialized `preview`. + +### GREEN + +- `cargo test -p opentake-tauri filesystem_probe_omits_preview_when_tracks_are_not_explicit --lib` — 1 passed, covering both omitted tracks and explicit empty tracks. +- `cargo test -p opentake-tauri home::tests --lib` — 17 passed. +- `pnpm -C web test --run src/components/home/HomeView.test.tsx src/components/home/HomeView.interaction.test.tsx src/store/recentStore.test.ts` — 3 files / 32 tests passed. +- `cargo fmt --check` — passed. +- `cargo clippy -p opentake-tauri --lib -- -D warnings` — passed (existing `block 0.1.6` future-incompatibility notice only). +- `pnpm -C web build` — TypeScript and Vite production build passed (existing dynamic-import and chunk-size warnings only). +- `git diff --check` — no whitespace errors. + +### Commit + +`fix(home): require complete preview metadata` diff --git a/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-6-report.md b/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-6-report.md new file mode 100644 index 00000000..83b78e67 --- /dev/null +++ b/.superpowers/sdd/2026-08-13-beta5-interface-polish/task-6-report.md @@ -0,0 +1,137 @@ +# Task 6 implementer report + +Status: COMPLETE — review round 2/5 remediations and CloseRequested parity are implemented. + +## Scope completed + +- Replaced ordinary project Save's source-only cover grab with a stable frame rendered by the existing preview/export `composite_timeline_frame` compositor. The media layer owns only representative-frame selection, bounded 16:9 cover geometry, and deterministic JPEG encoding; it does not contain alternate render logic. +- Selected a visible, resolvable clip midpoint, preferring the midpoint of a valid outgoing cross-dissolve. Empty and all-offline projects produce no replacement bytes. +- Preserved the prior thumbnail whenever representative selection, composite rendering, RGBA conversion, JPEG encoding, or the project writer's atomic replacement fails. +- Made Home advertise only bounded, regular, non-symlink JPEG thumbnails. Invalid prior bytes remain on disk but are not exposed to the UI. +- Added real authoritative-render evidence for background transition mixing, a transformed overlay, overlay bounds, and styled text, plus deterministic bounds, empty/offline, invalid-prior, capture-failure, stale-project, and atomic-write-failure coverage. + +## TDD evidence + +### RED + +```text +cargo test -p opentake-media thumbnail::project::tests::composite_ -- --nocapture +``` + +Exit 101: three assertions failed against the source-only implementation: layered output remained the source red, a `200×200` bound yielded `320×180` instead of `192×108`, and the representative frame was `0` instead of the transition midpoint `25`. Empty and offline cases already failed closed. + +```text +cargo test -p opentake-tauri home::tests::thumbnail_ --lib -- --nocapture +``` + +Exit 101: Home advertised invalid `thumbnail.jpg` bytes instead of retaining them without exposing them. + +### Phase 1 GREEN + +```text +cargo test -p opentake-media thumbnail::project::tests -- --nocapture +cargo test -p opentake-tauri home::tests::thumbnail_ --lib -- --nocapture +cargo test -p opentake-tauri project_open_async_tests::thumbnail_ --lib -- --nocapture +cargo clippy -p opentake-media --lib -- -D warnings +cargo check -p opentake-tauri --lib --no-default-features +``` + +Observed results: + +- Media project-thumbnail suite: 12 passed, including all five `composite_` cases. +- Home thumbnail suite: 2 passed. +- Tauri project thumbnail suite: 3 passed, including the real FFmpeg/GPU authoritative compositor fixture. +- Media clippy and Tauri no-default-features check passed. + +## Commit + +`feat(home): save composited project cover frames` + +## Review round 1/5 remediation + +- Strict cover mode now reports any planned image, video, text, or Lottie materialization failure. `CaptureFailed` preserves the prior component; `NoVisibleContent` explicitly removes it; `Captured` atomically replaces it. +- External media is accepted only from a persisted/dialog scope after validating both requested and retained final paths. Project media is opened solely through the session's retained `ProjectRoot` no-follow authority. Strict rendering has no ambient pathname fallback. +- The representative frame is selected by the authoritative flattened render plan, including its sorted clip order and transition adjacency, and requires a finite, non-degenerate, non-transparent meaningful draw. +- Cover geometry changed from crop/fill to opaque-black resize-to-fit. Portrait and 4:3 canvas boundary tests prove no authored canvas content is cropped. +- `project_save` is asynchronous. Capture, JPEG encode, and save run on a bounded blocking worker with a cancellation/deadline checkpoint and final project identity check before commit. `CloseRequested` awaits the same helper asynchronously before hiding the window. +- The project/core storage contract now carries `ThumbnailUpdate::{Preserve, Replace, Remove}` while retaining the former `Option>` methods as compatibility wrappers. + +### Additional RED evidence + +```text +cargo test -p opentake-media thumbnail::project::tests::composite_thumbnail_letterboxes_ -- --nocapture +``` + +Exit 101: portrait and 4:3 cases showed cropped content at the exact expected black-bar boundaries. + +```text +cargo test -p opentake-render representative_frame_ --lib -- --nocapture +``` + +Exit 101: `RenderPlan::representative_frame` did not exist; selection was still an independent stored-track traversal. + +```text +cargo test -p opentake-project explicit_thumbnail_removal_deletes_only_the_retained_optional_component --lib -- --nocapture +``` + +Exit 101: `ThumbnailUpdate` did not exist, so no-visible and capture-failure outcomes were indistinguishable. + +### Final focused GREEN evidence + +```text +cargo test -p opentake-project explicit_thumbnail_removal_deletes_only_the_retained_optional_component --lib +cargo test -p opentake-core explicit_thumbnail_remove_is_distinct_from_capture_failure_preserve --lib +cargo test -p opentake-render representative_frame_ --lib +cargo test -p opentake-media thumbnail::project::tests::composite_thumbnail_letterboxes_ --lib +cargo test -p opentake-tauri project_open_async_tests --lib -- --test-threads=1 +cargo clippy -p opentake-project -p opentake-core -p opentake-media -p opentake-render --all-targets -- -D warnings +cargo clippy -p opentake-tauri --lib -- -D warnings +rustfmt --edition 2021 --config skip_children=true +git diff --check +``` + +Observed results: project 1/1, core 1/1, render 3/3, media 2/2, and Tauri 19/19 passed. The Tauri group covers corrupt image/video/text, unauthorized external media, retained project-local media, layered output, stale identity, cancellation-before-commit, tri-state removal/preservation, and CloseRequested parity. Strict clippy passed for all changed library targets and the Tauri library. After those runs, concurrent chat changes temporarily broke the Tauri test target (`BlockDeltaPayload` / `assistant_with_id` / `DonePayload.message_id` mismatch), so the final post-checkpoint compile evidence is `cargo check -p opentake-core -p opentake-tauri`. Task 6 files pass rustfmt and diff checks; workspace-wide fmt and `--all-targets` Tauri clippy remain blocked by those unrelated concurrent chat edits. + +## Review round 2/5 remediation + +- Restored the source-compatible public `Project.thumbnail: Option>`. Explicit authoritative removal now travels through additive `save_to_root_with_thumbnail_update` and `publish_complete_to_with_thumbnail_update` APIs; ordinary callers retain the original `Some = replace`, `None = preserve` contract. +- Replaced the single-midpoint representative probe with deterministic groups derived from flattened render-plan span boundaries, opacity/position/scale/rotation/crop keyframes, fade boundaries, and validated transition intervals. Midpoint remains the stable first choice, but candidates at and around every visual event prevent animated opacity or scale from making a visible project look empty. +- Added a capture → precommit → commit atomic handshake. Timeout atomically cancels capture/precommit work and may then return; if the worker has entered commit, the async Save/Close caller awaits it and reports the actual publication result. The final transition into commit occurs under the existing session identity checkpoint immediately before persistence. +- Added a barrier regression after the identity checkpoint and before publication. It triggers timeout, observes the old thumbnail at response time, releases the detached worker, and proves that worker still cannot publish afterward. A complementary test proves a commit already in progress returns its real result instead of a timeout. + +### Round 2 RED evidence + +```text +cargo test -p opentake-project --test roundtrip project_thumbnail_field_remains_option_compatible --no-run +``` + +Exit 101: assigning `Option>` to `Project.thumbnail` failed because round 1 had changed the public field to `ThumbnailUpdate`. + +```text +cargo test -p opentake-render representative_frame_finds_ -- --nocapture +``` + +Exit 101: both opacity and scale fixtures returned `None` instead of frame 8 because only frame 4 (the invisible midpoint) was sampled. + +```text +cargo test -p opentake-tauri timed_out_cover_precommit_barrier_prevents_post_response_publication --lib --no-run +``` + +Exit 101: the precommit gate, timeout await helper, and gate-bound save helper did not exist. + +### Round 2 GREEN evidence + +```text +cargo test -p opentake-project +cargo test -p opentake-core +cargo test -p opentake-render +cargo test -p opentake-media +cargo test -p opentake-tauri commands::project_open_async_tests --lib -- --nocapture +cargo test -p opentake-tauri home::tests::thumbnail_ --lib -- --nocapture +cargo test -p opentake-tauri --lib +cargo clippy -p opentake-project -p opentake-core -p opentake-render -p opentake-media -p opentake-tauri --all-targets -- -D warnings +cargo fmt --all -- --check +git diff --check +``` + +Observed results: project 166 unit + all integration suites passed; core 70 unit + all integration suites passed; render 63 unit + all integration suites passed; media 432 passed / 1 environment-gated ignored plus all integration suites (one Main10 fixture test ignored); Tauri focused project-save/open 21/21 and Home thumbnail 2/2 passed; the complete Tauri library suite passed 667/667. Strict all-target clippy, workspace rustfmt, and diff checks passed. Unrelated concurrent chat/web and audit working-tree files were not included. diff --git a/.superpowers/sdd/2026-08-13-beta5-motion-studio/task-1-report.md b/.superpowers/sdd/2026-08-13-beta5-motion-studio/task-1-report.md new file mode 100644 index 00000000..2658839b --- /dev/null +++ b/.superpowers/sdd/2026-08-13-beta5-motion-studio/task-1-report.md @@ -0,0 +1,50 @@ +# Motion Studio Task 1 report + +Status: COMPLETE — CodeMirror dependency and license boundary implemented and independently approved. + +## Scope + +- Added exact runtime dependencies `codemirror@6.0.2`, `@codemirror/lang-html@6.4.12`, `@codemirror/lang-css@6.3.1`, and `@codemirror/theme-one-dark@6.1.3`. +- Added root third-party notices with the official repository, exact version, MIT identity, installed license path, exact copyright, and full published license text. +- Added a fail-closed Python inventory gate that binds `package.json`, the pnpm v9 runtime importer, `packages`, `snapshots`, installed manifest version/license/repository, the pinned published LICENSE SHA-256, and the notice text. +- Added adversarial tests for a missing notice, changed resolved version, runtime-to-dev dependency movement, missing resolution/snapshot record, lookalike repository, truncated license, and accidental deletion of any required direct package. + +## TDD evidence + +Initial RED: repository validation failed because all four direct packages and `THIRD_PARTY_NOTICES.md` were absent. + +Review REDs reproduced: + +- removing only the `packages` record or only the `snapshots` record was accepted; +- a lookalike installed repository and truncated two-word MIT file were accepted; +- moving an exact importer stanza from runtime dependencies to dev dependencies was accepted. + +Final GREEN: + +```text +python3 -B -m unittest scripts/test_check_license_inventory.py +Ran 7 tests — OK + +python3 -B scripts/check_license_inventory.py +CodeMirror dependency and license inventory is valid + +pnpm -C web install --frozen-lockfile --offline +Lockfile is up to date — exit 0 + +pnpm -C web licenses list --prod +All direct CodeMirror packages and resolved editor dependencies report MIT — exit 0 + +pnpm -C web build +TypeScript and Vite build passed; only pre-existing dynamic-import/chunk-size warnings + +git diff --check -- +exit 0 +``` + +## Review + +Independent code review round 1 found three HIGH and one MEDIUM fail-open/test-coupling issues. Re-review found one additional HIGH runtime/dev importer ambiguity. Both review rounds were reproduced with adversarial tests and fixed. Final review verdict: Spec PASS, Quality PASS, APPROVE, zero findings. + +## Commit + +`build(motion): add licensed CodeMirror editor dependencies` diff --git a/.superpowers/sdd/2026-08-13-beta5-motion-studio/task-2-report.md b/.superpowers/sdd/2026-08-13-beta5-motion-studio/task-2-report.md new file mode 100644 index 00000000..68114eeb --- /dev/null +++ b/.superpowers/sdd/2026-08-13-beta5-motion-studio/task-2-report.md @@ -0,0 +1,116 @@ +# Motion Studio Task 2 report + +Status: COMPLETE — independently reviewed and approved before commit. + +## Scope + +- Added a project-confined Motion Studio document store for the only editable + sources, `index.html` and `styles.css`, plus a bounded typed manifest and + revision catalog. +- Added a visible bilingual starter template, LF normalization, exact SHA-256 + revision hashes, bounded non-overlapping byte-offset edits, stale-baseline + rejection, and expected-result verification. +- Published immutable revision directories through one synced atomic catalog + replacement. Failed publication removes the unpublished revision and leaves + the prior catalog/revision readable after restart. +- A post-replacement directory-sync failure is returned to the caller as a + durability error while retaining the now-published revision; immediate and + restarted reads converge on that catalog instead of deleting its target. +- Opened the current project and every nested directory/file through retained + no-follow `cap-std` authorities. IDs, titles, directory names, sources, + parameters, manifests, catalogs, and patch counts all have explicit bounds. +- Added Windows by-handle replacement with `ReplaceIfExists`, avoiding the + non-overwriting behavior of `std::fs::rename` on Windows. +- Registered four asynchronous Tauri commands. Blocking capability I/O and + fsync work runs through `spawn_blocking`. Each command captures the exact + retained project authority before it can queue and revalidates that authority + after obtaining the store/publication/identity gates. +- Serialized Motion component commits against generation's complete-bundle + replacement so neither workflow can publish from a stale source tree. The + publication gate is released before synchronous core events, with a + subscriber re-entry regression to prevent deadlock. +- Opens Unix inputs with `O_NONBLOCK`, then rejects non-regular entries, so a + corrupt project FIFO cannot hang the blocking pool or store mutex. +- Manifest pretty-encoding is bounded before any revision directory is made; + patch offsets are explicitly UTF-8 byte offsets and must be codepoint + boundaries (the web adapter will convert CodeMirror UTF-16 positions). +- Extended complete Save As, same-target publication, generated-media + publication, and archive collection so project-local Motion documents remain + inside the `.opentake` bundle. + +## TDD evidence + +Initial RED: + +```text +cargo test -p opentake-tauri motion_documents::tests --lib +compile failed: MotionDocumentStore, DTOs, commands, and hash helpers absent +``` + +Save As RED: + +```text +cargo test -p opentake-project complete_publish_carries_motion_documents_across_save_as +failed: destination motion-documents/catalog.json did not exist +``` + +Line-ending RED: + +```text +cargo test -p opentake-tauri motion_documents::tests::normalizes_crlf_and_lone_cr_before_hashing_and_persistence --lib +failed: expected normalized result hash did not match +``` + +Final fresh GREEN: + +```text +cargo test -p opentake-tauri motion_documents:: --lib -- --nocapture +15 passed + +cargo test -p opentake-core +72 library + 9 integration tests passed; 0 failed + +cargo test -p opentake-project +167 library + 41 integration tests passed; 0 failed + +cargo test -p opentake-tauri --lib +690 passed; 0 failed + +cargo clippy -p opentake-core -p opentake-project -p opentake-tauri --all-targets -- -D warnings +passed + +cargo fmt --all -- --check +git diff --check +passed +``` + +Windows cross-check was attempted with: + +```text +cargo check -p opentake-tauri --lib --no-default-features --target x86_64-pc-windows-msvc +``` + +It stopped in third-party `ring 0.17.14` before project code because the local +macOS cross toolchain has no MSVC `assert.h`. The checked-in Windows branch uses +the same retained-root/by-handle rename contract already exercised by the +project transaction layer; Windows CI remains the executable platform gate. + +## Review + +Independent review found and the implementation fixed: queued IPC requests +crossing project replacement, generated complete-bundle publication losing a +Motion commit, blocking FIFO opens, swallowed post-rename directory-sync +errors, oversized pretty manifests, and ambiguous UTF-8 edit offsets. A later +review found the first publication gate was held during synchronous core event +emission; it is now dropped before broadcasting and covered by a re-entrant +subscriber test. + +Final re-review verdict: **Spec PASS / Quality PASS / APPROVE**, with zero +critical, high, medium, or low findings. The reviewer independently re-ran the +publication, Motion store, project carry, strict Clippy, formatting, and diff +gates. Windows runtime execution remains CI-owned because the local macOS +cross-toolchain stops in third-party `ring` before project code. + +## Commit + +Pending: `feat(motion): persist confined HTML and CSS documents`. diff --git a/.superpowers/sdd/2026-08-13-beta5-motion-studio/task-3-report.md b/.superpowers/sdd/2026-08-13-beta5-motion-studio/task-3-report.md new file mode 100644 index 00000000..9daa037f --- /dev/null +++ b/.superpowers/sdd/2026-08-13-beta5-motion-studio/task-3-report.md @@ -0,0 +1,100 @@ +# Motion Studio Task 3 report + +Status: COMPLETE — independently reviewed and approved before commit. + +## Scope + +- Added a typed `MotionDocumentSource` compiler for the project's real + `index.html` and `styles.css`. It emits one self-contained document with the + OpenTake seek contract and a fail-closed CSP, while rejecting author scripts, + event handlers, navigation/resource attributes, embedded documents, + filesystem URLs, CSS imports, and URL-bearing or executable CSS. +- Added structured, one-based Unicode line/column source diagnostics without + returning project paths or renderer internals. +- Extended `MotionRenderRequest` with a backwards-compatible absolute + `startFrame`; request validation and the versioned cache key include it, and + Chromium renders the exact integer frame at `(startFrame + i) / fps`. +- Hardened the deterministic browser clock so every CSS/Web Animation is + paused and pinned to the exact seek time, including animations created by a + seek listener. This fixed a live Chromium readback race found by the new + pixel-exact test. +- Added a bounded single-PNG reader: exactly one regular PNG frame, at most + 8 MiB before base64 expansion, with streaming growth detection and PNG magic + validation. +- Added the typed `motion_preview` Tauri command. It captures project authority + at IPC admission, reads the exact requested document revision, validates + canvas/fps/duration/frame bounds, uses the shared production Chromium pool, + checks project identity after capture, and returns only a bounded data URL, + exact revision/frame, and sanitized diagnostics. +- Preview generations are explicit. A newer preview cancels older work without + dropping its updater lease early; final publishing cannot overlap any active + preview, and project/app lifecycle cancellation reaches every generation. + Cancellation is rechecked after Chromium, around bounded PNG handling and + after base64 encoding, so a superseded request cannot publish a late success. + +## TDD evidence + +Initial RED: + +```text +cargo test -p opentake-motion preview_ --features chromium -- --nocapture +compile failed: MotionDocumentSource and MotionRenderRequest::with_start_frame were absent +``` + +The first production-clock attempt then exposed a real live-browser RED: + +```text +preview_frame_is_deterministic_and_visibly_advances +failed: Chromium opaque-white-background author-fenced pair diverged +``` + +Root cause: the virtual-time policy froze timers but did not pin compositor-run +CSS animations between fenced readbacks. `OpenTake.seek` now pauses and sets +every Web Animation's `currentTime` before and after seek callbacks. + +Final fresh GREEN: + +```text +cargo test -p opentake-motion preview_ --features chromium -- --nocapture +5 unit tests + 1 live Chromium test passed + +cargo test -p opentake-tauri motion::tests --lib -- --nocapture +6 passed + +cargo test -p opentake-motion -- --nocapture +62 library + 1 integration + 3 pipeline tests passed + +cargo test -p opentake-tauri motion_documents:: --lib -- --nocapture +15 passed + +cargo clippy -p opentake-motion -p opentake-tauri --all-targets -- -D warnings +cargo fmt --all -- --check +git diff --check +passed +``` + +The live Chromium test removes the completed cache between two captures of the +same frame and compares decoded pixels exactly. It separately captures another +integer frame and requires a meaningful visible pixel difference. Chromium was +available locally; this test did not take the no-browser skip path. + +## Review + +The first independent review found two release-blocking issues. A superseded +preview could return a late success after Chromium finished, and Chromium +accepted a no-whitespace quoted-attribute payload such as +``; the generated CSP then allowed its event handler. +The implementation now checks cancellation through response finalization, +recognizes quoted attribute adjacency, removes the redundant inline bridge, and +uses `script-src 'none'`. The deterministic runtime remains the pre-document +CDP injection, and its real Chromium pixel test remains green with scripts +disabled in the author document. + +Final independent re-review verdict: **Spec PASS / Quality APPROVE**, with zero +critical, high, medium, or low findings. The reviewer reran the exact +quote-adjacent active-content regression, cancelled-response regression, live +Chromium deterministic preview, formatting, and scoped diff checks. + +## Commit + +Pending: `feat(motion): preview real HTML and CSS deterministically`. diff --git a/.superpowers/sdd/2026-08-13-beta5-motion-studio/task-4-report.md b/.superpowers/sdd/2026-08-13-beta5-motion-studio/task-4-report.md new file mode 100644 index 00000000..1d1d84c9 --- /dev/null +++ b/.superpowers/sdd/2026-08-13-beta5-motion-studio/task-4-report.md @@ -0,0 +1,79 @@ +# Motion Studio Task 4 report + +Status: COMPLETE — independently reviewed and approved before commit. + +## Scope + +- Added `motion` as a persisted primary application view. The store migrates + the legacy key, restores supported primary views, and discards invalid or + modal-only values to a safe Home fallback. +- Added a four-control primary title-bar navigation group in the required + order: Home, Chat, Motion Studio, Panel Management. Every control retains the + existing 26-by-26-pixel title-bar geometry and has a localized accessible + label; the active primary destination is exposed semantically. +- Chat navigation reopens the existing editor and Agent panel without toggling + an already-open panel off. The App keeps every visited primary view mounted + but exposes and lays out exactly one, so Chat/editor and Motion Studio local + state survive round trips. +- Added the first independent Motion Studio shell with semantic landmarks for + files, HTML/CSS editor, 16:9 preview, inspector, and keyframe timeline. The + shell uses the existing dark design tokens and contains visible starter + content rather than an empty placeholder. +- Added complete Simplified Chinese and English navigation and workspace copy. + +## TDD evidence + +Initial RED: + +```text +MotionStudio module was absent. +"motion" was not assignable to AppView or persisted by uiStore. +The title bar exposed no Motion Studio entry or required four-control order. +App could not mount an independent Motion Studio view. +5 target failures / missing suite were observed. +``` + +During the first full regression run, two compatibility tests exposed stale +assumptions: one still expected every restart to return Home after selecting +the editor, and the new shell referenced an undefined `--border-secondary` +token. The persistence assertion now reflects the intentional primary-view +contract and the shell uses the existing `--border-subtle` token. + +Final fresh GREEN: + +```text +pnpm -C web exec vitest run \ + src/store/uiStore.persistence.test.ts src/styles/tokenUsage.test.ts \ + src/store/uiStore.test.ts src/components/shell/TitleBar.interaction.test.tsx \ + src/components/motion/MotionStudio.test.tsx src/App.lifecycle.test.tsx +6 files / 43 tests passed + +pnpm -C web test +145 files / 1323 tests passed + +pnpm -C web build +passed (only the existing dynamic-import and large-chunk warnings) + +git diff --check +passed +``` + +## Review + +The first independent review found two release-blocking semantic issues. The +preview was a named `
` but not an ARIA landmark, and the idempotent Chat +navigation exposed `aria-pressed` toggle semantics. Two focused regressions +were added and witnessed failing; the preview is now a named `region`, while +Chat uses the same `aria-current="page"` navigation state as the other primary +destinations. + +Final independent re-review verdict: **Spec PASS / Quality APPROVE**, with zero +remaining findings. It also checked the real 760px Tauri minimum against the +708px minimum Motion Studio grid, primary-view lifecycle/state retention, +focus-visible and reduced-motion behavior, persistence migration/fallback, and +the full Chinese/English key set. Its fresh focused suite passed 42/42 and its +scoped diff check passed. + +## Commit + +Pending: `feat(motion): add Motion Studio as a primary view`. diff --git a/.superpowers/sdd/2026-08-13-beta5-motion-studio/task-5-report.md b/.superpowers/sdd/2026-08-13-beta5-motion-studio/task-5-report.md new file mode 100644 index 00000000..60b40ce5 --- /dev/null +++ b/.superpowers/sdd/2026-08-13-beta5-motion-studio/task-5-report.md @@ -0,0 +1,125 @@ +# Motion Studio Task 5 report + +Status: COMPLETE — independently reviewed and approved before commit. + +## Scope + +- Replaced the shell placeholder with a Songxia/Codex-inspired dark authoring + workspace: document/template/history rail, controlled HTML/CSS CodeMirror + editor, semantic 16:9 preview region, parameter inspector, and bounded + keyframe strip. +- Added project-confined HTML/CSS document creation, loading, prospective hash, + atomic patch, preview, and cancellation commands. The browser-only fallback + now has a monotonic project identity and cannot leak Motion documents across + New/Open boundaries. +- Added 300 ms serialized autosave with Rust-authoritative revision hashes, + exact conflict reload/reapply behavior, source-version CAS guards, and a + project-boundary flush that blocks New/Open/sample/Save As when a Motion + edit cannot be persisted safely. +- Added deterministic integer-frame playback and preview scheduling with + supersession cancellation, stale-response rejection, document-bound + last-good frames, pause/suspend/resume lifecycle handling, and safe immediate + unmount/remount recovery. +- Added controlled CodeMirror language and source updates without cursor or + selection collapse, per-file diagnostics, keyboard/focus semantics, + reduced-motion behavior, and responsive folding at the real 760 px minimum. +- Added a parser-backed, deduplicated, 24-item keyframe scan that ignores CSS + comments and strings and cannot degrade quadratically on a 1 MiB source. +- Added CodeMirror packages under their MIT license and recorded them in the + third-party inventory. + +## TDD evidence + +Initial RED covered the absent store/editor and the required load, edit, +debounce, conflict, stale response, diagnostics, playback, narrow-layout, +keyboard, and reduced-motion behavior. + +Review-driven RED regressions additionally proved: + +- JS JSON hashing disagreed with Rust for integral floats and Unicode key order. +- document switches and conflict reads could overwrite input typed after the + operation began; +- preview cancellation, last-good frames, unmount/remount, and rapid + suspend/resume/suspend ordering could publish stale state; +- edits made inside the debounce window could be lost at project boundaries; +- CodeMirror external updates collapsed reverse selections; +- a regex keyframe scan parsed comments/strings and performed quadratic work; +- browser fallback Motion state crossed New/Open project identities. + +Every regression was observed failing before the corresponding production +change. The final browser isolation regression was 2 failures / 8 passes before +the monotonic identity and reset implementation, then 10/10 green. + +## Final fresh verification + +```text +pnpm -C web exec vitest run src/lib/api.test.ts +1 file / 10 tests passed + +pnpm -C web exec vitest run \ + src/components/motion/MotionCodeEditor.test.tsx \ + src/components/motion/MotionStudio.test.tsx \ + src/components/motion/MotionStudio.interaction.test.tsx \ + src/components/motion/MotionTimeline.test.ts \ + src/store/motionStudioStore.test.ts \ + src/store/projectActions.test.ts +6 files / 62 tests passed + +pnpm -C web exec vitest run \ + src/components/media/MediaPanel.test.tsx \ + src/components/shell/TitleBar.interaction.test.tsx \ + src/store/recentStore.test.ts src/store/projectActions.test.ts +4 files / 104 tests passed + +pnpm -C web test +149 files / 1352 tests passed + +pnpm -C web build +passed (only existing dynamic-import and large-chunk warnings) + +cargo test -p opentake-tauri motion_documents::tests --lib -- --nocapture +17/17 passed + +cargo test -p opentake-tauri motion::tests --lib -- --nocapture +7/7 passed + +cargo clippy -p opentake-tauri --all-targets -- -D warnings +passed (only existing block 0.1.6 future-incompatibility notice) + +cargo fmt --all -- --check +passed + +python3 -B -m unittest scripts/test_check_license_inventory.py +7/7 passed + +python3 -B scripts/check_license_inventory.py +passed + +pnpm -C web install --frozen-lockfile --offline +passed + +pnpm -C web licenses list --prod +passed; CodeMirror packages report MIT +``` + +Browser visual QA exercised the real wide workspace and the 760 px compact +layout. The semantic snapshot contained every required region and control; a +fresh reload had zero console warnings/errors. Temporary screenshots and +browser traces were removed before commit. + +## Review + +The independent review initially identified release-blocking issues in hash +authority, save/switch/conflict CAS, stale preview and last-good behavior, +visibility cancellation, disposal/remount, project-boundary persistence, and +keyframe scanning. All were reproduced and fixed. Its final main-path verdict +was **Spec PASS / Quality APPROVE** with no CRITICAL or HIGH findings. + +The sole remaining MEDIUM concerned browser fallback state crossing project +boundaries. That was then fixed with monotonic browser epochs, accurate paths, +and New/Open resets. The reviewer re-ran the focused API suite and returned a +final **APPROVE**, with zero findings at every severity. + +## Commit + +Pending: `feat(motion): build HTML and CSS authoring workspace`. diff --git a/.superpowers/sdd/2026-08-13-beta5-motion-studio/task-6-report.md b/.superpowers/sdd/2026-08-13-beta5-motion-studio/task-6-report.md new file mode 100644 index 00000000..c9411c9d --- /dev/null +++ b/.superpowers/sdd/2026-08-13-beta5-motion-studio/task-6-report.md @@ -0,0 +1,107 @@ +# Motion Studio Task 6 report + +Status: COMPLETE — independently reviewed and approved before commit. + +## Scope + +- Added document-bound Motion add/edit requests that resolve the exact saved + HTML/CSS revision, compile that source through the same deterministic + Chromium renderer used by preview, encode a validated MP4, and preserve only + the document id/revision hash as project provenance. +- Added project-authority, revision, dimensions, duration, cancellation, and + source/timeline FPS validation. A 10 fps six-frame document published into a + 20 fps timeline becomes a twelve-frame clip without changing duration. +- Added one-step atomic add and exact clip replacement. Failed render, probe, + cancellation, stale revision, project switch, Save As, or whole-bundle + replacement cannot mutate the timeline/manifest or leave a project orphan. +- Serialized retained media creation, copy, file sync, Unix directory sync, + identity verification, and durable core commit under the established + publication → project-identity-read → session lock order. +- Added deferred core events so retained-file rollback is disarmed and both + publication locks are released before synchronous subscribers run. EventBus + now isolates a panicking listener from committed commands and later mirrors. +- Added real per-frame progress from Chromium frame writes/cache hits through a + tagged Tauri event, strict TypeScript decoder, operation/terminal CAS, store, + bilingual button text, and an accessible live status. +- Added publish/save/preview/conflict gates, cancellation reconciliation, + project-boundary blocking, exact committed-clip selection, and lifecycle, + project, and current-view CAS before navigating back to the editor. + +## TDD evidence + +Initial RED evidence included missing document Motion request/bridge contracts, +three absent Web publish behaviors, stale document/dimension rejection, and the +live publishing integration target. + +Review-driven RED regressions additionally proved: + +- a complete generation-bundle replacement could interleave with a partial + Motion media copy; +- Save As could copy an incomplete orphan without an identity read lease; +- deferred events were required to avoid publication-lock re-entry deadlock and + rollback of an already-referenced media file; +- a panicking event subscriber turned an already-durable commit into an + apparent command failure; +- leaving Motion or unmounting before a late success navigated back to the + editor and selected the clip; +- the UI exposed only coarse phases rather than completed/total render frames. + +Every regression was observed failing before its production fix. The final +publication tests exercise a real complete-bundle replacement and real Save As +copy, then reopen the destination and verify both the manifest reference and +the exact media bytes. + +## Final fresh verification + +```text +cargo test -p opentake-core --lib +76/76 passed + +cargo test -p opentake-agent --test advertised_tool_acceptance motion -- --nocapture +2/2 passed + +cargo test -p opentake-agent mcp:: -- --nocapture +184/184 passed + +cargo test -p opentake-tauri motion::tests --lib -- --nocapture +12/12 passed + +cargo test -p opentake-tauri --test motion_command -- --nocapture +1/1 passed; live 4-frame progress was 0 → 1 → 2 → 3 → 4 + +OPENTAKE_RUN_FFMPEG_TESTS=1 cargo test -p opentake-tauri --test motion_integration -- --nocapture +1/1 passed; real Chromium/FFmpeg add, edit, reopen, cancellation, glyph pixels, +CSS animation frame differences, and 10 fps → 20 fps duration conversion + +cargo test -p opentake-motion --all-features +97 unit + 7 live Chromium + 3 pipeline tests passed + +cargo clippy -p opentake-core -p opentake-motion -p opentake-agent -p opentake-tauri --all-targets -- -D warnings +passed (only existing block 0.1.6 future-incompatibility notice) + +cargo fmt --all -- --check +passed + +pnpm -C web test +149 files / 1360 tests passed + +pnpm -C web build +passed (only existing dynamic-import and large-chunk warnings) + +git diff --check +passed +``` + +## Review + +The independent reviewer found release-blocking races in whole-bundle +replacement, Save As, retained-file durability/event ordering, event-subscriber +panic handling, and late UI navigation. It also identified the missing numeric +frame progress. All findings were reproduced, fixed, and re-reviewed. + +Final verdict: **Spec PASS / Quality APPROVE**, with zero CRITICAL, HIGH, +MEDIUM, or LOW findings. + +## Commit + +Pending: `feat(motion): publish Studio documents atomically`. diff --git a/.superpowers/sdd/2026-08-13-beta5-motion-studio/task-7-report.md b/.superpowers/sdd/2026-08-13-beta5-motion-studio/task-7-report.md new file mode 100644 index 00000000..c25bff1c --- /dev/null +++ b/.superpowers/sdd/2026-08-13-beta5-motion-studio/task-7-report.md @@ -0,0 +1,111 @@ +# Motion Studio Task 7 report + +Status: COMPLETE — independently reviewed and approved before commit. + +## Scope + +- Added six capability-gated Agent/MCP tools for listing, reading, creating, + patching, previewing, and publishing current-project Motion Studio documents. +- Added exact JSON schemas and bounded decoders for document identifiers, + revision hashes, UTF-8 byte edits, source/result sizes, preview dimensions and + frames, and add-versus-edit publishing arguments. +- Kept filesystem access behind a typed Tauri bridge. Model-visible results + contain document and timeline identifiers, bounded source/preview content, + diagnostics, and revision hashes, but no filesystem paths or private errors. +- Added structured, non-mutating revision conflicts with the current hash and + explicit remediation. Patch persistence recomputes the authoritative result + hash and commits only against the caller's exact baseline. +- Split dispatch into project-bound admission and deferred execution. Admission + captures the current `ProjectAssetAuthority` while the lifecycle gate is + held; execution starts only after that identity lease is released, preserving + the publication → identity lock order while rejecting project switches. +- Reused the production Motion preview and publish pipelines, propagated the + original MCP cancellation token, and subscribed active operations to project + identity transitions. +- Added a sanitized `motion_document_changed` event. A clean open editor + installs an Agent-authored authoritative revision; a dirty, saving, + conflicting, or publishing editor preserves local work and reaches the + existing explicit conflict flow instead of being silently overwritten. +- Bound every change event to its original project epoch and path. The Web + decoder validates the complete payload and the store checks project identity + before updating its list, after the authoritative read, and before reporting + a read error; project reset also invalidates all pending external refreshes. + +## TDD evidence + +Initial RED verification used: + +```text +cargo test -p opentake-agent mcp::motion_documents::tests -- --nocapture +``` + +The test target failed to compile because the Motion document bridge, requests, +tool constants, handlers, and schemas did not exist. Added regressions cover: + +- exactly six advertised tools and their strict server schemas; +- source, edit, preview, publish, hash, identifier, and result bounds; +- traversal/absolute/path-like input rejection and no path-shaped result fields; +- structured stale revision conflicts with no mutation; +- deferred execution after the lifecycle identity lease is released; +- current-project authority captured at admission and cancellation after Save As; +- Chinese text patching, exact revision changes, and sanitized change events; +- clean-editor authoritative refresh and dirty-editor local-source preservation. +- delayed old-project notifications and a pending refresh crossing project reset; +- exact production render boundaries (2-pixel minimum and 3,600-frame maximum); +- consistent `documentId` serialization across list/read/create responses. + +## Final fresh verification + +```text +cargo test -p opentake-agent mcp:: +191/191 passed + +cargo test -p opentake-tauri chat::tests --lib +24/24 passed + +cargo test -p opentake-tauri motion_documents::tests --lib +17/17 passed + +cargo test -p opentake-tauri mcp::tests --lib +83/83 passed + +cargo test -p opentake-tauri motion::tests --lib +12/12 passed + +cargo test -p opentake-agent motion_document +8/8 passed + +cargo clippy -p opentake-agent -p opentake-tauri --all-targets -- -D warnings +passed (only the existing block 0.1.6 future-incompatibility notice) + +cargo fmt --all -- --check +passed + +pnpm -C web test +149 files / 1365 tests passed + +pnpm -C web build +passed (only existing dynamic-import and large-chunk warnings) + +git diff --check +passed +``` + +## Review + +The first independent review found a project-switch notification race, a +schema/production render-bound mismatch, and an `id` versus `documentId` +contract mismatch. All three were reproduced and fixed with project-bound event +CAS, aligned 2..4096 / 1..3600 limits, and a single Agent-facing +`documentId` field. Re-review then found a publish-window event could advance +the summary but leave the open document stale. The store now retains the latest +project-bound change during publishing, replays it after every publish terminal +state, and clears it on project reset; a deferred-publish regression covers the +full summary → pending → authoritative install sequence. + +Final independent verdict: **Spec PASS / Quality APPROVE**, with zero CRITICAL, +HIGH, MEDIUM, or LOW findings. + +## Commit + +Pending: `feat(agent): edit Motion Studio documents with hash-safe tools`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 24846069..f150a0cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ 本文件记录 OpenTake 的重要改动。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 +## [1.0.0-beta.5] — 2026-08-14 + +### 新增(Added) + +- 新增显式配对、钥匙串凭据、重启保留与按客户端撤销/重新生成的外部 MCP 连接;固定回环端点继续执行 Bearer、Host、Origin、请求边界与工程身份校验。 +- 新增独立 Motion Studio 一级入口,支持受限 HTML/CSS 编辑、CodeMirror、确定性 Chromium 逐帧预览、FFmpeg 发布、工程内文档持久化、发布进度和 Agent 哈希安全协作。 +- Agent 对话改为权威有序内容块;文本、tool use 和 tool result 在同一无边框回复内按真实 provider 顺序连续呈现,清空时间线返回真实 PNG。 + +### 改进(Changed) + +- 外观设置移除未生效的深浅切换与勾号抖动,保留深色标准/紧凑密度;模型清理说明和所有 disclosure 使用统一进入/退出动画与焦点恢复。 +- 素材库 Home 入口移动到左侧分类栏顶部;Home 移除 AI 生成记录,项目卡改为 16:9 真实封面或包含项目名、画布比例与轨道结构的占位。 +- macOS 原生交通灯与 38px TitleBar 的左/右 26px 图标控件使用同一垂直中心。 + +### 安全与可靠性(Security / Reliability) + +- 外部 MCP catalog、凭据 generation、listener 生命周期、last-used 持久化与 session/request 取消均采用 fail-closed 事务和明确的 shutdown barrier。 +- Agent sequence 重放、history resync、Motion revision conflict、项目切换、Save As、发布取消与媒体持久化均绑定权威工程身份,防止迟到结果跨项目提交或覆盖本地编辑。 +- Motion HTML/CSS 禁止网络与活动脚本,预览/发布有文档、图片、帧数、尺寸和结果总量边界;新增 CodeMirror MIT 许可证 inventory 门禁。 + +### Beta 已知边界 + +- macOS 候选包仍为 ad-hoc 签名且未公证;Windows 安装器仍未使用 Authenticode。平台安装与升级证据必须来自候选 exact-SHA CI/实机,不能由 macOS 或浏览器 fallback 替代。 +- 在最终打包 `.app` GUI 验收、远端 main CI 和签名 secret 预检完成前,不创建或发布 `v1.0.0-beta.5`。 + ## [1.0.0-beta.2] — 2026-08-03 ### 新增(Added) diff --git a/Cargo.lock b/Cargo.lock index 48f0737a..0e1f5886 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3690,7 +3690,7 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "opentake-agent" -version = "1.0.0-beta.4" +version = "1.0.0-beta.5" dependencies = [ "anyhow", "async-trait", @@ -3725,7 +3725,7 @@ dependencies = [ [[package]] name = "opentake-core" -version = "1.0.0-beta.4" +version = "1.0.0-beta.5" dependencies = [ "opentake-domain", "opentake-ops", @@ -3739,7 +3739,7 @@ dependencies = [ [[package]] name = "opentake-domain" -version = "1.0.0-beta.4" +version = "1.0.0-beta.5" dependencies = [ "serde", "serde_json", @@ -3747,7 +3747,7 @@ dependencies = [ [[package]] name = "opentake-gen" -version = "1.0.0-beta.4" +version = "1.0.0-beta.5" dependencies = [ "anyhow", "async-trait", @@ -3765,7 +3765,7 @@ dependencies = [ [[package]] name = "opentake-media" -version = "1.0.0-beta.4" +version = "1.0.0-beta.5" dependencies = [ "anyhow", "byteorder", @@ -3803,7 +3803,7 @@ dependencies = [ [[package]] name = "opentake-motion" -version = "1.0.0-beta.4" +version = "1.0.0-beta.5" dependencies = [ "base64 0.22.1", "hex", @@ -3821,7 +3821,7 @@ dependencies = [ [[package]] name = "opentake-ops" -version = "1.0.0-beta.4" +version = "1.0.0-beta.5" dependencies = [ "opentake-domain", "serde_json", @@ -3829,7 +3829,7 @@ dependencies = [ [[package]] name = "opentake-process-tree" -version = "1.0.0-beta.4" +version = "1.0.0-beta.5" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3837,7 +3837,7 @@ dependencies = [ [[package]] name = "opentake-project" -version = "1.0.0-beta.4" +version = "1.0.0-beta.5" dependencies = [ "cap-fs-ext", "cap-std", @@ -3856,7 +3856,7 @@ dependencies = [ [[package]] name = "opentake-render" -version = "1.0.0-beta.4" +version = "1.0.0-beta.5" dependencies = [ "bytemuck", "cosmic-text", @@ -3873,7 +3873,7 @@ dependencies = [ [[package]] name = "opentake-tauri" -version = "1.0.0-beta.4" +version = "1.0.0-beta.5" dependencies = [ "axum", "base64 0.22.1", @@ -3884,6 +3884,7 @@ dependencies = [ "crossbeam-channel", "futures", "futures-util", + "getrandom 0.3.4", "glob", "http-range", "image", @@ -3905,6 +3906,7 @@ dependencies = [ "quick-xml", "reqwest 0.12.28", "reqwest 0.13.4", + "rmcp", "rustls", "same-file", "semver", @@ -3912,6 +3914,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "sse-stream", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -3920,6 +3923,7 @@ dependencies = [ "tauri-plugin-updater", "tempfile", "tokio", + "tracing", "uuid", "velato", "windows-sys 0.61.2", @@ -4859,6 +4863,7 @@ dependencies = [ "pastey 0.2.3", "pin-project-lite", "rand 0.10.2", + "reqwest 0.13.4", "rmcp-macros", "schemars 1.2.1", "serde", @@ -5714,9 +5719,9 @@ dependencies = [ [[package]] name = "sse-stream" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72" +checksum = "39f24a9b78c40b90817bbcd1821c74ddfd74916aadd29403d001532a9195532d" dependencies = [ "bytes", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index 72ede092..54f11498 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ members = [ ] [workspace.package] -version = "1.0.0-beta.4" +version = "1.0.0-beta.5" edition = "2021" license = "GPL-3.0-or-later" repository = "https://github.com/appergb/OpenTake" diff --git a/README.md b/README.md index 0fe8d730..95cbaa43 100644 --- a/README.md +++ b/README.md @@ -303,9 +303,10 @@ cd .. cargo tauri dev ``` -> **Current Status**: `1.0.0-beta.4` candidate. The local editing, preview, -> persistence, export, Agent, Motion Canvas, and reviewed AI workflow verticals -> are implemented. See the [Beta release notes](docs/releases/1.0.0-beta.4.md) +> **Current Status**: `1.0.0-beta.5` candidate. The local editing, preview, +> persistence, export, authenticated external MCP, ordered Agent conversation, +> Motion Studio, and reviewed AI workflow verticals are implemented. See the +> [Beta release notes](docs/releases/1.0.0-beta.5.md) > for validation scope and platform/provider limits. The sibling directory `palmier-pro-upstream/` contains upstream Swift sources for reference during porting. @@ -321,6 +322,7 @@ The sibling directory `palmier-pro-upstream/` contains upstream Swift sources fo | `1.0.0-beta.2` | 2026-08-08 | Hardened Beta: official Codex login, atomic timeline gestures, secure MCP and interaction polish | | `1.0.0-beta.3` | 2026-08-09 | Playback Beta: app-wide Space transport, native HEVC source preview and release-pipeline hardening | | `1.0.0-beta.4` | 2026-08-10 | Release candidate: timing and transition persistence, export consistency, signed updater and Windows tract security upgrade | +| `1.0.0-beta.5` | 2026-08-14 | Agent workflow Beta: persistent authenticated MCP, ordered inline tools, Motion Studio, project previews and interface polish | | *(planned)* `1.0.0` | TBD | Phase 10: Full release — CapCut parity + deep Agent integration | 📖 [Full Roadmap](docs/architecture/ROADMAP.md) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..5381ec30 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,35 @@ +# OpenTake third-party notices + +OpenTake includes the following CodeMirror packages for the Motion Studio source editor. Each package is used under the MIT License and is distributed from the exact version pinned in `web/pnpm-lock.yaml`. + +| Package | Version | Official repository | License | Installed license evidence | +| --- | --- | --- | --- | --- | +| `codemirror` | `6.0.2` | [https://github.com/codemirror/basic-setup](https://github.com/codemirror/basic-setup) | MIT | `web/node_modules/codemirror/LICENSE` | +| `@codemirror/lang-html` | `6.4.12` | [https://code.haverbeke.berlin/codemirror/lang-html](https://code.haverbeke.berlin/codemirror/lang-html) | MIT | `web/node_modules/@codemirror/lang-html/LICENSE` | +| `@codemirror/lang-css` | `6.3.1` | [https://github.com/codemirror/lang-css](https://github.com/codemirror/lang-css) | MIT | `web/node_modules/@codemirror/lang-css/LICENSE` | +| `@codemirror/state` | `6.7.1` | [https://code.haverbeke.berlin/codemirror/state](https://code.haverbeke.berlin/codemirror/state) | MIT | `web/node_modules/@codemirror/state/LICENSE` | +| `@codemirror/theme-one-dark` | `6.1.3` | [https://github.com/codemirror/theme-one-dark](https://github.com/codemirror/theme-one-dark) | MIT | `web/node_modules/@codemirror/theme-one-dark/LICENSE` | + +## License text + +MIT License + +Copyright (C) 2018-2021 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/crates/opentake-agent/src/chat/llm.rs b/crates/opentake-agent/src/chat/llm.rs index 64ec11c6..f55f007e 100644 --- a/crates/opentake-agent/src/chat/llm.rs +++ b/crates/opentake-agent/src/chat/llm.rs @@ -12,17 +12,15 @@ //! provider, so chat must either use that provider or fail clearly. No //! auto-fallback to another provider when the selected one lacks a key. -use std::collections::BTreeMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use futures::{Stream, StreamExt}; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use opentake_gen::{KeyStore, ProviderKey}; -use crate::chat::session::{AgentContentBlock, ChatMessage, Role, ToolCall}; -use crate::tools::result::Block; +use crate::chat::session::{AgentContentBlock, ChatMessage, ToolCall}; /// Which BYOK provider a chat session talks to. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -95,11 +93,13 @@ pub struct ToolSchema { /// matching Tauri event so the UI renders incrementally. #[derive(Clone, Debug)] pub enum StreamEvent { - /// A text chunk from the assistant. Concatenated in order = full turn text. - Delta(String), - /// The assistant requested a tool call. `ToolCall.result` is `None` here; - /// the loop dispatches, fills the result, and re-feeds the LLM. - ToolCall(ToolCall), + /// A text chunk addressed to the provider's content-block index. + BlockDelta { block_index: usize, delta: String }, + /// Insert or replace one provider-addressed content block. + BlockUpsert { + block_index: usize, + block: AgentContentBlock, + }, } /// The final assistant turn after the stream closes: full text + any tool calls @@ -108,6 +108,7 @@ pub enum StreamEvent { pub struct TurnResult { pub content: String, pub tool_calls: Vec, + pub blocks: Vec, } /// Everything a chat turn needs: the full history (system + user + assistant + @@ -146,9 +147,6 @@ impl From for LlmError { } } -const OPENAI_URL: &str = "https://api.openai.com/v1/chat/completions"; -const ANTHROPIC_URL: &str = "https://api.anthropic.com/v1/messages"; -const ANTHROPIC_VERSION: &str = "2023-06-01"; const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const READ_TIMEOUT: Duration = Duration::from_secs(30); const REQUEST_TIMEOUT: Duration = Duration::from_secs(120); @@ -215,666 +213,16 @@ where let model = req.model.unwrap_or_else(|| provider.default_model()); match provider { - LlmProvider::OpenAi => stream_openai(&key, model, req, cancel, &mut on_event).await, - LlmProvider::Anthropic => stream_anthropic(&key, model, req, cancel, &mut on_event).await, + LlmProvider::OpenAi => openai::stream(&key, model, req, cancel, &mut on_event).await, + LlmProvider::Anthropic => anthropic::stream(&key, model, req, cancel, &mut on_event).await, } } -// MARK: - OpenAI streaming - -/// Build the OpenAI request body from the session messages + tool schemas. -fn openai_body(model: &str, messages: &[ChatMessage], tools: &[ToolSchema]) -> serde_json::Value { - let msgs: Vec = messages.iter().map(openai_message).collect(); - let mut body = serde_json::json!({ - "model": model, - "messages": msgs, - "stream": true, - }); - if !tools.is_empty() { - body["tools"] = serde_json::Value::Array( - tools - .iter() - .map(|t| { - serde_json::json!({ - "type": "function", - "function": { - "name": t.name, - "description": t.description, - "parameters": t.parameters, - } - }) - }) - .collect(), - ); - } - body -} - -/// Map one [`ChatMessage`] to the OpenAI wire shape. Tool-result messages carry -/// `tool_call_id`; assistant turns with tool calls carry `tool_calls`. -fn openai_message(m: &ChatMessage) -> serde_json::Value { - match m.role { - Role::System => serde_json::json!({"role": "system", "content": m.content}), - Role::User => serde_json::json!({"role": "user", "content": m.content}), - Role::Assistant => { - let mut v = serde_json::json!({"role": "assistant", "content": m.content}); - if !m.tool_calls.is_empty() { - v["tool_calls"] = serde_json::Value::Array( - m.tool_calls - .iter() - .map(|tc| { - serde_json::json!({ - "id": tc.id, - "type": "function", - "function": { - "name": tc.name, - "arguments": tc.args.to_string(), - } - }) - }) - .collect(), - ); - } - v - } - Role::Tool => serde_json::json!({ - "role": "tool", - "content": m.content, - "tool_call_id": m.tool_call_id.clone().unwrap_or_default(), - }), - } -} - -async fn stream_openai( - key: &str, - model: &str, - req: ChatRequest<'_>, - cancel: &AtomicBool, - on_event: &mut F, -) -> Result -where - F: FnMut(StreamEvent) + Send, -{ - let body = openai_body(model, req.messages, req.tools); - let resp = http_client()? - .post(OPENAI_URL) - .bearer_auth(key) - .json(&body) - .send() - .await?; - if !resp.status().is_success() { - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - return Err(LlmError::Provider(format!("HTTP {status}: {text}"))); - } - - let mut full = String::new(); - let mut tool_buf: BTreeMap = BTreeMap::new(); - let mut stream = resp.bytes_stream(); - let mut buf = Vec::new(); - while let Some(chunk) = next_chunk_or_cancel(&mut stream, cancel).await? { - let bytes = chunk.map_err(|e| LlmError::Stream(e.to_string()))?; - buf.extend_from_slice(bytes.as_ref()); - for event in drain_sse_frames(&mut buf)? { - if let Some(rest) = event.strip_prefix("data: ") { - let rest = rest.trim(); - if rest == "[DONE]" { - continue; - } - let v: serde_json::Value = match serde_json::from_str(rest) { - Ok(v) => v, - Err(_) => continue, - }; - let Some(delta) = v - .get("choices") - .and_then(|c| c.get(0)) - .and_then(|c| c.get("delta")) - else { - continue; - }; - if let Some(text) = delta.get("content").and_then(|c| c.as_str()) { - if !text.is_empty() { - full.push_str(text); - on_event(StreamEvent::Delta(text.to_string())); - } - } - if let Some(arr) = delta.get("tool_calls").and_then(|t| t.as_array()) { - for tc in arr { - let idx = tc.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize; - let entry = tool_buf - .entry(idx) - .or_insert_with(|| (String::new(), String::new(), String::new())); - if let Some(id) = tc.get("id").and_then(|i| i.as_str()) { - entry.0 = id.to_string(); - } - if let Some(fn_obj) = tc.get("function") { - if let Some(name) = fn_obj.get("name").and_then(|n| n.as_str()) { - entry.1 = name.to_string(); - } - if let Some(args) = fn_obj.get("arguments").and_then(|a| a.as_str()) { - entry.2.push_str(args); - } - } - } - } - } - } - } - - let mut tool_calls = Vec::new(); - if cancel.load(Ordering::Relaxed) { - return Err(LlmError::Cancelled); - } - for (_, (id, name, args_str)) in tool_buf { - let args = if args_str.is_empty() { - serde_json::json!({}) - } else { - serde_json::from_str(&args_str).unwrap_or(serde_json::json!({"_raw": args_str})) - }; - let tc = ToolCall::request(id, name, args); - on_event(StreamEvent::ToolCall(tc.clone())); - tool_calls.push(tc); - } - - Ok(TurnResult { - content: full, - tool_calls, - }) -} - -// MARK: - Anthropic streaming - -fn anthropic_tool_result_content(message: &ChatMessage) -> serde_json::Value { - let Some(AgentContentBlock::ToolResult { content, .. }) = message - .blocks - .iter() - .find(|block| matches!(block, AgentContentBlock::ToolResult { .. })) - else { - return serde_json::Value::String(message.content.clone()); - }; - serde_json::Value::Array( - content - .iter() - .map(|block| match block { - Block::Text { text } => serde_json::json!({ - "type": "text", - "text": text, - }), - Block::Image { base64, media_type } => serde_json::json!({ - "type": "image", - "source": { - "type": "base64", - "media_type": media_type, - "data": base64, - }, - }), - }) - .collect(), - ) -} - -/// Build the Anthropic request body. System prompt is a top-level field (not a -/// message); tool results are `role:user` `tool_result` content blocks. -fn anthropic_body( - model: &str, - messages: &[ChatMessage], - tools: &[ToolSchema], -) -> serde_json::Value { - let mut system = String::new(); - let mut turns: Vec = Vec::new(); - for m in messages { - match m.role { - Role::System => { - if !system.is_empty() { - system.push_str("\n\n"); - } - system.push_str(&m.content); - } - Role::User => turns.push(serde_json::json!({ - "role": "user", - "content": [{"type": "text", "text": m.content}], - })), - Role::Assistant => { - let mut blocks = Vec::new(); - if !m.content.is_empty() { - blocks.push(serde_json::json!({"type": "text", "text": m.content})); - } - for tc in &m.tool_calls { - blocks.push(serde_json::json!({ - "type": "tool_use", - "id": tc.id, - "name": tc.name, - "input": tc.args, - })); - } - turns.push(serde_json::json!({"role": "assistant", "content": blocks})); - } - Role::Tool => { - let mut block = serde_json::json!({ - "type": "tool_result", - "tool_use_id": m.tool_call_id.clone().unwrap_or_default(), - "content": anthropic_tool_result_content(m), - }); - if let Some(is_error) = m.tool_is_error { - block["is_error"] = serde_json::Value::Bool(is_error); - } - if let Some(last) = turns.last_mut() { - if last.get("role").and_then(|r| r.as_str()) == Some("user") { - if let Some(arr) = last.get_mut("content").and_then(|c| c.as_array_mut()) { - arr.push(block); - continue; - } - } - } - turns.push(serde_json::json!({"role": "user", "content": vec![block]})); - } - } - } - - let mut body = serde_json::json!({ - "model": model, - "max_tokens": 8192, - "stream": true, - "messages": turns, - }); - if !system.is_empty() { - body["system"] = serde_json::json!([{ - "type": "text", - "text": system, - "cache_control": {"type": "ephemeral"}, - }]); - } - if !tools.is_empty() { - let mut wire_tools: Vec = tools - .iter() - .map(|t| { - serde_json::json!({ - "name": t.name, - "description": t.description, - "input_schema": t.parameters, - }) - }) - .collect(); - if let Some(last) = wire_tools.last_mut() { - last["cache_control"] = serde_json::json!({"type": "ephemeral"}); - } - body["tools"] = serde_json::Value::Array(wire_tools); - } - if let Some(last_block) = body["messages"] - .as_array_mut() - .and_then(|messages| messages.last_mut()) - .and_then(|message| message.get_mut("content")) - .and_then(serde_json::Value::as_array_mut) - .and_then(|content| content.last_mut()) - { - last_block["cache_control"] = serde_json::json!({"type": "ephemeral"}); - } - body -} - -#[derive(Deserialize)] -struct AnthEvent { - #[serde(rename = "type")] - typ: String, - #[serde(default)] - delta: Option, - #[serde(default)] - content_block: Option, - #[serde(default)] - index: Option, -} - -async fn stream_anthropic( - key: &str, - model: &str, - req: ChatRequest<'_>, - cancel: &AtomicBool, - on_event: &mut F, -) -> Result -where - F: FnMut(StreamEvent) + Send, -{ - let body = anthropic_body(model, req.messages, req.tools); - let resp = http_client()? - .post(ANTHROPIC_URL) - .header("x-api-key", key) - .header("anthropic-version", ANTHROPIC_VERSION) - .json(&body) - .send() - .await?; - if !resp.status().is_success() { - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - return Err(LlmError::Provider(format!("HTTP {status}: {text}"))); - } - - let mut full = String::new(); - let mut tool_buf: BTreeMap = BTreeMap::new(); - let mut stream = resp.bytes_stream(); - let mut buf = Vec::new(); - while let Some(chunk) = next_chunk_or_cancel(&mut stream, cancel).await? { - let bytes = chunk.map_err(|e| LlmError::Stream(e.to_string()))?; - buf.extend_from_slice(bytes.as_ref()); - for event in drain_sse_frames(&mut buf)? { - let data_line = event - .lines() - .find_map(|l| l.strip_prefix("data: ")) - .unwrap_or(""); - if data_line.is_empty() { - continue; - } - let ev: AnthEvent = match serde_json::from_str(data_line) { - Ok(e) => e, - Err(_) => continue, - }; - match ev.typ.as_str() { - "content_block_start" => { - if let Some(cb) = ev.content_block { - if cb.get("type").and_then(|t| t.as_str()) == Some("tool_use") { - let i = ev.index.unwrap_or(0) as usize; - let id = cb - .get("id") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let name = cb - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - tool_buf.insert(i, (id, name, String::new())); - } - } - } - "content_block_delta" => { - let Some(delta) = ev.delta else { continue }; - let i = ev.index.unwrap_or(0) as usize; - match delta.get("type").and_then(|t| t.as_str()) { - Some("text_delta") => { - if let Some(text) = delta.get("text").and_then(|t| t.as_str()) { - if !text.is_empty() { - full.push_str(text); - on_event(StreamEvent::Delta(text.to_string())); - } - } - } - Some("input_json_delta") => { - if let Some(partial) = delta.get("partial").and_then(|p| p.as_str()) { - if let Some(entry) = tool_buf.get_mut(&i) { - entry.2.push_str(partial); - } - } - } - _ => {} - } - } - "message_stop" => break, - _ => {} - } - } - } - - let mut tool_calls = Vec::new(); - if cancel.load(Ordering::Relaxed) { - return Err(LlmError::Cancelled); - } - for (_, (id, name, args_str)) in tool_buf { - let args = if args_str.is_empty() { - serde_json::json!({}) - } else { - serde_json::from_str(&args_str).unwrap_or(serde_json::json!({"_raw": args_str})) - }; - let tc = ToolCall::request(id, name, args); - on_event(StreamEvent::ToolCall(tc.clone())); - tool_calls.push(tc); - } - - Ok(TurnResult { - content: full, - tool_calls, - }) -} - #[cfg(test)] -mod tests { - use super::*; - use opentake_gen::MemoryKeyStore; +pub(crate) use anthropic::{body as anthropic_body, StreamDecoder as AnthropicStreamDecoder}; - fn store_with_key(provider: LlmProvider, value: &str) -> MemoryKeyStore { - MemoryKeyStore::new().with_key(provider.key(), value) - } - - #[test] - fn provider_choice_is_explicit() { - assert_eq!(provider_from_choice("openai").unwrap(), LlmProvider::OpenAi); - assert_eq!( - provider_from_choice("anthropic").unwrap(), - LlmProvider::Anthropic - ); - let err = provider_from_choice("google").unwrap_err().to_string(); - assert!(err.contains("does not support provider")); - let err = provider_from_choice("mystery").unwrap_err().to_string(); - assert!(err.contains("unknown provider")); - } +mod anthropic; +mod openai; - #[test] - fn stream_chat_requires_a_key_for_the_selected_provider() { - let store = MemoryKeyStore::new(); - let err = futures::executor::block_on(stream_chat( - LlmProvider::OpenAi, - &store, - ChatRequest { - messages: &[ChatMessage::user("hi")], - tools: &[], - model: None, - }, - &AtomicBool::new(false), - |_| {}, - )) - .unwrap_err() - .to_string(); - assert!(err.contains("no API key configured for openai")); - } - - #[test] - fn no_key_guide_mentions_settings_and_provider() { - let msg = no_key_guide(LlmProvider::Anthropic); - assert!(msg.contains("Settings")); - assert!(msg.contains("Anthropic")); - } - - #[test] - fn memory_store_round_trips_selected_provider_key() { - let store = store_with_key(LlmProvider::OpenAi, "sk-test"); - let dyn_store: &dyn KeyStore = &store; - assert_eq!( - dyn_store - .load(ProviderKey::OpenAI.account()) - .unwrap() - .as_deref(), - Some("sk-test") - ); - assert_eq!( - dyn_store.load(ProviderKey::Anthropic.account()).unwrap(), - None - ); - } - - #[test] - fn openai_body_shape_minimum() { - let msgs = vec![ChatMessage::user("hi")]; - let body = openai_body("gpt-4o-mini", &msgs, &[]); - assert_eq!(body["model"], "gpt-4o-mini"); - assert_eq!(body["stream"], true); - assert_eq!(body["messages"][0]["role"], "user"); - assert_eq!(body["messages"][0]["content"], "hi"); - assert!(body.get("tools").is_none()); - } - - #[test] - fn openai_body_with_tools() { - let tools = vec![ToolSchema { - name: "get_timeline".into(), - description: "read".into(), - parameters: serde_json::json!({"type": "object"}), - }]; - let body = openai_body("m", &[ChatMessage::user("x")], &tools); - let t = &body["tools"][0]; - assert_eq!(t["type"], "function"); - assert_eq!(t["function"]["name"], "get_timeline"); - assert_eq!(t["function"]["parameters"]["type"], "object"); - } - - #[test] - fn openai_assistant_with_tool_calls_round_trips() { - let tc = ToolCall::request("call-1", "split_clip", serde_json::json!({"atFrame": 10})); - let m = ChatMessage::assistant("splitting", vec![tc]); - let v = openai_message(&m); - assert_eq!(v["role"], "assistant"); - assert_eq!(v["tool_calls"][0]["id"], "call-1"); - assert_eq!(v["tool_calls"][0]["function"]["name"], "split_clip"); - assert_eq!( - v["tool_calls"][0]["function"]["arguments"], - "{\"atFrame\":10}" - ); - } - - #[test] - fn openai_tool_result_carries_tool_call_id() { - let m = ChatMessage::tool_result("call-1", serde_json::json!({"summary": "ok"})); - let v = openai_message(&m); - assert_eq!(v["role"], "tool"); - assert_eq!(v["tool_call_id"], "call-1"); - } - - #[test] - fn anthropic_system_prompt_hoisted_top_level() { - let msgs = vec![ - ChatMessage::system("you are an editor"), - ChatMessage::user("hi"), - ]; - let body = anthropic_body("claude", &msgs, &[]); - assert_eq!(body["system"][0]["type"], "text"); - assert_eq!(body["system"][0]["text"], "you are an editor"); - assert_eq!(body["system"][0]["cache_control"]["type"], "ephemeral"); - assert_eq!(body["messages"].as_array().unwrap().len(), 1); - assert_eq!(body["messages"][0]["role"], "user"); - } - - #[test] - fn anthropic_request_sets_all_prompt_cache_boundaries_and_upstream_token_limit() { - let tools = vec![ - ToolSchema { - name: "get_timeline".into(), - description: "read".into(), - parameters: serde_json::json!({"type": "object"}), - }, - ToolSchema { - name: "split_clip".into(), - description: "edit".into(), - parameters: serde_json::json!({"type": "object"}), - }, - ]; - let messages = vec![ - ChatMessage::system("system"), - ChatMessage::user("first"), - ChatMessage::assistant("ack", vec![]), - ChatMessage::user("latest"), - ]; - - let body = anthropic_body("claude", &messages, &tools); - - assert_eq!(body["max_tokens"], 8192); - assert!(body["tools"][0].get("cache_control").is_none()); - assert_eq!(body["tools"][1]["cache_control"]["type"], "ephemeral"); - assert_eq!( - body["messages"][2]["content"][0]["cache_control"]["type"], - "ephemeral" - ); - assert!(body["messages"][0]["content"][0] - .get("cache_control") - .is_none()); - } - - #[test] - fn anthropic_tool_results_nest_under_user_turns() { - let msgs = vec![ - ChatMessage::user("please"), - ChatMessage::assistant( - "", - vec![ToolCall::request( - "c1", - "get_timeline", - serde_json::json!({}), - )], - ), - ChatMessage::tool_error_result("c1", serde_json::json!({"error": "Cancelled"})), - ]; - let body = anthropic_body("claude", &msgs, &[]); - let turns = body["messages"].as_array().unwrap(); - assert_eq!(turns.len(), 3); - let last = turns.last().unwrap(); - assert_eq!(last["role"], "user"); - assert_eq!(last["content"][0]["type"], "tool_result"); - assert_eq!(last["content"][0]["tool_use_id"], "c1"); - assert_eq!(last["content"][0]["is_error"], true); - } - - #[test] - fn anthropic_tool_results_preserve_native_image_blocks() { - let message = ChatMessage::tool_result_blocks( - "c-image", - vec![ - Block::text("before"), - Block::image("aW1hZ2U=", "image/png"), - Block::text("after"), - ], - serde_json::json!({"summary": "beforeafter", "isError": false}), - false, - ); - - let body = anthropic_body("claude", &[message], &[]); - let content = &body["messages"][0]["content"][0]["content"]; - - assert_eq!( - content[0], - serde_json::json!({"type": "text", "text": "before"}) - ); - assert_eq!(content[1]["type"], "image"); - assert_eq!(content[1]["source"]["type"], "base64"); - assert_eq!(content[1]["source"]["media_type"], "image/png"); - assert_eq!(content[1]["source"]["data"], "aW1hZ2U="); - assert_eq!( - content[2], - serde_json::json!({"type": "text", "text": "after"}) - ); - } - - #[test] - fn drain_sse_frames_handles_split_utf8_chunks() { - let mut buffer = Vec::new(); - let bytes = "data: {\"text\":\"你\"}\n\n".as_bytes(); - buffer.extend_from_slice(&bytes[..15]); - assert!(drain_sse_frames(&mut buffer).unwrap().is_empty()); - buffer.extend_from_slice(&bytes[15..]); - let frames = drain_sse_frames(&mut buffer).unwrap(); - assert_eq!(frames, vec!["data: {\"text\":\"你\"}"]); - assert!(buffer.is_empty()); - } - - #[tokio::test(start_paused = true)] - async fn next_chunk_or_cancel_interrupts_pending_stream() { - let cancel = AtomicBool::new(false); - let mut stream = futures::stream::pending::, std::io::Error>>(); - let wait = next_chunk_or_cancel(&mut stream, &cancel); - tokio::pin!(wait); - - tokio::task::yield_now().await; - tokio::time::advance(CANCEL_POLL_INTERVAL).await; - cancel.store(true, Ordering::Relaxed); - tokio::time::advance(CANCEL_POLL_INTERVAL).await; - - let err = wait.await.unwrap_err().to_string(); - assert!(err.contains("cancelled")); - } -} +#[cfg(test)] +mod tests; diff --git a/crates/opentake-agent/src/chat/llm/anthropic.rs b/crates/opentake-agent/src/chat/llm/anthropic.rs new file mode 100644 index 00000000..0c81772e --- /dev/null +++ b/crates/opentake-agent/src/chat/llm/anthropic.rs @@ -0,0 +1,490 @@ +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicBool, Ordering}; + +use serde::Deserialize; + +use crate::chat::session::{AgentContentBlock, ChatMessage, Role, ToolCall}; +use crate::tools::result::Block; + +use super::{ + drain_sse_frames, http_client, next_chunk_or_cancel, ChatRequest, LlmError, StreamEvent, + ToolSchema, TurnResult, +}; + +const URL: &str = "https://api.anthropic.com/v1/messages"; +const VERSION: &str = "2023-06-01"; + +fn anthropic_tool_result_content(content: &[Block]) -> serde_json::Value { + serde_json::Value::Array( + content + .iter() + .map(|block| match block { + Block::Text { text } => serde_json::json!({ + "type": "text", + "text": text, + }), + Block::Image { base64, media_type } => serde_json::json!({ + "type": "image", + "source": { + "type": "base64", + "media_type": media_type, + "data": base64, + }, + }), + }) + .collect(), + ) +} + +/// Build the Anthropic request body. System prompt is a top-level field (not a +/// message); tool results are `role:user` `tool_result` content blocks. +pub(crate) fn body( + model: &str, + messages: &[ChatMessage], + tools: &[ToolSchema], +) -> serde_json::Value { + let mut system = String::new(); + let mut turns: Vec = Vec::new(); + for m in messages { + match m.role { + Role::System => { + if !system.is_empty() { + system.push_str("\n\n"); + } + for block in &m.blocks { + if let AgentContentBlock::Text { text } = block { + system.push_str(text); + } + } + } + Role::User => { + let blocks = m + .blocks + .iter() + .filter_map(|block| match block { + AgentContentBlock::Text { text } => { + Some(serde_json::json!({"type": "text", "text": text})) + } + _ => None, + }) + .collect::>(); + turns.push(serde_json::json!({"role": "user", "content": blocks})); + } + Role::Assistant => { + let blocks = m + .blocks + .iter() + .filter_map(|block| match block { + AgentContentBlock::Text { text } => { + Some(serde_json::json!({"type": "text", "text": text})) + } + AgentContentBlock::ToolUse { + id, name, input, .. + } => Some(serde_json::json!({ + "type": "tool_use", + "id": id, + "name": name, + "input": input, + })), + AgentContentBlock::ToolResult { .. } => None, + }) + .collect::>(); + turns.push(serde_json::json!({"role": "assistant", "content": blocks})); + } + Role::Tool => { + for result in &m.blocks { + let AgentContentBlock::ToolResult { + tool_use_id, + content, + is_error, + } = result + else { + continue; + }; + let mut block = serde_json::json!({ + "type": "tool_result", + "tool_use_id": tool_use_id, + "content": anthropic_tool_result_content(content), + }); + if let Some(is_error) = is_error { + block["is_error"] = serde_json::Value::Bool(*is_error); + } + if let Some(last) = turns.last_mut() { + if last.get("role").and_then(|r| r.as_str()) == Some("user") { + if let Some(arr) = + last.get_mut("content").and_then(|c| c.as_array_mut()) + { + arr.push(block); + continue; + } + } + } + turns.push(serde_json::json!({"role": "user", "content": vec![block]})); + } + } + } + } + + let mut body = serde_json::json!({ + "model": model, + "max_tokens": 8192, + "stream": true, + "messages": turns, + }); + if !system.is_empty() { + body["system"] = serde_json::json!([{ + "type": "text", + "text": system, + "cache_control": {"type": "ephemeral"}, + }]); + } + if !tools.is_empty() { + let mut wire_tools: Vec = tools + .iter() + .map(|t| { + serde_json::json!({ + "name": t.name, + "description": t.description, + "input_schema": t.parameters, + }) + }) + .collect(); + if let Some(last) = wire_tools.last_mut() { + last["cache_control"] = serde_json::json!({"type": "ephemeral"}); + } + body["tools"] = serde_json::Value::Array(wire_tools); + } + if let Some(last_block) = body["messages"] + .as_array_mut() + .and_then(|messages| messages.last_mut()) + .and_then(|message| message.get_mut("content")) + .and_then(serde_json::Value::as_array_mut) + .and_then(|content| content.last_mut()) + { + last_block["cache_control"] = serde_json::json!({"type": "ephemeral"}); + } + body +} + +#[derive(Deserialize)] +struct AnthEvent { + #[serde(rename = "type")] + typ: String, + #[serde(default)] + delta: Option, + #[serde(default)] + content_block: Option, + #[serde(default)] + index: Option, +} + +#[derive(Clone, Debug)] +enum AnthropicBlockState { + Text(String), + ToolUse { + id: String, + name: String, + initial_input: serde_json::Value, + partial_json: String, + }, +} + +#[derive(Clone, Debug)] +struct AnthropicBlockLifecycle { + state: AnthropicBlockState, + stopped: bool, +} + +impl AnthropicBlockState { + fn content_block(&self) -> AgentContentBlock { + match self { + Self::Text(text) => AgentContentBlock::Text { text: text.clone() }, + Self::ToolUse { + id, + name, + initial_input, + partial_json, + } => { + let input = if partial_json.is_empty() { + initial_input.clone() + } else { + serde_json::from_str(partial_json) + .unwrap_or_else(|_| serde_json::json!({"_raw": partial_json})) + }; + AgentContentBlock::ToolUse { + id: id.clone(), + name: name.clone(), + input, + result: None, + is_error: None, + } + } + } + } +} + +/// Stateful Anthropic SSE decoder shared by the live HTTP path and protocol +/// tests. Content-block indices remain authoritative from start through stop. +#[derive(Default)] +pub(crate) struct StreamDecoder { + buffer: Vec, + blocks: BTreeMap, + message_stopped: bool, +} + +impl StreamDecoder { + pub(crate) fn push_chunk(&mut self, bytes: &[u8], on_event: &mut F) -> Result + where + F: FnMut(StreamEvent), + { + self.buffer.extend_from_slice(bytes); + for frame in drain_sse_frames(&mut self.buffer)? { + self.handle_frame(&frame, on_event)?; + } + Ok(self.message_stopped) + } + + fn handle_frame(&mut self, frame: &str, on_event: &mut F) -> Result<(), LlmError> + where + F: FnMut(StreamEvent), + { + let data_line = frame + .lines() + .find_map(|line| line.strip_prefix("data: ")) + .unwrap_or(""); + if data_line.is_empty() { + return Ok(()); + } + let ev: AnthEvent = match serde_json::from_str(data_line) { + Ok(event) => event, + Err(_) => return Ok(()), + }; + if self.message_stopped { + let detail = if ev.typ == "message_stop" { + "message_stop more than once".to_string() + } else { + format!("{} after message_stop", ev.typ) + }; + return Err(LlmError::Stream(detail)); + } + match ev.typ.as_str() { + "content_block_start" => { + let index = ev.index.unwrap_or(0) as usize; + if index != self.blocks.len() { + return Err(LlmError::Stream(format!( + "non-contiguous Anthropic content block index {index}" + ))); + } + let Some(content_block) = ev.content_block else { + return Err(LlmError::Stream( + "Anthropic content block start omitted content_block".into(), + )); + }; + let state = match content_block.get("type").and_then(|value| value.as_str()) { + Some("text") => AnthropicBlockState::Text( + content_block + .get("text") + .and_then(|value| value.as_str()) + .unwrap_or("") + .to_string(), + ), + Some("tool_use") => AnthropicBlockState::ToolUse { + id: content_block + .get("id") + .and_then(|value| value.as_str()) + .unwrap_or("") + .to_string(), + name: content_block + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or("") + .to_string(), + initial_input: content_block + .get("input") + .cloned() + .unwrap_or_else(|| serde_json::json!({})), + partial_json: String::new(), + }, + other => { + return Err(LlmError::Stream(format!( + "unsupported Anthropic content block type {other:?}" + ))) + } + }; + let block = state.content_block(); + self.blocks.insert( + index, + AnthropicBlockLifecycle { + state, + stopped: false, + }, + ); + on_event(StreamEvent::BlockUpsert { + block_index: index, + block, + }); + } + "content_block_delta" => { + let index = ev.index.unwrap_or(0) as usize; + let Some(delta) = ev.delta else { + return Ok(()); + }; + let Some(lifecycle) = self.blocks.get_mut(&index) else { + return Err(LlmError::Stream(format!( + "delta addressed missing content block {index}" + ))); + }; + if lifecycle.stopped { + return Err(LlmError::Stream(format!( + "delta after content block {index} stopped" + ))); + } + match delta.get("type").and_then(|value| value.as_str()) { + Some("text_delta") => { + let text = delta + .get("text") + .and_then(|value| value.as_str()) + .unwrap_or(""); + let AnthropicBlockState::Text(full) = &mut lifecycle.state else { + return Err(LlmError::Stream(format!( + "text delta addressed non-text block {index}" + ))); + }; + full.push_str(text); + if !text.is_empty() { + on_event(StreamEvent::BlockDelta { + block_index: index, + delta: text.to_string(), + }); + } + } + Some("input_json_delta") => { + let partial = delta + .get("partial_json") + .or_else(|| delta.get("partial")) + .and_then(|value| value.as_str()) + .unwrap_or(""); + let AnthropicBlockState::ToolUse { partial_json, .. } = + &mut lifecycle.state + else { + return Err(LlmError::Stream(format!( + "input delta addressed non-tool block {index}" + ))); + }; + partial_json.push_str(partial); + } + _ => {} + } + } + "content_block_stop" => { + let index = ev.index.unwrap_or(0) as usize; + let Some(lifecycle) = self.blocks.get_mut(&index) else { + return Err(LlmError::Stream(format!( + "content block stop addressed missing block {index}" + ))); + }; + if lifecycle.stopped { + return Err(LlmError::Stream(format!( + "content block {index} stopped more than once" + ))); + } + lifecycle.stopped = true; + on_event(StreamEvent::BlockUpsert { + block_index: index, + block: lifecycle.state.content_block(), + }); + } + "message_stop" => { + if let Some((index, _)) = self.blocks.iter().find(|(_, block)| !block.stopped) { + return Err(LlmError::Stream(format!( + "message_stop before content block {index} stopped" + ))); + } + self.message_stopped = true; + } + _ => {} + } + Ok(()) + } + + pub(crate) fn finish(self) -> Result { + if !self.buffer.iter().all(u8::is_ascii_whitespace) { + return Err(LlmError::Stream( + "Anthropic stream ended with an incomplete SSE frame".into(), + )); + } + if let Some((index, _)) = self.blocks.iter().find(|(_, block)| !block.stopped) { + return Err(LlmError::Stream(format!( + "Anthropic stream ended before content block {index} stopped" + ))); + } + if !self.message_stopped { + return Err(LlmError::Stream( + "Anthropic stream ended before message_stop".into(), + )); + } + let blocks = self + .blocks + .into_values() + .map(|block| block.state.content_block()) + .collect::>(); + let content = blocks + .iter() + .filter_map(|block| match block { + AgentContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::(); + let tool_calls = blocks + .iter() + .filter_map(|block| match block { + AgentContentBlock::ToolUse { + id, name, input, .. + } => Some(ToolCall::request(id, name, input.clone())), + _ => None, + }) + .collect(); + Ok(TurnResult { + content, + tool_calls, + blocks, + }) + } +} + +pub(super) async fn stream( + key: &str, + model: &str, + req: ChatRequest<'_>, + cancel: &AtomicBool, + on_event: &mut F, +) -> Result +where + F: FnMut(StreamEvent) + Send, +{ + let body = body(model, req.messages, req.tools); + let resp = http_client()? + .post(URL) + .header("x-api-key", key) + .header("anthropic-version", VERSION) + .json(&body) + .send() + .await?; + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + return Err(LlmError::Provider(format!("HTTP {status}: {text}"))); + } + + let mut stream = resp.bytes_stream(); + let mut decoder = StreamDecoder::default(); + while let Some(chunk) = next_chunk_or_cancel(&mut stream, cancel).await? { + let bytes = chunk.map_err(|e| LlmError::Stream(e.to_string()))?; + decoder.push_chunk(bytes.as_ref(), on_event)?; + } + + if cancel.load(Ordering::Relaxed) { + return Err(LlmError::Cancelled); + } + decoder.finish() +} diff --git a/crates/opentake-agent/src/chat/llm/openai.rs b/crates/opentake-agent/src/chat/llm/openai.rs new file mode 100644 index 00000000..6562d245 --- /dev/null +++ b/crates/opentake-agent/src/chat/llm/openai.rs @@ -0,0 +1,235 @@ +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicBool, Ordering}; + +use crate::chat::session::{AgentContentBlock, ChatMessage, Role, ToolCall}; +use crate::tools::result::Block; + +use super::{ + drain_sse_frames, http_client, next_chunk_or_cancel, ChatRequest, LlmError, StreamEvent, + ToolSchema, TurnResult, +}; + +const URL: &str = "https://api.openai.com/v1/chat/completions"; + +fn legacy_tool_result_content(content: &[Block], is_error: bool) -> String { + if let [Block::Text { text }] = content { + if serde_json::from_str::(text).is_ok() { + return text.clone(); + } + } + let summary = content + .iter() + .filter_map(|block| match block { + Block::Text { text } => Some(text.as_str()), + Block::Image { .. } => None, + }) + .collect::(); + serde_json::json!({"summary": summary, "isError": is_error}).to_string() +} + +/// Build the OpenAI request body from the session messages + tool schemas. +pub(super) fn body( + model: &str, + messages: &[ChatMessage], + tools: &[ToolSchema], +) -> serde_json::Value { + let msgs: Vec = messages.iter().map(message).collect(); + let mut body = serde_json::json!({ + "model": model, + "messages": msgs, + "stream": true, + }); + if !tools.is_empty() { + body["tools"] = serde_json::Value::Array( + tools + .iter() + .map(|t| { + serde_json::json!({ + "type": "function", + "function": { + "name": t.name, + "description": t.description, + "parameters": t.parameters, + } + }) + }) + .collect(), + ); + } + body +} + +/// Map one [`ChatMessage`] to the OpenAI wire shape. Tool-result messages carry +/// `tool_call_id`; assistant turns with tool calls carry `tool_calls`. +pub(super) fn message(m: &ChatMessage) -> serde_json::Value { + let text = m + .blocks + .iter() + .filter_map(|block| match block { + AgentContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::(); + match m.role { + Role::System => serde_json::json!({"role": "system", "content": text}), + Role::User => serde_json::json!({"role": "user", "content": text}), + Role::Assistant => { + let tool_calls = m + .blocks + .iter() + .filter_map(|block| match block { + AgentContentBlock::ToolUse { + id, name, input, .. + } => Some(serde_json::json!({ + "id": id, + "type": "function", + "function": { + "name": name, + "arguments": input.to_string(), + } + })), + _ => None, + }) + .collect::>(); + let mut v = serde_json::json!({"role": "assistant", "content": text}); + if !tool_calls.is_empty() { + v["tool_calls"] = serde_json::Value::Array(tool_calls); + } + v + } + Role::Tool => { + let (tool_call_id, content) = m + .blocks + .iter() + .find_map(|block| match block { + AgentContentBlock::ToolResult { + tool_use_id, + content, + is_error, + } => Some(( + tool_use_id.clone(), + legacy_tool_result_content(content, is_error.unwrap_or(false)), + )), + _ => None, + }) + .unwrap_or_default(); + serde_json::json!({ + "role": "tool", + "content": content, + "tool_call_id": tool_call_id, + }) + } + } +} + +pub(super) async fn stream( + key: &str, + model: &str, + req: ChatRequest<'_>, + cancel: &AtomicBool, + on_event: &mut F, +) -> Result +where + F: FnMut(StreamEvent) + Send, +{ + let body = body(model, req.messages, req.tools); + let resp = http_client()? + .post(URL) + .bearer_auth(key) + .json(&body) + .send() + .await?; + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + return Err(LlmError::Provider(format!("HTTP {status}: {text}"))); + } + + let mut full = String::new(); + let mut tool_buf: BTreeMap = BTreeMap::new(); + let mut stream = resp.bytes_stream(); + let mut buf = Vec::new(); + while let Some(chunk) = next_chunk_or_cancel(&mut stream, cancel).await? { + let bytes = chunk.map_err(|e| LlmError::Stream(e.to_string()))?; + buf.extend_from_slice(bytes.as_ref()); + for event in drain_sse_frames(&mut buf)? { + if let Some(rest) = event.strip_prefix("data: ") { + let rest = rest.trim(); + if rest == "[DONE]" { + continue; + } + let v: serde_json::Value = match serde_json::from_str(rest) { + Ok(v) => v, + Err(_) => continue, + }; + let Some(delta) = v + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("delta")) + else { + continue; + }; + if let Some(text) = delta.get("content").and_then(|c| c.as_str()) { + if !text.is_empty() { + full.push_str(text); + on_event(StreamEvent::BlockDelta { + block_index: 0, + delta: text.to_string(), + }); + } + } + if let Some(arr) = delta.get("tool_calls").and_then(|t| t.as_array()) { + for tc in arr { + let idx = tc.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize; + let entry = tool_buf + .entry(idx) + .or_insert_with(|| (String::new(), String::new(), String::new())); + if let Some(id) = tc.get("id").and_then(|i| i.as_str()) { + entry.0 = id.to_string(); + } + if let Some(fn_obj) = tc.get("function") { + if let Some(name) = fn_obj.get("name").and_then(|n| n.as_str()) { + entry.1 = name.to_string(); + } + if let Some(args) = fn_obj.get("arguments").and_then(|a| a.as_str()) { + entry.2.push_str(args); + } + } + } + } + } + } + } + + let mut blocks = (!full.is_empty()) + .then(|| AgentContentBlock::Text { text: full.clone() }) + .into_iter() + .collect::>(); + let mut tool_calls = Vec::new(); + if cancel.load(Ordering::Relaxed) { + return Err(LlmError::Cancelled); + } + for (index, (id, name, args_str)) in tool_buf { + let args = if args_str.is_empty() { + serde_json::json!({}) + } else { + serde_json::from_str(&args_str).unwrap_or(serde_json::json!({"_raw": args_str})) + }; + let tc = ToolCall::request(id, name, args); + let block_index = blocks.len(); + debug_assert_eq!(block_index, index + usize::from(!full.is_empty())); + let block = AgentContentBlock::from(tc.clone()); + on_event(StreamEvent::BlockUpsert { + block_index, + block: block.clone(), + }); + blocks.push(block); + tool_calls.push(tc); + } + + Ok(TurnResult { + content: full, + tool_calls, + blocks, + }) +} diff --git a/crates/opentake-agent/src/chat/llm/tests.rs b/crates/opentake-agent/src/chat/llm/tests.rs new file mode 100644 index 00000000..b1895919 --- /dev/null +++ b/crates/opentake-agent/src/chat/llm/tests.rs @@ -0,0 +1,401 @@ +use super::openai::{body as openai_body, message as openai_message}; +use super::*; +use crate::tools::result::Block; +use opentake_gen::MemoryKeyStore; + +fn store_with_key(provider: LlmProvider, value: &str) -> MemoryKeyStore { + MemoryKeyStore::new().with_key(provider.key(), value) +} + +#[test] +fn provider_choice_is_explicit() { + assert_eq!(provider_from_choice("openai").unwrap(), LlmProvider::OpenAi); + assert_eq!( + provider_from_choice("anthropic").unwrap(), + LlmProvider::Anthropic + ); + let err = provider_from_choice("google").unwrap_err().to_string(); + assert!(err.contains("does not support provider")); + let err = provider_from_choice("mystery").unwrap_err().to_string(); + assert!(err.contains("unknown provider")); +} + +#[test] +fn stream_chat_requires_a_key_for_the_selected_provider() { + let store = MemoryKeyStore::new(); + let err = futures::executor::block_on(stream_chat( + LlmProvider::OpenAi, + &store, + ChatRequest { + messages: &[ChatMessage::user("hi")], + tools: &[], + model: None, + }, + &AtomicBool::new(false), + |_| {}, + )) + .unwrap_err() + .to_string(); + assert!(err.contains("no API key configured for openai")); +} + +#[test] +fn no_key_guide_mentions_settings_and_provider() { + let msg = no_key_guide(LlmProvider::Anthropic); + assert!(msg.contains("Settings")); + assert!(msg.contains("Anthropic")); +} + +#[test] +fn memory_store_round_trips_selected_provider_key() { + let store = store_with_key(LlmProvider::OpenAi, "sk-test"); + let dyn_store: &dyn KeyStore = &store; + assert_eq!( + dyn_store + .load(ProviderKey::OpenAI.account()) + .unwrap() + .as_deref(), + Some("sk-test") + ); + assert_eq!( + dyn_store.load(ProviderKey::Anthropic.account()).unwrap(), + None + ); +} + +#[test] +fn openai_body_shape_minimum() { + let msgs = vec![ChatMessage::user("hi")]; + let body = openai_body("gpt-4o-mini", &msgs, &[]); + assert_eq!(body["model"], "gpt-4o-mini"); + assert_eq!(body["stream"], true); + assert_eq!(body["messages"][0]["role"], "user"); + assert_eq!(body["messages"][0]["content"], "hi"); + assert!(body.get("tools").is_none()); +} + +#[test] +fn openai_body_with_tools() { + let tools = vec![ToolSchema { + name: "get_timeline".into(), + description: "read".into(), + parameters: serde_json::json!({"type": "object"}), + }]; + let body = openai_body("m", &[ChatMessage::user("x")], &tools); + let t = &body["tools"][0]; + assert_eq!(t["type"], "function"); + assert_eq!(t["function"]["name"], "get_timeline"); + assert_eq!(t["function"]["parameters"]["type"], "object"); +} + +#[test] +fn openai_assistant_with_tool_calls_round_trips() { + let tc = ToolCall::request("call-1", "split_clip", serde_json::json!({"atFrame": 10})); + let m = ChatMessage::assistant("splitting", vec![tc]); + let v = openai_message(&m); + assert_eq!(v["role"], "assistant"); + assert_eq!(v["tool_calls"][0]["id"], "call-1"); + assert_eq!(v["tool_calls"][0]["function"]["name"], "split_clip"); + assert_eq!( + v["tool_calls"][0]["function"]["arguments"], + "{\"atFrame\":10}" + ); +} + +#[test] +fn openai_message_derives_assistant_fields_from_authoritative_blocks() { + let mut message = ChatMessage::assistant_blocks_with_id( + "assistant-blocks", + vec![ + AgentContentBlock::Text { text: "A".into() }, + AgentContentBlock::ToolUse { + id: "call-block".into(), + name: "split_clip".into(), + input: serde_json::json!({"atFrame": 10}), + result: None, + is_error: None, + }, + AgentContentBlock::Text { text: "B".into() }, + ], + ); + message.content = "stale".into(); + message.tool_calls = vec![ToolCall::request( + "call-stale", + "delete_clip", + serde_json::json!({}), + )]; + + let wire = openai_message(&message); + + assert_eq!(wire["content"], "AB"); + assert_eq!(wire["tool_calls"].as_array().unwrap().len(), 1); + assert_eq!(wire["tool_calls"][0]["id"], "call-block"); + assert_eq!(wire["tool_calls"][0]["function"]["name"], "split_clip"); +} + +#[test] +fn openai_tool_result_carries_tool_call_id() { + let m = ChatMessage::tool_result("call-1", serde_json::json!({"summary": "ok"})); + let v = openai_message(&m); + assert_eq!(v["role"], "tool"); + assert_eq!(v["tool_call_id"], "call-1"); +} + +#[test] +fn anthropic_system_prompt_hoisted_top_level() { + let msgs = vec![ + ChatMessage::system("you are an editor"), + ChatMessage::user("hi"), + ]; + let body = anthropic_body("claude", &msgs, &[]); + assert_eq!(body["system"][0]["type"], "text"); + assert_eq!(body["system"][0]["text"], "you are an editor"); + assert_eq!(body["system"][0]["cache_control"]["type"], "ephemeral"); + assert_eq!(body["messages"].as_array().unwrap().len(), 1); + assert_eq!(body["messages"][0]["role"], "user"); +} + +#[test] +fn anthropic_body_preserves_authoritative_interleaved_assistant_blocks() { + let message = ChatMessage::assistant_blocks_with_id( + "assistant-interleaved", + vec![ + AgentContentBlock::Text { text: "A".into() }, + AgentContentBlock::ToolUse { + id: "call-1".into(), + name: "split_clip".into(), + input: serde_json::json!({"clipId": "c1"}), + result: None, + is_error: None, + }, + AgentContentBlock::Text { text: "B".into() }, + ], + ); + + let body = anthropic_body("claude", &[message], &[]); + + assert_eq!( + body["messages"][0]["content"], + serde_json::json!([ + {"type": "text", "text": "A"}, + { + "type": "tool_use", + "id": "call-1", + "name": "split_clip", + "input": {"clipId": "c1"} + }, + {"type": "text", "text": "B", "cache_control": {"type": "ephemeral"}} + ]) + ); +} + +#[test] +fn anthropic_request_sets_all_prompt_cache_boundaries_and_upstream_token_limit() { + let tools = vec![ + ToolSchema { + name: "get_timeline".into(), + description: "read".into(), + parameters: serde_json::json!({"type": "object"}), + }, + ToolSchema { + name: "split_clip".into(), + description: "edit".into(), + parameters: serde_json::json!({"type": "object"}), + }, + ]; + let messages = vec![ + ChatMessage::system("system"), + ChatMessage::user("first"), + ChatMessage::assistant("ack", vec![]), + ChatMessage::user("latest"), + ]; + + let body = anthropic_body("claude", &messages, &tools); + + assert_eq!(body["max_tokens"], 8192); + assert!(body["tools"][0].get("cache_control").is_none()); + assert_eq!(body["tools"][1]["cache_control"]["type"], "ephemeral"); + assert_eq!( + body["messages"][2]["content"][0]["cache_control"]["type"], + "ephemeral" + ); + assert!(body["messages"][0]["content"][0] + .get("cache_control") + .is_none()); +} + +#[test] +fn anthropic_tool_results_nest_under_user_turns() { + let msgs = vec![ + ChatMessage::user("please"), + ChatMessage::assistant( + "", + vec![ToolCall::request( + "c1", + "get_timeline", + serde_json::json!({}), + )], + ), + ChatMessage::tool_error_result("c1", serde_json::json!({"error": "Cancelled"})), + ]; + let body = anthropic_body("claude", &msgs, &[]); + let turns = body["messages"].as_array().unwrap(); + assert_eq!(turns.len(), 3); + let last = turns.last().unwrap(); + assert_eq!(last["role"], "user"); + assert_eq!(last["content"][0]["type"], "tool_result"); + assert_eq!(last["content"][0]["tool_use_id"], "c1"); + assert_eq!(last["content"][0]["is_error"], true); +} + +#[test] +fn anthropic_tool_results_preserve_native_image_blocks() { + let message = ChatMessage::tool_result_blocks( + "c-image", + vec![ + Block::text("before"), + Block::image("aW1hZ2U=", "image/png"), + Block::text("after"), + ], + serde_json::json!({"summary": "beforeafter", "isError": false}), + false, + ); + + let body = anthropic_body("claude", &[message], &[]); + let content = &body["messages"][0]["content"][0]["content"]; + + assert_eq!( + content[0], + serde_json::json!({"type": "text", "text": "before"}) + ); + assert_eq!(content[1]["type"], "image"); + assert_eq!(content[1]["source"]["type"], "base64"); + assert_eq!(content[1]["source"]["media_type"], "image/png"); + assert_eq!(content[1]["source"]["data"], "aW1hZ2U="); + assert_eq!( + content[2], + serde_json::json!({"type": "text", "text": "after"}) + ); +} + +fn decode_anthropic_sse(sse: &str) -> Result { + let mut decoder = AnthropicStreamDecoder::default(); + decoder.push_chunk(sse.as_bytes(), &mut |_| {})?; + decoder.finish() +} + +#[test] +fn anthropic_stream_rejects_eof_before_text_block_and_message_stop() { + let sse = concat!( + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"partial\"}}\n\n", + ); + + let error = decode_anthropic_sse(sse).unwrap_err().to_string(); + + assert!(error.contains("before content block 0 stopped")); +} + +#[test] +fn anthropic_stream_rejects_eof_before_tool_block_and_message_stop() { + let sse = concat!( + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call-1\",\"name\":\"split_clip\",\"input\":{}}}\n\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"clipId\\\":\"}}\n\n", + ); + + let error = decode_anthropic_sse(sse).unwrap_err().to_string(); + + assert!(error.contains("before content block 0 stopped")); +} + +#[test] +fn anthropic_stream_rejects_eof_without_message_stop() { + let sse = concat!( + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + ); + + let error = decode_anthropic_sse(sse).unwrap_err().to_string(); + + assert!(error.contains("before message_stop")); +} + +#[test] +fn anthropic_stream_rejects_message_stop_while_a_block_is_open() { + let sse = concat!( + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n", + "data: {\"type\":\"message_stop\"}\n\n", + ); + + let error = decode_anthropic_sse(sse).unwrap_err().to_string(); + + assert!(error.contains("message_stop before content block 0 stopped")); +} + +#[test] +fn anthropic_stream_rejects_repeated_content_block_stop() { + let sse = concat!( + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "data: {\"type\":\"message_stop\"}\n\n", + ); + + let error = decode_anthropic_sse(sse).unwrap_err().to_string(); + + assert!(error.contains("content block 0 stopped more than once")); +} + +#[test] +fn anthropic_stream_rejects_delta_after_content_block_stop() { + let sse = concat!( + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"late\"}}\n\n", + "data: {\"type\":\"message_stop\"}\n\n", + ); + + let error = decode_anthropic_sse(sse).unwrap_err().to_string(); + + assert!(error.contains("delta after content block 0 stopped")); +} + +#[test] +fn anthropic_stream_rejects_repeated_message_stop() { + let sse = concat!( + "data: {\"type\":\"message_stop\"}\n\n", + "data: {\"type\":\"message_stop\"}\n\n", + ); + + let error = decode_anthropic_sse(sse).unwrap_err().to_string(); + + assert!(error.contains("message_stop more than once")); +} + +#[test] +fn drain_sse_frames_handles_split_utf8_chunks() { + let mut buffer = Vec::new(); + let bytes = "data: {\"text\":\"你\"}\n\n".as_bytes(); + buffer.extend_from_slice(&bytes[..15]); + assert!(drain_sse_frames(&mut buffer).unwrap().is_empty()); + buffer.extend_from_slice(&bytes[15..]); + let frames = drain_sse_frames(&mut buffer).unwrap(); + assert_eq!(frames, vec!["data: {\"text\":\"你\"}"]); + assert!(buffer.is_empty()); +} + +#[tokio::test(start_paused = true)] +async fn next_chunk_or_cancel_interrupts_pending_stream() { + let cancel = AtomicBool::new(false); + let mut stream = futures::stream::pending::, std::io::Error>>(); + let wait = next_chunk_or_cancel(&mut stream, &cancel); + tokio::pin!(wait); + + tokio::task::yield_now().await; + tokio::time::advance(CANCEL_POLL_INTERVAL).await; + cancel.store(true, Ordering::Relaxed); + tokio::time::advance(CANCEL_POLL_INTERVAL).await; + + let err = wait.await.unwrap_err().to_string(); + assert!(err.contains("cancelled")); +} diff --git a/crates/opentake-agent/src/chat/loop.rs b/crates/opentake-agent/src/chat/loop.rs index e444da05..50e496ef 100644 --- a/crates/opentake-agent/src/chat/loop.rs +++ b/crates/opentake-agent/src/chat/loop.rs @@ -22,7 +22,9 @@ use opentake_gen::KeyStore; use crate::chat::llm::{ no_key_guide, provider_from_choice, stream_chat, ChatRequest, LlmError, StreamEvent, ToolSchema, }; -use crate::chat::session::{ChatMessage, ChatSession, ToolCall}; +use crate::chat::session::{ + next_message_id, AgentContentBlock, ChatMessage, ChatSession, ToolCall, +}; use crate::mcp::convert::safe_tool_result_for_llm; use crate::mcp::dispatch::Dispatcher; use crate::plugin::registry::PluginRegistry; @@ -49,6 +51,35 @@ fn tool_result_message( ChatMessage::tool_result_blocks(tool_call_id, blocks, safe_result, result.is_error) } +fn persist_tool_result_message( + session: &mut ChatSession, + session_id: &str, + emitter: &dyn EmitLoop, + message: ChatMessage, +) { + debug_assert_eq!(message.role, crate::chat::session::Role::Tool); + debug_assert!(!message.blocks.is_empty()); + + let message_id = message.id.clone(); + let mut sequence = EventSequence::default(); + session.messages.push(message.clone()); + for (block_index, block) in message.blocks.iter().cloned().enumerate() { + emitter.emit(LoopEvent::BlockUpsert { + session_id: session_id.to_string(), + message_id: message_id.clone(), + sequence: sequence.take(), + block_index, + block, + }); + } + emitter.emit(LoopEvent::Done { + session_id: session_id.to_string(), + message_id, + sequence: sequence.take(), + message, + }); +} + fn map_dispatch_join_error(error: tokio::task::JoinError) -> LlmError { tracing::error!( target: "opentake::chat::private", @@ -70,14 +101,8 @@ fn has_trailing_user_message(session: &ChatSession, text: &str) -> bool { ) } -fn persist_assistant_tool_round( - session: &mut ChatSession, - content: String, - tool_calls: Vec, -) -> usize { - session - .messages - .push(ChatMessage::assistant(content, tool_calls)); +fn persist_assistant_tool_round(session: &mut ChatSession, message: ChatMessage) -> usize { + session.messages.push(message); session.messages.len() - 1 } @@ -85,17 +110,16 @@ fn update_assistant_tool_call( session: &mut ChatSession, assistant_index: usize, resolved_tool_call: &ToolCall, -) { +) -> Option<(usize, AgentContentBlock)> { if let Some(message) = session.messages.get_mut(assistant_index) { - if let Some(existing) = message - .tool_calls - .iter_mut() - .find(|tool_call| tool_call.id == resolved_tool_call.id) - { - *existing = resolved_tool_call.clone(); - message.refresh_blocks(); - } + let block_index = message.upsert_tool_use(resolved_tool_call.clone()); + return message + .blocks + .get(block_index) + .cloned() + .map(|block| (block_index, block)); } + None } /// Restore the provider protocol after cancellation or a failed dispatch left @@ -135,14 +159,15 @@ fn resolve_orphan_tool_uses(messages: &mut Vec) -> usize { .collect(); for tool_call_id in missing { let cancelled = serde_json::json!({"error": "Cancelled"}); - if let Some(tool_call) = messages[index] + let mut tool_call = messages[index] .tool_calls - .iter_mut() + .iter() .find(|tool_call| tool_call.id == tool_call_id) - { - tool_call.result = Some(cancelled.clone()); - tool_call.is_error = Some(true); - } + .cloned() + .unwrap_or_else(|| ToolCall::request(&tool_call_id, "", serde_json::json!({}))); + tool_call.result = Some(cancelled.clone()); + tool_call.is_error = Some(true); + messages[index].upsert_tool_use(tool_call); messages.insert( insert_at, ChatMessage::tool_error_result(tool_call_id, cancelled), @@ -150,8 +175,6 @@ fn resolve_orphan_tool_uses(messages: &mut Vec) -> usize { insert_at += 1; repaired += 1; } - messages[index].refresh_blocks(); - index = insert_at; } @@ -162,29 +185,125 @@ fn resolve_orphan_tool_uses(messages: &mut Vec) -> usize { /// matching front-end event. #[derive(Clone, Debug)] pub enum LoopEvent { - /// A text chunk from the assistant (concatenate in order). - Delta { session_id: String, delta: String }, - /// A tool call. Emitted twice per call: once when the model requests it - /// (result=None), once after dispatch fills the result. - ToolCall { + /// A text chunk addressed to one authoritative message block. + BlockDelta { + session_id: String, + message_id: String, + sequence: u64, + block_index: usize, + delta: String, + }, + /// Insert or replace one authoritative content block. + BlockUpsert { session_id: String, - tool_call: ToolCall, + message_id: String, + sequence: u64, + block_index: usize, + block: AgentContentBlock, }, /// The assistant turn is complete (final text + all tool calls resolved). Done { session_id: String, + message_id: String, + sequence: u64, message: ChatMessage, }, } +#[derive(Default)] +struct EventSequence(u64); + +impl EventSequence { + fn take(&mut self) -> u64 { + let sequence = self.0; + self.0 = self.0.saturating_add(1); + sequence + } + + fn next(&self) -> u64 { + self.0 + } +} + +fn apply_stream_event( + assistant: &mut ChatMessage, + session_id: &str, + emitter: &dyn EmitLoop, + sequence: &mut EventSequence, + event: StreamEvent, +) { + match event { + StreamEvent::BlockDelta { block_index, delta } => { + let applied = assistant.append_text_delta_at(block_index, &delta); + debug_assert!(applied, "provider emitted an invalid text block index"); + if applied { + emitter.emit(LoopEvent::BlockDelta { + session_id: session_id.to_string(), + message_id: assistant.id.clone(), + sequence: sequence.take(), + block_index, + delta, + }); + } + } + StreamEvent::BlockUpsert { block_index, block } => { + let applied = assistant.upsert_block_at(block_index, block.clone()); + debug_assert!(applied, "provider emitted a non-contiguous block index"); + if applied { + emitter.emit(LoopEvent::BlockUpsert { + session_id: session_id.to_string(), + message_id: assistant.id.clone(), + sequence: sequence.take(), + block_index, + block, + }); + } + } + } +} + /// Errors a turn can hit. The shell maps these to a final `chat_done` with an /// error-styled assistant message so the user sees what went wrong. #[derive(Debug, thiserror::Error)] pub enum LoopError { - #[error("LLM error: {0}")] - Llm(#[from] LlmError), + #[error("LLM error: {source}")] + Llm { + #[source] + source: LlmError, + message_id: String, + sequence: u64, + }, #[error("cancelled")] - Cancelled, + Cancelled { message_id: String, sequence: u64 }, +} + +impl LoopError { + pub fn llm(source: LlmError, message_id: &str, sequence: u64) -> Self { + Self::Llm { + source, + message_id: message_id.to_string(), + sequence, + } + } + + pub fn cancelled(message_id: &str, sequence: u64) -> Self { + Self::Cancelled { + message_id: message_id.to_string(), + sequence, + } + } + + pub fn message_id(&self) -> &str { + match self { + Self::Llm { message_id, .. } | Self::Cancelled { message_id, .. } => message_id, + } + } + + pub fn sequence(&self) -> u64 { + match self { + Self::Llm { sequence, .. } | Self::Cancelled { sequence, .. } => *sequence, + } + } } /// The bound a host provides so the loop can emit events back to the UI. The @@ -338,14 +457,18 @@ impl ChatLoop { user_text: String, emitter: &dyn EmitLoop, cancel: Arc, - ) -> Result<(), LoopError> { + ) -> Result { + let message_id = next_message_id(); self.run_turn_gated( session, provider_choice, user_text, + ChatTurn { + first_message_id: message_id, + cancel, + gate: Arc::new(DirectChatTurnGate), + }, emitter, - cancel, - Arc::new(DirectChatTurnGate), ) .await } @@ -358,11 +481,16 @@ impl ChatLoop { session: &mut ChatSession, provider_choice: String, user_text: String, + turn: ChatTurn, emitter: &dyn EmitLoop, - cancel: Arc, - gate: Arc, - ) -> Result<(), LoopError> { - let provider = provider_from_choice(&provider_choice)?; + ) -> Result { + let ChatTurn { + first_message_id, + cancel, + gate, + } = turn; + let provider = provider_from_choice(&provider_choice) + .map_err(|error| LoopError::llm(error, &first_message_id, 0))?; session.provider = Some(provider_choice); session.model = Some(provider.default_model().to_string()); if !has_trailing_user_message(session, &user_text) { @@ -372,30 +500,39 @@ impl ChatLoop { if self .store .load(provider.key().account()) - .map_err(|e| LlmError::Network(format!("keychain: {e}")))? + .map_err(|e| LlmError::Network(format!("keychain: {e}"))) + .map_err(|error| LoopError::llm(error, &first_message_id, 0))? .is_none() { + let mut sequence = EventSequence::default(); let guide = no_key_guide(provider); - let msg = ChatMessage::assistant(guide.clone(), Vec::new()); - emitter.emit(LoopEvent::Delta { + let msg = ChatMessage::assistant_with_id(&first_message_id, &guide, Vec::new()); + emitter.emit(LoopEvent::BlockDelta { session_id: session.id.clone(), + message_id: first_message_id.clone(), + sequence: sequence.take(), + block_index: 0, delta: guide, }); emitter.emit(LoopEvent::Done { session_id: session.id.clone(), + message_id: first_message_id.clone(), + sequence: sequence.take(), message: msg.clone(), }); session.messages.push(msg); - return Ok(()); + return Ok(first_message_id); } let tools = self.tool_catalog(); let sid = session.id.clone(); + let mut message_id = first_message_id; const MAX_ROUNDS: usize = 8; for _ in 0..MAX_ROUNDS { + let mut sequence = EventSequence::default(); if cancel.load(Ordering::Relaxed) { - return Err(LoopError::Cancelled); + return Err(LoopError::cancelled(&message_id, sequence.next())); } let repaired = resolve_orphan_tool_uses(&mut session.messages); @@ -409,7 +546,7 @@ impl ChatLoop { let Some(timeline) = gate.timeline(&self.dispatcher) else { cancel.store(true, Ordering::Relaxed); - return Err(LoopError::Cancelled); + return Err(LoopError::cancelled(&message_id, sequence.next())); }; let system = self.system_prompt_for_timeline(timeline); let mut messages = Vec::with_capacity(session.messages.len() + 1); @@ -422,39 +559,32 @@ impl ChatLoop { model: session.model.as_deref(), }; - let sid_clone = sid.clone(); - let turn = match stream_chat(provider, self.store.as_ref(), req, &cancel, |ev| match ev - { - StreamEvent::Delta(delta) => emitter.emit(LoopEvent::Delta { - session_id: sid_clone.clone(), - delta, - }), - StreamEvent::ToolCall(tool_call) => emitter.emit(LoopEvent::ToolCall { - session_id: sid_clone.clone(), - tool_call, - }), + let mut assistant = ChatMessage::assistant_blocks_with_id(&message_id, Vec::new()); + let turn = match stream_chat(provider, self.store.as_ref(), req, &cancel, |event| { + apply_stream_event(&mut assistant, &sid, emitter, &mut sequence, event) }) .await { Ok(turn) => turn, - Err(LlmError::Cancelled) => return Err(LoopError::Cancelled), - Err(err) => return Err(LoopError::Llm(err)), + Err(LlmError::Cancelled) => { + return Err(LoopError::cancelled(&message_id, sequence.next())) + } + Err(error) => return Err(LoopError::llm(error, &message_id, sequence.next())), }; if cancel.load(Ordering::Relaxed) { - return Err(LoopError::Cancelled); + return Err(LoopError::cancelled(&message_id, sequence.next())); } - let assistant_index = persist_assistant_tool_round( - session, - turn.content.clone(), - turn.tool_calls.clone(), - ); + debug_assert_eq!(assistant.content, turn.content); + debug_assert_eq!(assistant.tool_calls.len(), turn.tool_calls.len()); + debug_assert_eq!(assistant.blocks, turn.blocks); + let assistant_index = persist_assistant_tool_round(session, assistant); let mut resolved = Vec::with_capacity(turn.tool_calls.len()); for mut tc in turn.tool_calls { if cancel.load(Ordering::Relaxed) { - return Err(LoopError::Cancelled); + return Err(LoopError::cancelled(&message_id, sequence.next())); } let dispatcher = self.dispatcher.clone(); let gate = gate.clone(); @@ -464,303 +594,79 @@ impl ChatLoop { with_redacted_dispatch_panic(|| gate.dispatch(&dispatcher, &name, args)) }) .await - .map_err(map_dispatch_join_error)?; + .map_err(map_dispatch_join_error) + .map_err(|error| LoopError::llm(error, &message_id, sequence.next()))?; let Some(result) = result else { cancel.store(true, Ordering::Relaxed); - return Err(LoopError::Cancelled); + return Err(LoopError::cancelled(&message_id, sequence.next())); }; if cancel.load(Ordering::Relaxed) { - return Err(LoopError::Cancelled); + return Err(LoopError::cancelled(&message_id, sequence.next())); } let result_json = tool_result_for_model(&result); let tc_id = tc.id.clone(); tc.result = Some(result_json.clone()); tc.is_error = Some(result.is_error); - update_assistant_tool_call(session, assistant_index, &tc); - emitter.emit(LoopEvent::ToolCall { - session_id: sid.clone(), - tool_call: tc.clone(), - }); - session - .messages - .push(tool_result_message(tc_id, &result, result_json)); + if let Some((block_index, block)) = + update_assistant_tool_call(session, assistant_index, &tc) + { + emitter.emit(LoopEvent::BlockUpsert { + session_id: sid.clone(), + message_id: message_id.clone(), + sequence: sequence.take(), + block_index, + block, + }); + } + let tool_result = tool_result_message(tc_id, &result, result_json); + persist_tool_result_message(session, &sid, emitter, tool_result); resolved.push(tc); } if resolved.is_empty() { if cancel.load(Ordering::Relaxed) { - return Err(LoopError::Cancelled); + return Err(LoopError::cancelled(&message_id, sequence.next())); } let assistant = session.messages[assistant_index].clone(); emitter.emit(LoopEvent::Done { session_id: sid.clone(), + message_id: message_id.clone(), + sequence: sequence.take(), message: assistant, }); - return Ok(()); + return Ok(message_id); } + message_id = next_message_id(); } - let last = session.messages.last().cloned().unwrap_or_else(|| { - ChatMessage::assistant("Reached the tool-call round limit; stopping.", Vec::new()) - }); if cancel.load(Ordering::Relaxed) { - return Err(LoopError::Cancelled); + return Err(LoopError::cancelled(&message_id, 0)); } + let mut sequence = EventSequence::default(); + let text = "Reached the tool-call round limit; stopping."; + let last = ChatMessage::assistant_with_id(&message_id, text, Vec::new()); + emitter.emit(LoopEvent::BlockDelta { + session_id: sid.clone(), + message_id: message_id.clone(), + sequence: sequence.take(), + block_index: 0, + delta: text.to_string(), + }); emitter.emit(LoopEvent::Done { session_id: sid, + message_id: message_id.clone(), + sequence: sequence.take(), message: last, }); - Ok(()) + Ok(message_id) } } -#[cfg(test)] -mod tests { - use super::*; - use crate::mcp::core_handle::CoreHandle; - use opentake_domain::{Clip, ClipType, MediaManifest, Timeline, Track}; - use opentake_gen::MemoryKeyStore; - use opentake_ops::{EditCommand, EditResult}; - use std::path::PathBuf; - use std::sync::Mutex; - - #[test] - fn orphan_tool_uses_are_repaired_before_the_next_user_turn() { - let mut orphan = - ToolCall::request("missing", "split_clip", serde_json::json!({"clipId": "c1"})); - let resolved = ToolCall::request("resolved", "get_timeline", serde_json::json!({})); - let mut messages = vec![ - ChatMessage::assistant("working", vec![resolved, orphan.clone()]), - ChatMessage::tool_result("resolved", serde_json::json!({"ok": true})), - ChatMessage::user("continue"), - ]; - - assert_eq!(resolve_orphan_tool_uses(&mut messages), 1); - assert_eq!(messages.len(), 4); - assert_eq!(messages[2].role, crate::chat::Role::Tool); - assert_eq!(messages[2].tool_call_id.as_deref(), Some("missing")); - assert_eq!(messages[2].tool_is_error, Some(true)); - assert!(messages[2].content.contains("Cancelled")); - assert_eq!(messages[3].role, crate::chat::Role::User); - orphan.result = Some(serde_json::json!({"error": "Cancelled"})); - orphan.is_error = Some(true); - assert_eq!(messages[0].tool_calls[1].result, orphan.result); - assert_eq!(messages[0].tool_calls[1].is_error, Some(true)); - - assert_eq!(resolve_orphan_tool_uses(&mut messages), 0); - assert_eq!(messages.len(), 4); - } - - #[test] - fn dispatcher_failures_become_provider_error_results() { - let failed_result = ToolResult::error("invalid clip"); - let failed_safe = tool_result_for_model(&failed_result); - let failed = tool_result_message("call-failed", &failed_result, failed_safe.clone()); - assert_eq!(failed.tool_call_id.as_deref(), Some("call-failed")); - assert_eq!(failed.tool_is_error, Some(true)); - assert_eq!( - serde_json::from_str::(&failed.content).unwrap(), - failed_safe - ); - - let succeeded_result = ToolResult::ok("done"); - let succeeded = tool_result_message( - "call-ok", - &succeeded_result, - tool_result_for_model(&succeeded_result), - ); - assert_eq!(succeeded.tool_is_error, None); - } - - /// A minimal CoreHandle over an in-memory timeline + manifest, so the loop - /// can dispatch read tools without a full AppCore. - struct FakeHandle { - timeline: Timeline, - } - impl CoreHandle for FakeHandle { - fn timeline(&self) -> Timeline { - self.timeline.clone() - } - fn media(&self) -> MediaManifest { - MediaManifest::new() - } - fn apply(&self, _cmd: EditCommand) -> anyhow::Result { - Ok(EditResult { - changed: false, - timeline_changed: false, - manifest_changed: false, - action_name: "noop".into(), - affected_clip_ids: Vec::new(), - timeline_version: 0, - summary: "noop".into(), - }) - } - fn project_dir(&self) -> Option { - None - } - } - - /// An emitter that just collects events for assertions. - struct CollectEmitter { - events: Arc>>, - } - impl EmitLoop for CollectEmitter { - fn emit(&self, event: LoopEvent) { - let s = match event { - LoopEvent::Delta { delta, .. } => format!("delta:{delta}"), - LoopEvent::ToolCall { tool_call, .. } => { - format!( - "tool:{}:{}", - tool_call.name, - tool_call.is_error.unwrap_or(false) - ) - } - LoopEvent::Done { message, .. } => format!("done:{}", message.content), - }; - self.events.lock().unwrap().push(s); - } - } - - fn talking_head_timeline() -> Timeline { - let mut tl = Timeline::new(); - let mut v = Track::new("v1", ClipType::Video); - v.clips.push(Clip::new("c1", "asset", 0, 30 * 20)); - tl.tracks.push(v); - tl - } - - fn build_loop(timeline: Timeline, store: Arc) -> ChatLoop { - let handle: Arc = Arc::new(FakeHandle { timeline }); - let registry = Arc::new(RwLock::new(PluginRegistry::new())); - let dispatcher = Arc::new(Dispatcher::new(handle, registry.clone())); - ChatLoop::new(dispatcher, registry, store) - } - - #[test] - fn tool_catalog_hides_bridge_tools_when_bridge_is_missing() { - let loop_ = build_loop(talking_head_timeline(), Arc::new(MemoryKeyStore::new())); - let tools = loop_.tool_catalog(); - assert!(tools.iter().any(|t| t.name == "tighten_silences")); - assert!(!tools.iter().any(|t| t.name == "remove_filler_words")); - assert!(!tools.iter().any(|t| t.name == "get_transcript")); - assert!(!tools.iter().any(|t| t.name == "search_media")); - assert!(!tools.iter().any(|t| t.name == "inspect_media")); - assert!(!tools.iter().any(|t| t.name == "inspect_timeline")); - assert!(!tools.iter().any(|t| t.name == "import_media")); - } - - #[test] - fn system_prompt_includes_context_signal() { - let loop_ = build_loop(talking_head_timeline(), Arc::new(MemoryKeyStore::new())); - let prompt = loop_.system_prompt(); - assert!(prompt.contains("context signal")); - assert!(prompt.contains("talking_head") || prompt.contains("video_type")); - } - - #[test] - fn tool_round_persists_assistant_before_tool_results() { - let mut session = ChatSession::new("s1"); - session.messages.push(ChatMessage::user("trim this")); - - let requested = vec![ToolCall::request( - "call-1", - "get_timeline", - serde_json::json!({}), - )]; - let assistant_index = - persist_assistant_tool_round(&mut session, "working".into(), requested.clone()); - - let mut resolved = requested[0].clone(); - resolved.result = Some(serde_json::json!({"summary": "ok"})); - resolved.is_error = Some(false); - update_assistant_tool_call(&mut session, assistant_index, &resolved); - session.messages.push(ChatMessage::tool_result( - resolved.id.clone(), - resolved.result.clone().unwrap(), - )); - - assert_eq!( - session.messages[1].role, - crate::chat::session::Role::Assistant - ); - assert_eq!(session.messages[1].tool_calls.len(), 1); - assert_eq!( - session.messages[1].tool_calls[0].result, - Some(serde_json::json!({"summary": "ok"})) - ); - assert_eq!(session.messages[2].role, crate::chat::session::Role::Tool); - assert_eq!(session.messages[2].tool_call_id.as_deref(), Some("call-1")); - } - - #[test] - fn chat_tool_result_uses_shared_fail_closed_error_boundary() { - let private = "quota exhausted for customer alice plan enterprise"; - let value = tool_result_for_model(&ToolResult::error(private)); - let wire = value.to_string(); - assert!(wire.contains("MCP_TOOL_ERROR_REDACTED")); - assert!(!wire.contains(private)); - assert_eq!(value["isError"], true); - } - - #[tokio::test] - async fn chat_join_error_does_not_expose_panic_payload() { - let join = tokio::task::spawn_blocking(|| { - with_redacted_dispatch_panic(|| { - panic!("provider panic carried oauth-super-secret-token") - }) - }) - .await - .expect_err("worker must panic"); - let error = map_dispatch_join_error(join).to_string(); - assert!(error.contains("tool dispatch task failed")); - assert!(!error.contains("oauth-super-secret-token")); - } - - #[tokio::test] - async fn no_key_path_emits_guide_and_done() { - let loop_ = build_loop(talking_head_timeline(), Arc::new(MemoryKeyStore::new())); - let mut session = ChatSession::new("s1"); - let events = Arc::new(Mutex::new(Vec::new())); - let emitter = CollectEmitter { - events: events.clone(), - }; - let cancel = Arc::new(AtomicBool::new(false)); - loop_ - .run_turn( - &mut session, - "openai".into(), - "tighten silences".into(), - &emitter, - cancel, - ) - .await - .unwrap(); - let evs = events.lock().unwrap().clone(); - assert!(evs.iter().any(|e| e.contains("Settings"))); - assert!(evs.iter().any(|e| e.starts_with("done:"))); - assert_eq!(session.messages.len(), 2); - } - - #[tokio::test] - async fn unsupported_provider_fails_before_streaming() { - let loop_ = build_loop(talking_head_timeline(), Arc::new(MemoryKeyStore::new())); - let mut session = ChatSession::new("s1"); - let cancel = Arc::new(AtomicBool::new(false)); - let emitter = CollectEmitter { - events: Arc::new(Mutex::new(Vec::new())), - }; - let err = loop_ - .run_turn( - &mut session, - "google".into(), - "hello".into(), - &emitter, - cancel, - ) - .await - .unwrap_err() - .to_string(); - assert!(err.contains("does not support provider")); - assert!(session.messages.is_empty()); - } +pub struct ChatTurn { + pub first_message_id: String, + pub cancel: Arc, + pub gate: Arc, } + +#[cfg(test)] +mod tests; diff --git a/crates/opentake-agent/src/chat/loop/tests.rs b/crates/opentake-agent/src/chat/loop/tests.rs new file mode 100644 index 00000000..f52a047d --- /dev/null +++ b/crates/opentake-agent/src/chat/loop/tests.rs @@ -0,0 +1,511 @@ +use super::*; +use crate::mcp::core_handle::CoreHandle; +use opentake_domain::{Clip, ClipType, MediaManifest, Timeline, Track}; +use opentake_gen::MemoryKeyStore; +use opentake_ops::{EditCommand, EditResult}; +use std::path::PathBuf; +use std::sync::Mutex; + +#[test] +fn events_are_addressed_by_session_message_and_block() { + let block = crate::chat::AgentContentBlock::Text { text: "A".into() }; + let message = ChatMessage::assistant_blocks_with_id("assistant-1", vec![block.clone()]); + let events = [ + LoopEvent::BlockDelta { + session_id: "session-1".into(), + message_id: "assistant-1".into(), + sequence: 0, + block_index: 0, + delta: "A".into(), + }, + LoopEvent::BlockUpsert { + session_id: "session-1".into(), + message_id: "assistant-1".into(), + sequence: 1, + block_index: 0, + block, + }, + LoopEvent::Done { + session_id: "session-1".into(), + message_id: "assistant-1".into(), + sequence: 2, + message, + }, + ]; + + assert!(matches!( + &events[0], + LoopEvent::BlockDelta { + session_id, + message_id, + sequence: 0, + block_index: 0, + delta, + } if session_id == "session-1" && message_id == "assistant-1" && delta == "A" + )); + assert!(matches!( + &events[1], + LoopEvent::BlockUpsert { + message_id, + sequence: 1, + block_index: 0, + .. + } if message_id == "assistant-1" + )); + assert!(matches!( + &events[2], + LoopEvent::Done { + message_id, + sequence: 2, + message, + .. + } if message_id == &message.id + )); +} + +#[test] +fn anthropic_interleaved_sse_preserves_loop_event_and_next_round_body_order() { + struct EventCollector { + events: Arc>>, + } + impl EmitLoop for EventCollector { + fn emit(&self, event: LoopEvent) { + self.events.lock().unwrap().push(event); + } + } + + let sse = concat!( + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n", + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"A\"}}\n\n", + "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call-1\",\"name\":\"split_clip\",\"input\":{}}}\n\n", + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"clipId\\\":\\\"c1\\\"}\"}}\n\n", + "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\n", + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":2,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n", + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"text_delta\",\"text\":\"B\"}}\n\n", + "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":2}\n\n", + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", + ); + let events = Arc::new(Mutex::new(Vec::new())); + let emitter = EventCollector { + events: events.clone(), + }; + let mut assistant = ChatMessage::assistant_blocks_with_id("assistant-sse", Vec::new()); + let mut decoder = crate::chat::llm::AnthropicStreamDecoder::default(); + let mut sequence = EventSequence::default(); + + for chunk in sse.as_bytes().chunks(37) { + decoder + .push_chunk(chunk, &mut |event| { + apply_stream_event( + &mut assistant, + "session-sse", + &emitter, + &mut sequence, + event, + ) + }) + .unwrap(); + } + let turn = decoder.finish().unwrap(); + + assert_eq!(assistant.blocks, turn.blocks); + assert!(matches!( + &assistant.blocks[..], + [ + AgentContentBlock::Text { text: first }, + AgentContentBlock::ToolUse { id, input, .. }, + AgentContentBlock::Text { text: second } + ] if first == "A" + && id == "call-1" + && input == &serde_json::json!({"clipId": "c1"}) + && second == "B" + )); + let addressed = events + .lock() + .unwrap() + .iter() + .map(|event| match event { + LoopEvent::BlockDelta { block_index, .. } + | LoopEvent::BlockUpsert { block_index, .. } => *block_index, + LoopEvent::Done { .. } => usize::MAX, + }) + .collect::>(); + assert_eq!(addressed, vec![0, 0, 0, 1, 1, 2, 2, 2]); + let sequences = events + .lock() + .unwrap() + .iter() + .map(|event| match event { + LoopEvent::BlockDelta { sequence, .. } + | LoopEvent::BlockUpsert { sequence, .. } + | LoopEvent::Done { sequence, .. } => *sequence, + }) + .collect::>(); + assert_eq!(sequences, (0..sequences.len() as u64).collect::>()); + + let body = crate::chat::llm::anthropic_body("claude", &[assistant], &[]); + assert_eq!( + body["messages"][0]["content"], + serde_json::json!([ + {"type": "text", "text": "A"}, + { + "type": "tool_use", + "id": "call-1", + "name": "split_clip", + "input": {"clipId": "c1"} + }, + {"type": "text", "text": "B", "cache_control": {"type": "ephemeral"}} + ]) + ); +} + +#[test] +fn orphan_tool_uses_are_repaired_before_the_next_user_turn() { + let mut orphan = + ToolCall::request("missing", "split_clip", serde_json::json!({"clipId": "c1"})); + let resolved = ToolCall::request("resolved", "get_timeline", serde_json::json!({})); + let mut messages = vec![ + ChatMessage::assistant("working", vec![resolved, orphan.clone()]), + ChatMessage::tool_result("resolved", serde_json::json!({"ok": true})), + ChatMessage::user("continue"), + ]; + + assert_eq!(resolve_orphan_tool_uses(&mut messages), 1); + assert_eq!(messages.len(), 4); + assert_eq!(messages[2].role, crate::chat::Role::Tool); + assert_eq!(messages[2].tool_call_id.as_deref(), Some("missing")); + assert_eq!(messages[2].tool_is_error, Some(true)); + assert!(messages[2].content.contains("Cancelled")); + assert_eq!(messages[3].role, crate::chat::Role::User); + orphan.result = Some(serde_json::json!({"error": "Cancelled"})); + orphan.is_error = Some(true); + assert_eq!(messages[0].tool_calls[1].result, orphan.result); + assert_eq!(messages[0].tool_calls[1].is_error, Some(true)); + + assert_eq!(resolve_orphan_tool_uses(&mut messages), 0); + assert_eq!(messages.len(), 4); +} + +#[test] +fn dispatcher_failures_become_provider_error_results() { + let failed_result = ToolResult::error("invalid clip"); + let failed_safe = tool_result_for_model(&failed_result); + let failed = tool_result_message("call-failed", &failed_result, failed_safe.clone()); + assert_eq!(failed.tool_call_id.as_deref(), Some("call-failed")); + assert_eq!(failed.tool_is_error, Some(true)); + assert_eq!( + serde_json::from_str::(&failed.content).unwrap(), + failed_safe + ); + + let succeeded_result = ToolResult::ok("done"); + let succeeded = tool_result_message( + "call-ok", + &succeeded_result, + tool_result_for_model(&succeeded_result), + ); + assert_eq!(succeeded.tool_is_error, None); +} + +/// A minimal CoreHandle over an in-memory timeline + manifest, so the loop +/// can dispatch read tools without a full AppCore. +struct FakeHandle { + timeline: Timeline, +} +impl CoreHandle for FakeHandle { + fn timeline(&self) -> Timeline { + self.timeline.clone() + } + fn media(&self) -> MediaManifest { + MediaManifest::new() + } + fn apply(&self, _cmd: EditCommand) -> anyhow::Result { + Ok(EditResult { + changed: false, + timeline_changed: false, + manifest_changed: false, + action_name: "noop".into(), + affected_clip_ids: Vec::new(), + timeline_version: 0, + summary: "noop".into(), + }) + } + fn project_dir(&self) -> Option { + None + } +} + +/// An emitter that just collects events for assertions. +struct CollectEmitter { + events: Arc>>, +} +impl EmitLoop for CollectEmitter { + fn emit(&self, event: LoopEvent) { + let s = match event { + LoopEvent::BlockDelta { delta, .. } => format!("delta:{delta}"), + LoopEvent::BlockUpsert { block, .. } => format!("block:{block:?}"), + LoopEvent::Done { message, .. } => format!("done:{}", message.content), + }; + self.events.lock().unwrap().push(s); + } +} + +fn talking_head_timeline() -> Timeline { + let mut tl = Timeline::new(); + let mut v = Track::new("v1", ClipType::Video); + v.clips.push(Clip::new("c1", "asset", 0, 30 * 20)); + tl.tracks.push(v); + tl +} + +fn build_loop(timeline: Timeline, store: Arc) -> ChatLoop { + let handle: Arc = Arc::new(FakeHandle { timeline }); + let registry = Arc::new(RwLock::new(PluginRegistry::new())); + let dispatcher = Arc::new(Dispatcher::new(handle, registry.clone())); + ChatLoop::new(dispatcher, registry, store) +} + +#[test] +fn tool_catalog_hides_bridge_tools_when_bridge_is_missing() { + let loop_ = build_loop(talking_head_timeline(), Arc::new(MemoryKeyStore::new())); + let tools = loop_.tool_catalog(); + assert!(tools.iter().any(|t| t.name == "tighten_silences")); + assert!(!tools.iter().any(|t| t.name == "remove_filler_words")); + assert!(!tools.iter().any(|t| t.name == "get_transcript")); + assert!(!tools.iter().any(|t| t.name == "search_media")); + assert!(!tools.iter().any(|t| t.name == "inspect_media")); + assert!(!tools.iter().any(|t| t.name == "inspect_timeline")); + assert!(!tools.iter().any(|t| t.name == "import_media")); +} + +#[test] +fn system_prompt_includes_context_signal() { + let loop_ = build_loop(talking_head_timeline(), Arc::new(MemoryKeyStore::new())); + let prompt = loop_.system_prompt(); + assert!(prompt.contains("context signal")); + assert!(prompt.contains("talking_head") || prompt.contains("video_type")); +} + +#[test] +fn tool_round_persists_assistant_before_tool_results() { + let mut session = ChatSession::new("s1"); + session.messages.push(ChatMessage::user("trim this")); + + let requested = vec![ToolCall::request( + "call-1", + "get_timeline", + serde_json::json!({}), + )]; + let assistant_index = persist_assistant_tool_round( + &mut session, + ChatMessage::assistant("working", requested.clone()), + ); + + let mut resolved = requested[0].clone(); + resolved.result = Some(serde_json::json!({"summary": "ok"})); + resolved.is_error = Some(false); + update_assistant_tool_call(&mut session, assistant_index, &resolved); + session.messages.push(ChatMessage::tool_result( + resolved.id.clone(), + resolved.result.clone().unwrap(), + )); + + assert_eq!( + session.messages[1].role, + crate::chat::session::Role::Assistant + ); + assert_eq!(session.messages[1].tool_calls.len(), 1); + assert_eq!( + session.messages[1].tool_calls[0].result, + Some(serde_json::json!({"summary": "ok"})) + ); + assert_eq!(session.messages[2].role, crate::chat::session::Role::Tool); + assert_eq!(session.messages[2].tool_call_id.as_deref(), Some("call-1")); +} + +#[test] +fn persisted_tool_result_emits_upsert_then_done_on_its_own_sequence() { + struct EventCollector { + events: Arc>>, + } + impl EmitLoop for EventCollector { + fn emit(&self, event: LoopEvent) { + self.events.lock().unwrap().push(event); + } + } + + let mut session = ChatSession::new("session-tool-result"); + let message = ChatMessage::tool_result("call-1", serde_json::json!({"summary": "ok"})); + let message_id = message.id.clone(); + let events = Arc::new(Mutex::new(Vec::new())); + let emitter = EventCollector { + events: events.clone(), + }; + + persist_tool_result_message(&mut session, "session-tool-result", &emitter, message); + + assert_eq!(session.messages.len(), 1); + assert_eq!(session.messages[0].id, message_id); + assert_eq!(session.messages[0].role, crate::chat::Role::Tool); + let events = events.lock().unwrap(); + assert!(matches!( + &events[..], + [ + LoopEvent::BlockUpsert { + session_id, + message_id: upsert_id, + sequence: 0, + block_index: 0, + block: AgentContentBlock::ToolResult { tool_use_id, .. }, + }, + LoopEvent::Done { + session_id: done_session_id, + message_id: done_id, + sequence: 1, + message, + }, + ] if session_id == "session-tool-result" + && done_session_id == session_id + && upsert_id == &message_id + && done_id == upsert_id + && message.id == message_id + && message.role == crate::chat::Role::Tool + && message.tool_call_id.as_deref() == Some("call-1") + && tool_use_id == "call-1" + )); +} + +#[test] +fn chat_tool_result_uses_shared_fail_closed_error_boundary() { + let private = "quota exhausted for customer alice plan enterprise"; + let value = tool_result_for_model(&ToolResult::error(private)); + let wire = value.to_string(); + assert!(wire.contains("MCP_TOOL_ERROR_REDACTED")); + assert!(!wire.contains(private)); + assert_eq!(value["isError"], true); +} + +#[tokio::test] +async fn chat_join_error_does_not_expose_panic_payload() { + let join = tokio::task::spawn_blocking(|| { + with_redacted_dispatch_panic(|| panic!("provider panic carried oauth-super-secret-token")) + }) + .await + .expect_err("worker must panic"); + let error = map_dispatch_join_error(join).to_string(); + assert!(error.contains("tool dispatch task failed")); + assert!(!error.contains("oauth-super-secret-token")); +} + +#[tokio::test] +async fn no_key_path_emits_guide_and_done() { + let loop_ = build_loop(talking_head_timeline(), Arc::new(MemoryKeyStore::new())); + let mut session = ChatSession::new("s1"); + let events = Arc::new(Mutex::new(Vec::new())); + let emitter = CollectEmitter { + events: events.clone(), + }; + let cancel = Arc::new(AtomicBool::new(false)); + loop_ + .run_turn( + &mut session, + "openai".into(), + "tighten silences".into(), + &emitter, + cancel, + ) + .await + .unwrap(); + let evs = events.lock().unwrap().clone(); + assert!(evs.iter().any(|e| e.contains("Settings"))); + assert!(evs.iter().any(|e| e.starts_with("done:"))); + assert_eq!(session.messages.len(), 2); +} + +#[tokio::test] +async fn events_no_key_path_reuses_message_id_from_first_delta_through_done() { + struct IdentityEmitter { + events: Arc>>, + } + impl EmitLoop for IdentityEmitter { + fn emit(&self, event: LoopEvent) { + self.events.lock().unwrap().push(event); + } + } + + let loop_ = build_loop(talking_head_timeline(), Arc::new(MemoryKeyStore::new())); + let mut session = ChatSession::new("session-identity"); + let events = Arc::new(Mutex::new(Vec::new())); + let emitter = IdentityEmitter { + events: events.clone(), + }; + let message_id = loop_ + .run_turn( + &mut session, + "openai".into(), + "hello".into(), + &emitter, + Arc::new(AtomicBool::new(false)), + ) + .await + .unwrap(); + + let events = events.lock().unwrap(); + assert!(matches!( + &events[0], + LoopEvent::BlockDelta { + session_id, + message_id: delta_message_id, + block_index: 0, + .. + } if session_id == "session-identity" && delta_message_id == &message_id + )); + assert!(matches!( + &events[1], + LoopEvent::Done { + message_id: done_message_id, + message, + .. + } if done_message_id == &message_id && message.id == message_id + )); + assert_eq!(session.messages.last().unwrap().id, message_id); +} + +#[test] +fn events_errors_and_cancellation_retain_the_active_message_id() { + let cancelled = LoopError::cancelled("assistant-active", 4); + let failed = LoopError::llm( + LlmError::Provider("provider failed".into()), + "assistant-active", + 4, + ); + + assert_eq!(cancelled.message_id(), "assistant-active"); + assert_eq!(failed.message_id(), "assistant-active"); + assert_eq!(cancelled.sequence(), 4); + assert_eq!(failed.sequence(), 4); +} + +#[tokio::test] +async fn unsupported_provider_fails_before_streaming() { + let loop_ = build_loop(talking_head_timeline(), Arc::new(MemoryKeyStore::new())); + let mut session = ChatSession::new("s1"); + let cancel = Arc::new(AtomicBool::new(false)); + let emitter = CollectEmitter { + events: Arc::new(Mutex::new(Vec::new())), + }; + let err = loop_ + .run_turn( + &mut session, + "google".into(), + "hello".into(), + &emitter, + cancel, + ) + .await + .unwrap_err() + .to_string(); + assert!(err.contains("does not support provider")); + assert!(session.messages.is_empty()); +} diff --git a/crates/opentake-agent/src/chat/mod.rs b/crates/opentake-agent/src/chat/mod.rs index 6c23983f..8c910c9f 100644 --- a/crates/opentake-agent/src/chat/mod.rs +++ b/crates/opentake-agent/src/chat/mod.rs @@ -22,6 +22,6 @@ pub mod store; pub use llm::{ no_key_guide, provider_from_choice, stream_chat, ChatRequest, LlmError, LlmProvider, ToolSchema, }; -pub use r#loop::{ChatLoop, ChatTurnGate, EmitLoop, LoopError, LoopEvent}; -pub use session::{AgentContentBlock, ChatMessage, ChatSession, Role, ToolCall}; +pub use r#loop::{ChatLoop, ChatTurn, ChatTurnGate, EmitLoop, LoopError, LoopEvent}; +pub use session::{next_message_id, AgentContentBlock, ChatMessage, ChatSession, Role, ToolCall}; pub use store::{ChatSessionStore, ChatSessionStoreError}; diff --git a/crates/opentake-agent/src/chat/session.rs b/crates/opentake-agent/src/chat/session.rs index 62db67ab..64f76fc5 100644 --- a/crates/opentake-agent/src/chat/session.rs +++ b/crates/opentake-agent/src/chat/session.rs @@ -91,7 +91,7 @@ pub struct ChatMessage { pub content: String, #[serde(default)] pub tool_calls: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[serde(default)] pub blocks: Vec, pub created_at: i64, /// When role == Tool: the `tool_call_id` this result answers. OpenAI's @@ -107,47 +107,77 @@ pub struct ChatMessage { impl ChatMessage { pub fn user(text: impl Into) -> Self { + let text = text.into(); let mut message = ChatMessage { - id: next_id(), + id: next_message_id(), role: Role::User, - content: text.into(), + content: String::new(), tool_calls: Vec::new(), - blocks: Vec::new(), + blocks: (!text.is_empty()) + .then_some(AgentContentBlock::Text { text }) + .into_iter() + .collect(), created_at: now_millis(), tool_call_id: None, tool_is_error: None, }; - message.refresh_blocks(); + message.refresh_legacy_fields(); message } pub fn assistant(text: impl Into, tool_calls: Vec) -> Self { + Self::assistant_with_id(next_message_id(), text, tool_calls) + } + + pub fn assistant_with_id( + id: impl Into, + text: impl Into, + tool_calls: Vec, + ) -> Self { + let text = text.into(); + let mut blocks = Vec::with_capacity(usize::from(!text.is_empty()) + tool_calls.len()); + if !text.is_empty() { + blocks.push(AgentContentBlock::Text { text }); + } + blocks.extend(tool_calls.into_iter().map(AgentContentBlock::from)); + Self::assistant_blocks_with_id(id, blocks) + } + + pub fn assistant_blocks(blocks: Vec) -> Self { + Self::assistant_blocks_with_id(next_message_id(), blocks) + } + + pub fn assistant_blocks_with_id(id: impl Into, blocks: Vec) -> Self { let mut message = ChatMessage { - id: next_id(), + id: id.into(), role: Role::Assistant, - content: text.into(), - tool_calls, - blocks: Vec::new(), + content: String::new(), + tool_calls: Vec::new(), + blocks, created_at: now_millis(), tool_call_id: None, tool_is_error: None, }; - message.refresh_blocks(); + message.refresh_legacy_fields(); message } pub fn system(text: impl Into) -> Self { + let text = text.into(); let mut message = ChatMessage { - id: next_id(), + id: next_message_id(), role: Role::System, - content: text.into(), + content: String::new(), tool_calls: Vec::new(), - blocks: Vec::new(), + blocks: (!text.is_empty()) + .then_some(AgentContentBlock::Text { text }) + .into_iter() + .collect(), created_at: now_millis(), tool_call_id: None, tool_is_error: None, }; - message.refresh_blocks(); + message.refresh_legacy_fields(); message } @@ -174,7 +204,7 @@ impl ChatMessage { ) -> Self { let tool_call_id = tool_call_id.into(); ChatMessage { - id: next_id(), + id: next_message_id(), role: Role::Tool, content: legacy_result.to_string(), tool_calls: Vec::new(), @@ -200,9 +230,148 @@ impl ChatMessage { ) } - /// Rebuild the structured representation after an in-place update to the - /// temporary legacy view fields. - pub(crate) fn refresh_blocks(&mut self) { + /// Append a streamed text chunk and return the authoritative block index. + /// Only an adjacent text block is consolidated; text separated by a tool + /// event remains a distinct block. + pub fn append_text_delta(&mut self, delta: impl AsRef) -> usize { + let delta = delta.as_ref(); + let block_index = match self.blocks.last_mut() { + Some(AgentContentBlock::Text { text }) => { + text.push_str(delta); + self.blocks.len() - 1 + } + _ => { + self.blocks.push(AgentContentBlock::Text { + text: delta.to_string(), + }); + self.blocks.len() - 1 + } + }; + self.refresh_legacy_fields(); + block_index + } + + /// Append a text delta to one provider-addressed block. A new block may be + /// created only at the current tail; gaps or type mismatches are rejected. + pub fn append_text_delta_at(&mut self, block_index: usize, delta: impl AsRef) -> bool { + let delta = delta.as_ref(); + let applied = if block_index == self.blocks.len() { + self.blocks.push(AgentContentBlock::Text { + text: delta.to_string(), + }); + true + } else if let Some(AgentContentBlock::Text { text }) = self.blocks.get_mut(block_index) { + text.push_str(delta); + true + } else { + false + }; + if applied { + self.refresh_legacy_fields(); + } + applied + } + + /// Insert or replace one provider-addressed block without changing its + /// position. Provider indices must be contiguous. + pub fn upsert_block_at(&mut self, block_index: usize, block: AgentContentBlock) -> bool { + let applied = if block_index == self.blocks.len() { + self.blocks.push(block); + true + } else if let Some(existing) = self.blocks.get_mut(block_index) { + *existing = block; + true + } else { + false + }; + if applied { + self.refresh_legacy_fields(); + } + applied + } + + /// Insert a tool request in event order, or update the already-addressed + /// block when dispatch later fills its result. + pub fn upsert_tool_use(&mut self, tool_call: ToolCall) -> usize { + if let Some((index, block)) = self.blocks.iter_mut().enumerate().find(|(_, block)| { + matches!(block, AgentContentBlock::ToolUse { id, .. } if id == &tool_call.id) + }) { + *block = AgentContentBlock::from(tool_call); + self.refresh_legacy_fields(); + return index; + } + self.blocks.push(AgentContentBlock::from(tool_call)); + let index = self.blocks.len() - 1; + self.refresh_legacy_fields(); + index + } + + /// Derive temporary flat compatibility fields from authoritative blocks. + /// This never mutates or reorders `blocks`. + pub fn refresh_legacy_fields(&mut self) { + self.content = if self.role == Role::Tool { + self.blocks + .iter() + .find_map(|block| match block { + AgentContentBlock::ToolResult { + content, is_error, .. + } => Some(legacy_tool_result_content( + content, + is_error.unwrap_or(false), + )), + _ => None, + }) + .unwrap_or_default() + } else { + self.blocks + .iter() + .filter_map(|block| match block { + AgentContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::() + }; + self.tool_calls = self + .blocks + .iter() + .filter_map(|block| match block { + AgentContentBlock::ToolUse { + id, + name, + input, + result, + is_error, + } => Some(ToolCall { + id: id.clone(), + name: name.clone(), + args: input.clone(), + result: result.clone(), + is_error: *is_error, + }), + _ => None, + }) + .collect(); + self.tool_call_id = None; + self.tool_is_error = None; + if self.role == Role::Tool { + if let Some(AgentContentBlock::ToolResult { + tool_use_id, + is_error, + .. + }) = self + .blocks + .iter() + .find(|block| matches!(block, AgentContentBlock::ToolResult { .. })) + { + self.tool_call_id = Some(tool_use_id.clone()); + self.tool_is_error = *is_error; + } + } + } + + /// Migrate the temporary Beta 4 flat fields when no authoritative blocks + /// were persisted yet. + fn migrate_legacy_fields_to_blocks(&mut self) { let mut blocks = Vec::new(); if !self.content.is_empty() && self.role != Role::Tool { blocks.push(AgentContentBlock::Text { @@ -233,6 +402,34 @@ impl ChatMessage { } } +fn legacy_tool_result_content(content: &[Block], is_error: bool) -> String { + if let [Block::Text { text }] = content { + if serde_json::from_str::(text).is_ok() { + return text.clone(); + } + } + let summary = content + .iter() + .filter_map(|block| match block { + Block::Text { text } => Some(text.as_str()), + Block::Image { .. } => None, + }) + .collect::(); + serde_json::json!({"summary": summary, "isError": is_error}).to_string() +} + +impl From for AgentContentBlock { + fn from(tool_call: ToolCall) -> Self { + AgentContentBlock::ToolUse { + id: tool_call.id, + name: tool_call.name, + input: tool_call.args, + result: tool_call.result, + is_error: tool_call.is_error, + } + } +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ChatMessageWire { @@ -243,7 +440,7 @@ struct ChatMessageWire { #[serde(default)] tool_calls: Vec, #[serde(default)] - blocks: Vec, + blocks: Option>, created_at: i64, #[serde(default)] tool_call_id: Option, @@ -257,82 +454,26 @@ impl<'de> Deserialize<'de> for ChatMessage { D: serde::Deserializer<'de>, { let wire = ChatMessageWire::deserialize(deserializer)?; + let has_authoritative_blocks = wire.blocks.is_some(); let mut message = ChatMessage { id: wire.id, role: wire.role, content: wire.content, tool_calls: wire.tool_calls, - blocks: wire.blocks, + blocks: wire.blocks.unwrap_or_default(), created_at: wire.created_at, tool_call_id: wire.tool_call_id, tool_is_error: wire.tool_is_error, }; - if message.blocks.is_empty() { - message.refresh_blocks(); + if has_authoritative_blocks { + message.refresh_legacy_fields(); } else { - message.apply_blocks_to_legacy_view(); + message.migrate_legacy_fields_to_blocks(); } Ok(message) } } -impl ChatMessage { - fn apply_blocks_to_legacy_view(&mut self) { - if self.role != Role::Tool { - self.content = self - .blocks - .iter() - .filter_map(|block| match block { - AgentContentBlock::Text { text } => Some(text.as_str()), - _ => None, - }) - .collect::(); - } - if self.role == Role::Assistant { - self.tool_calls = self - .blocks - .iter() - .filter_map(|block| match block { - AgentContentBlock::ToolUse { - id, - name, - input, - result, - is_error, - } => Some(ToolCall { - id: id.clone(), - name: name.clone(), - args: input.clone(), - result: result.clone(), - is_error: *is_error, - }), - _ => None, - }) - .collect(); - } - if self.role == Role::Tool { - if let Some(AgentContentBlock::ToolResult { - tool_use_id, - content, - is_error, - }) = self - .blocks - .iter() - .find(|block| matches!(block, AgentContentBlock::ToolResult { .. })) - { - if self.content.is_empty() { - self.content = match content.as_slice() { - [Block::Text { text }] => text.clone(), - _ => serde_json::to_string(content).unwrap_or_default(), - }; - } - self.tool_call_id = Some(tool_use_id.clone()); - self.tool_is_error = *is_error; - } - } - } -} - /// One conversation: an ordered message log. The chat loop appends user turns, /// assistant turns (with any tool calls), and tool-result turns; the front end /// reads the whole list back via `chat_history`. @@ -376,7 +517,7 @@ fn default_true() -> bool { /// millisecond are disambiguated by the counter. static ID_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); -fn next_id() -> String { +pub fn next_message_id() -> String { let n = ID_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); format!("m{}-{n}", now_millis()) } @@ -389,195 +530,4 @@ fn now_millis() -> i64 { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn roles_serialize_lowercase() { - assert_eq!(serde_json::to_string(&Role::User).unwrap(), "\"user\""); - assert_eq!( - serde_json::to_string(&Role::Assistant).unwrap(), - "\"assistant\"" - ); - assert_eq!(serde_json::to_string(&Role::Tool).unwrap(), "\"tool\""); - } - - #[test] - fn message_camelcase_round_trip() { - let m = ChatMessage::assistant("hi", vec![]); - let v = serde_json::to_value(&m).unwrap(); - assert_eq!(v["role"], "assistant"); - assert_eq!(v["content"], "hi"); - assert_eq!( - v["createdAt"], - serde_json::Value::Number(m.created_at.into()) - ); - assert!(v["toolCalls"].is_array()); - assert!(v.get("toolCallId").is_none()); - } - - #[test] - fn tool_call_carries_result_only_when_present() { - let mut tc = ToolCall::request("call-1", "get_timeline", serde_json::json!({})); - let v = serde_json::to_value(&tc).unwrap(); - assert!(v.get("result").is_none()); - assert!(v.get("isError").is_none()); - tc.result = Some(serde_json::json!({"ok": true})); - tc.is_error = Some(false); - let v = serde_json::to_value(&tc).unwrap(); - assert_eq!(v["result"]["ok"], true); - assert_eq!(v["isError"], false); - } - - #[test] - fn tool_result_message_has_tool_call_id() { - let m = ChatMessage::tool_result("call-1", serde_json::json!({"summary": "ok"})); - let v = serde_json::to_value(&m).unwrap(); - assert_eq!(v["role"], "tool"); - assert_eq!(v["toolCallId"], "call-1"); - assert!(v.get("toolIsError").is_none()); - assert!(v["content"].as_str().unwrap().contains("summary")); - } - - #[test] - fn tool_error_result_round_trips_an_explicit_error_marker() { - let m = ChatMessage::tool_error_result("call-1", serde_json::json!({"error": "Cancelled"})); - let v = serde_json::to_value(&m).unwrap(); - assert_eq!(v["toolIsError"], true); - let back: ChatMessage = serde_json::from_value(v).unwrap(); - assert_eq!(back.tool_is_error, Some(true)); - } - - #[test] - fn ids_are_unique_under_rapid_minting() { - let mut ids = std::collections::HashSet::new(); - for _ in 0..1000 { - ids.insert(next_id()); - } - assert_eq!(ids.len(), 1000); - } - - #[test] - fn session_round_trip() { - let mut s = ChatSession::new("sess-1"); - s.provider = Some("openai".into()); - s.is_open = false; - s.messages.push(ChatMessage::user("hello")); - let json = serde_json::to_string(&s).unwrap(); - let back: ChatSession = serde_json::from_str(&json).unwrap(); - assert_eq!(back.id, "sess-1"); - assert_eq!(back.provider.as_deref(), Some("openai")); - assert!(!back.is_open); - assert_eq!(back.messages.len(), 1); - assert_eq!(back.messages[0].role, Role::User); - } - - #[test] - fn content_blocks_use_the_tagged_camel_case_wire_contract() { - let message = ChatMessage::assistant( - "working", - vec![ToolCall::request( - "call-1", - "split_clip", - serde_json::json!({"clipId": "c1"}), - )], - ); - - let value = serde_json::to_value(&message).unwrap(); - - assert_eq!( - value["blocks"][0], - serde_json::json!({ - "type": "text", - "text": "working" - }) - ); - assert_eq!( - value["blocks"][1], - serde_json::json!({ - "type": "toolUse", - "id": "call-1", - "name": "split_clip", - "input": {"clipId": "c1"} - }) - ); - } - - #[test] - fn legacy_flat_messages_migrate_to_content_blocks() { - let legacy = serde_json::json!({ - "id": "legacy-1", - "role": "assistant", - "content": "working", - "toolCalls": [{ - "id": "call-1", - "name": "split_clip", - "args": {"clipId": "c1"}, - "result": {"ok": true}, - "isError": false - }], - "createdAt": 1 - }); - - let message: ChatMessage = serde_json::from_value(legacy).unwrap(); - - assert_eq!(message.blocks.len(), 2); - assert!(matches!( - &message.blocks[0], - AgentContentBlock::Text { text } if text == "working" - )); - assert!(matches!( - &message.blocks[1], - AgentContentBlock::ToolUse { id, is_error, .. } - if id == "call-1" && *is_error == Some(false) - )); - } - - #[test] - fn legacy_sessions_without_is_open_default_to_open() { - let legacy = serde_json::json!({ - "id": "legacy-session", - "messages": [], - "createdAt": 1 - }); - - let session: ChatSession = serde_json::from_value(legacy).unwrap(); - - assert!(session.is_open); - } - - #[test] - fn native_tool_result_blocks_round_trip_images_in_order() { - use crate::tools::result::Block; - - let message = ChatMessage::tool_result_blocks( - "call-image", - vec![ - Block::text("before"), - Block::image("aW1hZ2U=", "image/png"), - Block::text("after"), - ], - serde_json::json!({"summary": "beforeafter", "isError": false}), - false, - ); - - let json = serde_json::to_string(&message).unwrap(); - let restored: ChatMessage = serde_json::from_str(&json).unwrap(); - - let AgentContentBlock::ToolResult { content, .. } = &restored.blocks[0] else { - panic!("expected a native tool result block"); - }; - assert_eq!( - content, - &vec![ - Block::text("before"), - Block::image("aW1hZ2U=", "image/png"), - Block::text("after"), - ] - ); - assert_eq!( - restored.content, - serde_json::json!({"summary": "beforeafter", "isError": false}).to_string() - ); - } -} +mod tests; diff --git a/crates/opentake-agent/src/chat/session/tests.rs b/crates/opentake-agent/src/chat/session/tests.rs new file mode 100644 index 00000000..f9b1a65f --- /dev/null +++ b/crates/opentake-agent/src/chat/session/tests.rs @@ -0,0 +1,397 @@ +use super::*; + +#[test] +fn roles_serialize_lowercase() { + assert_eq!(serde_json::to_string(&Role::User).unwrap(), "\"user\""); + assert_eq!( + serde_json::to_string(&Role::Assistant).unwrap(), + "\"assistant\"" + ); + assert_eq!(serde_json::to_string(&Role::Tool).unwrap(), "\"tool\""); +} + +#[test] +fn message_camelcase_round_trip() { + let m = ChatMessage::assistant("hi", vec![]); + let v = serde_json::to_value(&m).unwrap(); + assert_eq!(v["role"], "assistant"); + assert_eq!(v["content"], "hi"); + assert_eq!( + v["createdAt"], + serde_json::Value::Number(m.created_at.into()) + ); + assert!(v["toolCalls"].is_array()); + assert!(v.get("toolCallId").is_none()); +} + +#[test] +fn tool_call_carries_result_only_when_present() { + let mut tc = ToolCall::request("call-1", "get_timeline", serde_json::json!({})); + let v = serde_json::to_value(&tc).unwrap(); + assert!(v.get("result").is_none()); + assert!(v.get("isError").is_none()); + tc.result = Some(serde_json::json!({"ok": true})); + tc.is_error = Some(false); + let v = serde_json::to_value(&tc).unwrap(); + assert_eq!(v["result"]["ok"], true); + assert_eq!(v["isError"], false); +} + +#[test] +fn tool_result_message_has_tool_call_id() { + let m = ChatMessage::tool_result("call-1", serde_json::json!({"summary": "ok"})); + let v = serde_json::to_value(&m).unwrap(); + assert_eq!(v["role"], "tool"); + assert_eq!(v["toolCallId"], "call-1"); + assert!(v.get("toolIsError").is_none()); + assert!(v["content"].as_str().unwrap().contains("summary")); +} + +#[test] +fn tool_error_result_round_trips_an_explicit_error_marker() { + let m = ChatMessage::tool_error_result("call-1", serde_json::json!({"error": "Cancelled"})); + let v = serde_json::to_value(&m).unwrap(); + assert_eq!(v["toolIsError"], true); + let back: ChatMessage = serde_json::from_value(v).unwrap(); + assert_eq!(back.tool_is_error, Some(true)); +} + +#[test] +fn ids_are_unique_under_rapid_minting() { + let mut ids = std::collections::HashSet::new(); + for _ in 0..1000 { + ids.insert(next_message_id()); + } + assert_eq!(ids.len(), 1000); +} + +#[test] +fn session_round_trip() { + let mut s = ChatSession::new("sess-1"); + s.provider = Some("openai".into()); + s.is_open = false; + s.messages.push(ChatMessage::user("hello")); + let json = serde_json::to_string(&s).unwrap(); + let back: ChatSession = serde_json::from_str(&json).unwrap(); + assert_eq!(back.id, "sess-1"); + assert_eq!(back.provider.as_deref(), Some("openai")); + assert!(!back.is_open); + assert_eq!(back.messages.len(), 1); + assert_eq!(back.messages[0].role, Role::User); +} + +#[test] +fn content_blocks_use_the_tagged_camel_case_wire_contract() { + let message = ChatMessage::assistant( + "working", + vec![ToolCall::request( + "call-1", + "split_clip", + serde_json::json!({"clipId": "c1"}), + )], + ); + + let value = serde_json::to_value(&message).unwrap(); + + assert_eq!( + value["blocks"][0], + serde_json::json!({ + "type": "text", + "text": "working" + }) + ); + assert_eq!( + value["blocks"][1], + serde_json::json!({ + "type": "toolUse", + "id": "call-1", + "name": "split_clip", + "input": {"clipId": "c1"} + }) + ); +} + +#[test] +fn blocks_preserve_interleaved_assistant_order_and_round_trip() { + let blocks = vec![ + AgentContentBlock::Text { text: "A".into() }, + AgentContentBlock::ToolUse { + id: "call-1".into(), + name: "split_clip".into(), + input: serde_json::json!({"clipId": "c1"}), + result: None, + is_error: None, + }, + AgentContentBlock::Text { text: "B".into() }, + AgentContentBlock::ToolUse { + id: "call-2".into(), + name: "delete_clip".into(), + input: serde_json::json!({"clipId": "c2"}), + result: Some(serde_json::json!({"ok": true})), + is_error: Some(false), + }, + ]; + let message = ChatMessage::assistant_blocks_with_id("assistant-ordered", blocks.clone()); + + assert_eq!(message.blocks, blocks); + assert_eq!(message.content, "AB"); + assert_eq!( + message + .tool_calls + .iter() + .map(|call| call.id.as_str()) + .collect::>(), + vec!["call-1", "call-2"] + ); + + let wire = serde_json::to_value(&message).unwrap(); + assert_eq!(wire["id"], "assistant-ordered"); + assert_eq!(wire["blocks"], serde_json::to_value(&blocks).unwrap()); + let restored: ChatMessage = serde_json::from_value(wire).unwrap(); + assert_eq!(restored.blocks, blocks); +} + +#[test] +fn blocks_append_text_deltas_only_consolidates_adjacent_text() { + let mut message = ChatMessage::assistant_blocks_with_id("assistant-stream", Vec::new()); + + assert_eq!(message.append_text_delta("A"), 0); + assert_eq!(message.append_text_delta("1"), 0); + assert_eq!( + message.upsert_tool_use(ToolCall::request( + "call-1", + "split_clip", + serde_json::json!({"clipId": "c1"}), + )), + 1 + ); + assert_eq!(message.append_text_delta("B"), 2); + assert_eq!(message.append_text_delta("2"), 2); + + assert_eq!( + message.blocks, + vec![ + AgentContentBlock::Text { text: "A1".into() }, + AgentContentBlock::ToolUse { + id: "call-1".into(), + name: "split_clip".into(), + input: serde_json::json!({"clipId": "c1"}), + result: None, + is_error: None, + }, + AgentContentBlock::Text { text: "B2".into() }, + ] + ); + assert_eq!(message.content, "A1B2"); +} + +#[test] +fn blocks_tool_result_wire_preserves_text_and_image_order() { + let block = AgentContentBlock::ToolResult { + tool_use_id: "call-image".into(), + content: vec![ + Block::text("before"), + Block::image("aW1hZ2U=", "image/png"), + Block::text("after"), + ], + is_error: Some(true), + }; + + let wire = serde_json::to_value(&block).unwrap(); + assert_eq!( + wire, + serde_json::json!({ + "type": "toolResult", + "toolUseId": "call-image", + "content": [ + {"kind": "text", "text": "before"}, + {"kind": "image", "base64": "aW1hZ2U=", "mediaType": "image/png"}, + {"kind": "text", "text": "after"} + ], + "isError": true + }) + ); + let restored: AgentContentBlock = serde_json::from_value(wire).unwrap(); + assert_eq!(restored, block); +} + +#[test] +fn blocks_refresh_legacy_fields_is_one_way_and_keeps_block_order() { + let original_blocks = vec![ + AgentContentBlock::Text { text: "A".into() }, + AgentContentBlock::ToolUse { + id: "call-1".into(), + name: "split_clip".into(), + input: serde_json::json!({"clipId": "c1"}), + result: None, + is_error: None, + }, + AgentContentBlock::Text { text: "B".into() }, + ]; + let mut message = + ChatMessage::assistant_blocks_with_id("assistant-authoritative", original_blocks.clone()); + message.content = "stale legacy text".into(); + message.tool_calls.clear(); + + message.refresh_legacy_fields(); + + assert_eq!(message.blocks, original_blocks); + assert_eq!(message.content, "AB"); + assert_eq!(message.tool_calls.len(), 1); + assert_eq!(message.tool_calls[0].id, "call-1"); +} + +#[test] +fn blocks_legacy_flat_messages_migrate_to_stable_text_then_tool_order() { + let legacy = serde_json::json!({ + "id": "legacy-ordered", + "role": "assistant", + "content": "working", + "toolCalls": [ + {"id": "call-1", "name": "split_clip", "args": {"clipId": "c1"}}, + {"id": "call-2", "name": "delete_clip", "args": {"clipId": "c2"}} + ], + "createdAt": 1 + }); + + let message: ChatMessage = serde_json::from_value(legacy).unwrap(); + + assert!(matches!( + &message.blocks[..], + [ + AgentContentBlock::Text { text }, + AgentContentBlock::ToolUse { id: first, .. }, + AgentContentBlock::ToolUse { id: second, .. } + ] if text == "working" && first == "call-1" && second == "call-2" + )); +} + +#[test] +fn blocks_explicit_empty_array_is_serialized_for_beta5_messages() { + let message = ChatMessage::assistant_blocks_with_id("assistant-empty", Vec::new()); + + let wire = serde_json::to_value(message).unwrap(); + + assert_eq!(wire["blocks"], serde_json::json!([])); +} + +#[test] +fn blocks_explicit_empty_array_wins_over_stale_legacy_fields() { + let wire = serde_json::json!({ + "id": "assistant-empty", + "role": "assistant", + "content": "stale text", + "toolCalls": [{ + "id": "stale-call", + "name": "split_clip", + "args": {"clipId": "stale"} + }], + "blocks": [], + "createdAt": 1 + }); + + let message: ChatMessage = serde_json::from_value(wire).unwrap(); + + assert!(message.blocks.is_empty()); + assert!(message.content.is_empty()); + assert!(message.tool_calls.is_empty()); +} + +#[test] +fn blocks_explicit_empty_tool_message_clears_stale_tool_metadata() { + let wire = serde_json::json!({ + "id": "tool-empty", + "role": "tool", + "content": "stale result", + "toolCalls": [], + "blocks": [], + "createdAt": 1, + "toolCallId": "stale-call", + "toolIsError": true + }); + + let message: ChatMessage = serde_json::from_value(wire).unwrap(); + + assert!(message.blocks.is_empty()); + assert!(message.content.is_empty()); + assert_eq!(message.tool_call_id, None); + assert_eq!(message.tool_is_error, None); +} + +#[test] +fn legacy_flat_messages_migrate_to_content_blocks() { + let legacy = serde_json::json!({ + "id": "legacy-1", + "role": "assistant", + "content": "working", + "toolCalls": [{ + "id": "call-1", + "name": "split_clip", + "args": {"clipId": "c1"}, + "result": {"ok": true}, + "isError": false + }], + "createdAt": 1 + }); + + let message: ChatMessage = serde_json::from_value(legacy).unwrap(); + + assert_eq!(message.blocks.len(), 2); + assert!(matches!( + &message.blocks[0], + AgentContentBlock::Text { text } if text == "working" + )); + assert!(matches!( + &message.blocks[1], + AgentContentBlock::ToolUse { id, is_error, .. } + if id == "call-1" && *is_error == Some(false) + )); +} + +#[test] +fn legacy_sessions_without_is_open_default_to_open() { + let legacy = serde_json::json!({ + "id": "legacy-session", + "messages": [], + "createdAt": 1 + }); + + let session: ChatSession = serde_json::from_value(legacy).unwrap(); + + assert!(session.is_open); +} + +#[test] +fn native_tool_result_blocks_round_trip_images_in_order() { + use crate::tools::result::Block; + + let message = ChatMessage::tool_result_blocks( + "call-image", + vec![ + Block::text("before"), + Block::image("aW1hZ2U=", "image/png"), + Block::text("after"), + ], + serde_json::json!({"summary": "beforeafter", "isError": false}), + false, + ); + + let json = serde_json::to_string(&message).unwrap(); + let restored: ChatMessage = serde_json::from_str(&json).unwrap(); + + let AgentContentBlock::ToolResult { content, .. } = &restored.blocks[0] else { + panic!("expected a native tool result block"); + }; + assert_eq!( + content, + &vec![ + Block::text("before"), + Block::image("aW1hZ2U=", "image/png"), + Block::text("after"), + ] + ); + assert_eq!( + restored.content, + serde_json::json!({"summary": "beforeafter", "isError": false}).to_string() + ); +} diff --git a/crates/opentake-agent/src/mcp/dispatch.rs b/crates/opentake-agent/src/mcp/dispatch.rs index 5889f552..7e4a9e4f 100644 --- a/crates/opentake-agent/src/mcp/dispatch.rs +++ b/crates/opentake-agent/src/mcp/dispatch.rs @@ -46,14 +46,20 @@ use crate::mcp::gen_catalog; use crate::mcp::generation::{GenerationBridge, GenerationRequest}; use crate::mcp::media_bridge::{ frame_to_block, media_frame_to_block, BridgeErrorKind, ImportSource, InspectMediaRequest, - InspectMediaResult, InspectResult, MediaBridge, SearchCandidate, TranscriptSource, - IMPORT_BYTES_BASE64_MAX, + InspectMediaResult, InspectResult, MediaBridge, SearchCandidate, TimelineMutationReceipt, + TimelineResultCaptureRequest, TranscriptSource, IMPORT_BYTES_BASE64_MAX, + TIMELINE_RESULT_IMAGE_BASE64_MAX, }; use crate::mcp::media_catalog::ModelMediaCatalog; use crate::mcp::motion::{ model_safe_commit, AddMotionRequest, EditMotionRequest, MotionBridge, MotionBridgeError, MotionBridgeErrorKind, MotionSourceRequest, }; +use crate::mcp::motion_documents::{ + decode_request as decode_motion_document_request, result_from_error as motion_document_error, + result_from_operation as finish_motion_document_operation, AdmittedMotionDocumentOperation, + MotionDocumentBridge, MotionDocumentTool, +}; use crate::mcp::vision::VisionBridge; use crate::plugin::registry::PluginRegistry; use crate::signal::engine; @@ -76,6 +82,7 @@ const INSPECT_MEDIA_MAX_FRAMES: usize = 12; const INSPECT_MEDIA_MAX_SEGMENTS: usize = 400; const INSPECT_MEDIA_MAX_WORDS: usize = 10_000; const DIRECT_UNDO_SCOPE: &str = "opentake:direct"; +const TIMELINE_RESULT_WARNING: &str = "Timeline preview unavailable."; thread_local! { static ACTIVE_UNDO_SCOPES: RefCell> = const { RefCell::new(Vec::new()) }; @@ -110,6 +117,24 @@ struct AgentUndoMarker { head: CoreUndoHead, } +/// The synchronous edit phase plus an optional post-commit capture. Desktop +/// project gates run [`Dispatcher::finish_dispatch`] only after releasing their +/// project-identity lease, so GPU work cannot block a project switch. +pub struct DispatchReceipt { + result: ToolResult, + timeline_result: TimelineResultCompletion, +} + +enum TimelineResultCompletion { + None, + Capture(TimelineResultCaptureRequest), + Warning, + MotionDocument { + tool: ToolName, + operation: Box, + }, +} + /// Resource class used by the HTTP MCP host before it starts blocking work. /// Unknown or malformed calls are conservatively treated as mutations. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -136,6 +161,9 @@ pub(crate) fn dispatch_admission_class(name: &str, args: &Value) -> DispatchAdmi | ToolName::SmartReframe | ToolName::TightenSilences | ToolName::RemoveFillerWords => DispatchAdmissionClass::ReadOnly, + ToolName::ListMotionDocuments + | ToolName::ReadMotionDocument + | ToolName::PreviewMotionDocument => DispatchAdmissionClass::ReadOnly, ToolName::AutoCutToBeats => match args.get("write") { None | Some(Value::Bool(false)) => DispatchAdmissionClass::ReadOnly, Some(_) => DispatchAdmissionClass::Mutation, @@ -171,6 +199,9 @@ pub(crate) fn dispatch_admission_class(name: &str, args: &Value) -> DispatchAdmi | ToolName::ApplyEffect | ToolName::AddMotionGraphic | ToolName::EditMotionGraphic + | ToolName::CreateMotionDocument + | ToolName::PatchMotionDocument + | ToolName::PublishMotionDocument | ToolName::TrackMotion | ToolName::GenerateMatte | ToolName::RemoveObject @@ -200,6 +231,10 @@ pub struct Dispatcher { /// Deterministic render + atomic import/place host capability. Motion tools /// are discoverable only while this bridge reports production readiness. motion_bridge: Option>, + /// Project-authorized HTML/CSS document editing and exact preview/publish. + /// Admission captures a host authority under the lifecycle gate; execution + /// is deferred until that gate releases its identity read lease. + motion_document_bridge: Option>, /// Capability-gated advanced workflows. Each tool is discovered only when /// this bridge explicitly reports a production implementation for it. advanced_bridge: Option>, @@ -274,6 +309,7 @@ impl Dispatcher { bridge, generation_bridge, motion_bridge, + motion_document_bridge: None, advanced_bridge, vision_bridge: None, agent_undo: Mutex::new(HashMap::new()), @@ -289,6 +325,14 @@ impl Dispatcher { self } + pub fn with_motion_document_bridge( + mut self, + bridge: Option>, + ) -> Self { + self.motion_document_bridge = bridge; + self + } + pub fn can_do_vision_analysis(&self) -> bool { self.vision_bridge .as_ref() @@ -326,6 +370,12 @@ impl Dispatcher { .is_some_and(|bridge| bridge.can_render_motion()) } + pub fn can_edit_motion_documents(&self) -> bool { + self.motion_document_bridge + .as_ref() + .is_some_and(|bridge| bridge.can_edit_motion_documents()) + } + pub fn advertised_tools(&self) -> Vec { let mut tools = ToolName::ALL.to_vec(); if !self.has_media_bridge() { @@ -337,6 +387,9 @@ impl Dispatcher { if self.can_render_motion() { tools.extend(ToolName::MOTION); } + if self.can_edit_motion_documents() { + tools.extend(ToolName::MOTION_DOCUMENTS); + } if let Some(bridge) = &self.advanced_bridge { for tool in bridge.supported_tools() { if ToolName::ADVANCED_AI.contains(&tool) && !tools.contains(&tool) { @@ -387,33 +440,78 @@ impl Dispatcher { args: Value, cancel: &opentake_media::MediaCancelToken, ) -> ToolResult { + let receipt = self.dispatch_cancellable_scoped_deferred(undo_scope, name, args, cancel); + self.finish_dispatch(receipt, cancel) + } + + /// Execute and commit a tool without performing the optional GPU capture. + /// Hosts with project lifecycle locks use this as phase one. + pub fn dispatch_cancellable_scoped_deferred( + &self, + undo_scope: &str, + name: &str, + args: Value, + cancel: &opentake_media::MediaCancelToken, + ) -> DispatchReceipt { let _undo_scope = ActiveUndoScope::enter(undo_scope); if cancel.is_cancelled() { - return ToolResult::error("Cancelled"); + return DispatchReceipt::complete(ToolResult::error("Cancelled")); } // 1. Resolve the tool name. let Ok(tool) = name.parse::() else { - return ToolResult::public_error( + return DispatchReceipt::complete(ToolResult::public_error( PublicErrorKind::UnknownTool, format!("Unknown tool: {name}"), - ); + )); }; // Validate the complete wire shape before snapshots or side effects. // Known-but-hidden compatibility names keep their strict schema // contract, but a valid invocation is rejected below as unavailable. if let Err(error) = validate_tool_args(tool, &args) { - return ToolResult::public_error( + return DispatchReceipt::complete(ToolResult::public_error( PublicErrorKind::InvalidArguments(tool), error.message, - ); + )); } if !self.advertised_tools().contains(&tool) { let message = match tool.hidden_capability_reason() { Some(reason) => format!("Tool is not advertised: {} ({reason})", tool.as_str()), None => format!("Tool is not advertised: {}", tool.as_str()), }; - return ToolResult::public_error(PublicErrorKind::UnknownTool, message); + return DispatchReceipt::complete(ToolResult::public_error( + PublicErrorKind::UnknownTool, + message, + )); + } + + // Motion Studio operations must acquire the host's publication lock in + // publication -> identity order. Admit against the exact project while + // the caller's lifecycle lease is still held, then execute from + // finish_dispatch after that lease is released. + if MotionDocumentTool::from_tool_name(tool).is_some() { + let request = match decode_motion_document_request(tool, &args) { + Ok(request) => request, + Err(error) => { + return DispatchReceipt::complete(ToolResult::public_error( + PublicErrorKind::InvalidArguments(tool), + error.message, + )) + } + }; + let Some(bridge) = self.motion_document_bridge.as_ref() else { + return DispatchReceipt::complete(ToolResult::public_error( + PublicErrorKind::CapabilityUnavailable(tool), + "Motion Studio document host capability is unavailable", + )); + }; + return match bridge.admit(request) { + Ok(operation) => DispatchReceipt { + result: ToolResult::ok(""), + timeline_result: TimelineResultCompletion::MotionDocument { tool, operation }, + }, + Err(error) => DispatchReceipt::complete(motion_document_error(tool, error)), + }; } // 2. Snapshot the pre-run state. @@ -425,10 +523,10 @@ impl Dispatcher { let args = match short_id::expand_id_prefixes(&args, &universe) { Ok(v) => v, Err(e) => { - return ToolResult::public_error( + return DispatchReceipt::complete(ToolResult::public_error( PublicErrorKind::InvalidArguments(tool), e.message, - ); + )); } }; @@ -437,7 +535,7 @@ impl Dispatcher { let mut op = OpContext::default(); let result = match self.run_body(tool, &args, &before, &manifest, &mut op, cancel) { Ok(r) => r, - Err(e) => return ToolResult::error(e.message), + Err(e) => return DispatchReceipt::complete(ToolResult::error(e.message)), }; // 6. Attach the context signal against the post-run timeline. @@ -452,7 +550,117 @@ impl Dispatcher { // created ids in summaries shorten too). let post_manifest = self.handle.media(); let post_universe = short_id::current_id_universe(&after, &post_manifest); - short_id::shorten_ids(result, &post_universe) + let result = short_id::shorten_ids(result, &post_universe); + let timeline_result = + self.timeline_result_completion(tool, &args, &before, &after, &result, cancel); + DispatchReceipt { + result, + timeline_result, + } + } + + /// Direct-scope counterpart to [`Self::dispatch_cancellable_scoped_deferred`]. + pub fn dispatch_cancellable_deferred( + &self, + name: &str, + args: Value, + cancel: &opentake_media::MediaCancelToken, + ) -> DispatchReceipt { + self.dispatch_cancellable_scoped_deferred(DIRECT_UNDO_SCOPE, name, args, cancel) + } + + /// Complete the optional capture and merge it into the already-successful + /// tool result. Capture errors are deliberately non-transactional and are + /// exposed only as one fixed warning. + pub fn finish_dispatch( + &self, + mut receipt: DispatchReceipt, + cancel: &opentake_media::MediaCancelToken, + ) -> ToolResult { + let request = + match std::mem::replace(&mut receipt.timeline_result, TimelineResultCompletion::None) { + TimelineResultCompletion::None => return receipt.result, + TimelineResultCompletion::Warning => { + insert_timeline_result_warning(&mut receipt.result); + return receipt.result; + } + TimelineResultCompletion::MotionDocument { tool, operation } => { + return finish_motion_document_operation(tool, operation, cancel) + } + TimelineResultCompletion::Capture(request) => request, + }; + let Some(bridge) = self.bridge.as_ref() else { + return receipt.result; + }; + let expected_revision = request.mutation.committed_revision.as_ref(); + if cancel.is_cancelled() + || expected_revision.is_some() + && self.handle.current_revision().as_ref() != expected_revision + { + insert_timeline_result_warning(&mut receipt.result); + return receipt.result; + } + let captured = bridge.capture_timeline_result(&request, cancel); + if cancel.is_cancelled() + || expected_revision.is_some() + && self.handle.current_revision().as_ref() != expected_revision + { + insert_timeline_result_warning(&mut receipt.result); + return receipt.result; + } + match captured { + Ok(Block::Image { base64, media_type }) + if media_type == "image/png" + && !base64.is_empty() + && base64.len() <= TIMELINE_RESULT_IMAGE_BASE64_MAX => + { + insert_after_summary(&mut receipt.result, Block::image(base64, media_type)); + } + Ok(_) | Err(_) => insert_timeline_result_warning(&mut receipt.result), + } + receipt.result + } + + fn timeline_result_completion( + &self, + tool: ToolName, + args: &Value, + before: &Timeline, + after: &Timeline, + result: &ToolResult, + cancel: &opentake_media::MediaCancelToken, + ) -> TimelineResultCompletion { + if result.is_error + || cancel.is_cancelled() + || tool == ToolName::Undo + || dispatch_admission_class(tool.as_str(), args) != DispatchAdmissionClass::Mutation + || before == after + { + return TimelineResultCompletion::None; + } + let Some(bridge) = self.bridge.as_ref() else { + return TimelineResultCompletion::None; + }; + let visible_clip_count_before = match bridge.visible_timeline_clip_count(before) { + Ok(count) => count, + Err(_) => return TimelineResultCompletion::Warning, + }; + let visible_clip_count_after = match bridge.visible_timeline_clip_count(after) { + Ok(count) => count, + Err(_) => return TimelineResultCompletion::Warning, + }; + if visible_clip_count_before > 0 && visible_clip_count_after == 0 { + TimelineResultCompletion::Capture(TimelineResultCaptureRequest { + timeline: after.clone(), + mutation: TimelineMutationReceipt { + visible_clip_count_before, + visible_clip_count_after, + committed_revision: self.handle.current_revision(), + }, + }) + } else { + TimelineResultCompletion::None + } } /// Decode args + execute one tool, returning its neutral result or a tool @@ -544,6 +752,14 @@ impl Dispatcher { | ToolName::UpscaleMedia => self.submit_generation(tool, args, cancel), ToolName::AddMotionGraphic => self.add_motion_graphic(args, cancel), ToolName::EditMotionGraphic => self.edit_motion_graphic(args, cancel), + ToolName::ListMotionDocuments + | ToolName::ReadMotionDocument + | ToolName::CreateMotionDocument + | ToolName::PatchMotionDocument + | ToolName::PreviewMotionDocument + | ToolName::PublishMotionDocument => Err(ToolError::new( + "Motion Studio document execution was not deferred", + )), ToolName::TrackMotion | ToolName::GenerateMatte | ToolName::RemoveObject @@ -2727,6 +2943,24 @@ impl Dispatcher { } } +impl DispatchReceipt { + fn complete(result: ToolResult) -> Self { + Self { + result, + timeline_result: TimelineResultCompletion::None, + } + } +} + +fn insert_after_summary(result: &mut ToolResult, block: Block) { + let index = usize::from(matches!(result.content.first(), Some(Block::Text { .. }))); + result.content.insert(index, block); +} + +fn insert_timeline_result_warning(result: &mut ToolResult) { + insert_after_summary(result, Block::text(TIMELINE_RESULT_WARNING)); +} + fn ensure_not_cancelled(cancel: &opentake_media::MediaCancelToken) -> Result<(), ToolError> { if cancel.is_cancelled() { Err(ToolError::new("Cancelled")) @@ -2901,6 +3135,14 @@ fn validate_tool_args(tool: ToolName, args: &Value) -> Result<(), ToolError> { validate_motion_params(params, "params")?; } } + ToolName::ListMotionDocuments + | ToolName::ReadMotionDocument + | ToolName::CreateMotionDocument + | ToolName::PatchMotionDocument + | ToolName::PreviewMotionDocument + | ToolName::PublishMotionDocument => { + decode_motion_document_request(tool, args)?; + } ToolName::TrackMotion => { decode!(TrackMotionArgs); validate_required_object::(args, "region", "region")?; @@ -4412,6 +4654,7 @@ mod tests { use opentake_domain::{ClipType, MediaManifestEntry, MediaSource, Track}; use opentake_ops::command::EditResult; use std::path::PathBuf; + use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use crate::mcp::core_handle::CoreHandle; @@ -4509,6 +4752,70 @@ mod tests { Dispatcher::new(handle, Arc::new(RwLock::new(PluginRegistry::new()))) } + struct DeferredDocumentBridge { + admitted: Arc, + executed: Arc, + } + + struct DeferredDocumentOperation { + executed: Arc, + } + + impl AdmittedMotionDocumentOperation for DeferredDocumentOperation { + fn execute( + self: Box, + _cancel: &opentake_media::MediaCancelToken, + ) -> Result< + crate::mcp::motion_documents::MotionDocumentResponse, + crate::mcp::motion_documents::MotionDocumentBridgeError, + > { + self.executed.fetch_add(1, Ordering::SeqCst); + Ok(crate::mcp::motion_documents::MotionDocumentResponse::Documents(Vec::new())) + } + } + + impl MotionDocumentBridge for DeferredDocumentBridge { + fn can_edit_motion_documents(&self) -> bool { + true + } + + fn admit( + &self, + _request: crate::mcp::motion_documents::MotionDocumentRequest, + ) -> Result< + Box, + crate::mcp::motion_documents::MotionDocumentBridgeError, + > { + self.admitted.fetch_add(1, Ordering::SeqCst); + Ok(Box::new(DeferredDocumentOperation { + executed: self.executed.clone(), + })) + } + } + + #[test] + fn motion_document_execution_is_deferred_until_project_lease_is_released() { + let admitted = Arc::new(AtomicUsize::new(0)); + let executed = Arc::new(AtomicUsize::new(0)); + let dispatcher = dispatcher_with(Arc::new(TestHandle::new())).with_motion_document_bridge( + Some(Arc::new(DeferredDocumentBridge { + admitted: admitted.clone(), + executed: executed.clone(), + })), + ); + let cancel = opentake_media::MediaCancelToken::new(); + let receipt = dispatcher.dispatch_cancellable_deferred( + "list_motion_documents", + serde_json::json!({}), + &cancel, + ); + assert_eq!(admitted.load(Ordering::SeqCst), 1); + assert_eq!(executed.load(Ordering::SeqCst), 0); + let result = dispatcher.finish_dispatch(receipt, &cancel); + assert!(!result.is_error, "{}", result.text_joined()); + assert_eq!(executed.load(Ordering::SeqCst), 1); + } + #[test] fn unknown_tool_is_error() { let d = dispatcher_with(Arc::new(TestHandle::new())); @@ -6458,8 +6765,8 @@ mod tests { use crate::mcp::media_bridge::{ BridgeError, ImportOutcome, ImportSource, InspectMediaRequest, InspectMediaResult, - InspectResult, InspectedFrame, InspectedMediaFrame, MediaBridge, TranscriptSource, - TranscriptSourceResult, + InspectResult, InspectedFrame, InspectedMediaFrame, MediaBridge, + TimelineResultCaptureRequest, TranscriptSource, TranscriptSourceResult, }; use crate::tools::result::Block; use opentake_media::{TranscriptionResult, TranscriptionSegment, TranscriptionWord}; @@ -6494,6 +6801,10 @@ mod tests { /// runs. Records the `(query, scope, limit, candidate ids)` of each call. search_result: Mutex>, search_calls: Mutex>, + timeline_result_captures: Mutex>, + timeline_result_capture_error: Mutex, + timeline_visibility_error: Mutex>, + cancel_during_timeline_capture: Mutex, } /// One recorded `search_media` call: `(query, scope, limit, candidate ids)`. @@ -6510,6 +6821,48 @@ mod tests { } impl MediaBridge for FakeBridge { + fn visible_timeline_clip_count(&self, timeline: &Timeline) -> Result { + if let Some(error) = self.timeline_visibility_error.lock().unwrap().as_ref() { + return Err(BridgeError::new(error.clone())); + } + Ok(timeline + .tracks + .iter() + .filter(|track| !track.hidden && track.kind != ClipType::Audio) + .flat_map(|track| &track.clips) + .filter(|clip| { + clip.duration_frames > 0 + && clip.media_type.is_visual() + && clip.opacity > 0.0 + && (clip.media_type != ClipType::Text + || clip + .text_content + .as_deref() + .is_some_and(|text| !text.trim().is_empty())) + }) + .count()) + } + + fn capture_timeline_result( + &self, + request: &TimelineResultCaptureRequest, + cancel: &opentake_media::MediaCancelToken, + ) -> Result { + self.timeline_result_captures + .lock() + .unwrap() + .push(request.clone()); + if *self.cancel_during_timeline_capture.lock().unwrap() { + cancel.cancel(); + } + if *self.timeline_result_capture_error.lock().unwrap() { + return Err(BridgeError::new( + "PRIVATE_CAPTURE_PATH=/Users/private/project.opentake", + )); + } + Ok(Block::image("iVBORw0KGgo=", "image/png")) + } + fn inspect_media( &self, request: &InspectMediaRequest, @@ -6675,6 +7028,293 @@ mod tests { (d, bridge) } + fn timeline_image_dispatcher(clip_count: usize) -> (Dispatcher, Arc) { + let mut timeline = Timeline::new(); + let mut track = Track::new("video", ClipType::Video); + for index in 0..clip_count { + track.clips.push(Clip::new( + format!("clip-{index}"), + format!("asset-{index}"), + index as i32 * 30, + 30, + )); + } + timeline.tracks.push(track); + let bridge = Arc::new(FakeBridge::default()); + let dispatcher = Dispatcher::with_bridge( + Arc::new(StateHandle::new(timeline, MediaManifest::new())), + Arc::new(RwLock::new(PluginRegistry::new())), + Some(bridge.clone()), + ); + (dispatcher, bridge) + } + + struct RevisionBumpingCaptureBridge { + handle: Arc, + captures: std::sync::atomic::AtomicUsize, + } + + impl MediaBridge for RevisionBumpingCaptureBridge { + fn visible_timeline_clip_count(&self, timeline: &Timeline) -> Result { + Ok(timeline + .tracks + .iter() + .filter(|track| !track.hidden && track.kind != ClipType::Audio) + .flat_map(|track| &track.clips) + .filter(|clip| { + clip.duration_frames > 0 && clip.media_type.is_visual() && clip.opacity > 0.0 + }) + .count()) + } + + fn capture_timeline_result( + &self, + _request: &TimelineResultCaptureRequest, + _cancel: &opentake_media::MediaCancelToken, + ) -> Result { + self.captures + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + self.handle + .apply(EditCommand::InsertTrack { + kind: ClipType::Audio, + at: None, + }) + .expect("simulate a concurrent committed revision"); + Ok(Block::image("iVBORw0KGgo=", "image/png")) + } + } + + fn has_timeline_result_image(result: &ToolResult) -> bool { + result.content.iter().any( + |block| matches!(block, Block::Image { media_type, .. } if media_type == "image/png"), + ) + } + + #[test] + fn timeline_image_visible_to_empty_records_receipt_and_orders_text_then_png() { + let (dispatcher, bridge) = timeline_image_dispatcher(1); + + let result = + dispatcher.dispatch("remove_clips", serde_json::json!({"clipIds": ["clip-0"]})); + + assert!(!result.is_error, "{}", result.text_joined()); + assert!(matches!(result.content.first(), Some(Block::Text { .. }))); + assert!(matches!( + result.content.get(1), + Some(Block::Image { media_type, .. }) if media_type == "image/png" + )); + let captures = bridge.timeline_result_captures.lock().unwrap(); + assert_eq!(captures.len(), 1); + assert_eq!(captures[0].mutation.visible_clip_count_before, 1); + assert_eq!(captures[0].mutation.visible_clip_count_after, 0); + } + + #[test] + fn timeline_image_delete_that_leaves_visible_content_does_not_capture() { + let (dispatcher, bridge) = timeline_image_dispatcher(2); + + let result = + dispatcher.dispatch("remove_clips", serde_json::json!({"clipIds": ["clip-0"]})); + + assert!(!result.is_error, "{}", result.text_joined()); + assert!(!has_timeline_result_image(&result)); + assert!(bridge.timeline_result_captures.lock().unwrap().is_empty()); + } + + #[test] + fn timeline_image_non_visual_mutation_does_not_capture() { + let (dispatcher, bridge) = timeline_image_dispatcher(1); + + let result = dispatcher.dispatch("deactivate_workflow", serde_json::json!({})); + + assert!(!result.is_error, "{}", result.text_joined()); + assert!(!has_timeline_result_image(&result)); + assert!(bridge.timeline_result_captures.lock().unwrap().is_empty()); + } + + #[test] + fn timeline_image_unchanged_timeline_visibility_failure_does_not_warn() { + let (dispatcher, bridge) = timeline_image_dispatcher(1); + *bridge.timeline_visibility_error.lock().unwrap() = + Some("PRIVATE_VISIBILITY_PATH=/Users/private/project.opentake".into()); + + let result = dispatcher.dispatch("deactivate_workflow", serde_json::json!({})); + + assert!(!result.is_error, "{}", result.text_joined()); + assert!(!result.text_joined().contains(TIMELINE_RESULT_WARNING)); + assert!(!result.text_joined().contains("PRIVATE_VISIBILITY_PATH")); + assert!(!has_timeline_result_image(&result)); + assert!(bridge.timeline_result_captures.lock().unwrap().is_empty()); + } + + #[test] + fn timeline_image_failed_mutation_rolls_back_and_does_not_capture() { + let (dispatcher, bridge) = timeline_image_dispatcher(1); + + let result = + dispatcher.dispatch("remove_clips", serde_json::json!({"clipIds": ["missing"]})); + + assert!(result.is_error); + assert_eq!(dispatcher.timeline().tracks[0].clips.len(), 1); + assert!(!has_timeline_result_image(&result)); + assert!(bridge.timeline_result_captures.lock().unwrap().is_empty()); + } + + #[test] + fn timeline_image_cancelled_mutation_does_not_capture() { + let (dispatcher, bridge) = timeline_image_dispatcher(1); + let cancel = opentake_media::MediaCancelToken::new(); + cancel.cancel(); + + let result = dispatcher.dispatch_cancellable( + "remove_clips", + serde_json::json!({"clipIds": ["clip-0"]}), + &cancel, + ); + + assert!(result.is_error); + assert_eq!(dispatcher.timeline().tracks[0].clips.len(), 1); + assert!(!has_timeline_result_image(&result)); + assert!(bridge.timeline_result_captures.lock().unwrap().is_empty()); + } + + #[test] + fn timeline_image_cancelled_during_capture_returns_no_image() { + let (dispatcher, bridge) = timeline_image_dispatcher(1); + let cancel = opentake_media::MediaCancelToken::new(); + *bridge.cancel_during_timeline_capture.lock().unwrap() = true; + + let result = dispatcher.dispatch_cancellable( + "remove_clips", + serde_json::json!({"clipIds": ["clip-0"]}), + &cancel, + ); + + assert!(!result.is_error, "the committed edit remains successful"); + assert!(!has_timeline_result_image(&result)); + assert!(result.text_joined().contains(TIMELINE_RESULT_WARNING)); + assert_eq!(bridge.timeline_result_captures.lock().unwrap().len(), 1); + } + + #[test] + fn timeline_image_stale_revision_after_capture_returns_no_image() { + let mut timeline = Timeline::new(); + let mut track = Track::new("video", ClipType::Video); + track.clips.push(Clip::new("clip-0", "asset-0", 0, 30)); + timeline.tracks.push(track); + let handle = Arc::new(StateHandle::new(timeline, MediaManifest::new())); + let bridge = Arc::new(RevisionBumpingCaptureBridge { + handle: handle.clone(), + captures: std::sync::atomic::AtomicUsize::new(0), + }); + let dispatcher = Dispatcher::with_bridge( + handle, + Arc::new(RwLock::new(PluginRegistry::new())), + Some(bridge.clone()), + ); + + let result = + dispatcher.dispatch("remove_clips", serde_json::json!({"clipIds": ["clip-0"]})); + + assert!( + !result.is_error, + "the committed deletion remains successful" + ); + assert!(!has_timeline_result_image(&result)); + assert!(result.text_joined().contains(TIMELINE_RESULT_WARNING)); + assert_eq!( + bridge.captures.load(std::sync::atomic::Ordering::Relaxed), + 1 + ); + } + + #[test] + fn timeline_image_undo_never_captures() { + let (dispatcher, bridge) = timeline_image_dispatcher(1); + let removed = + dispatcher.dispatch("remove_clips", serde_json::json!({"clipIds": ["clip-0"]})); + assert!(has_timeline_result_image(&removed)); + bridge.timeline_result_captures.lock().unwrap().clear(); + + let undone = dispatcher.dispatch("undo", serde_json::json!({})); + + assert!(!undone.is_error, "{}", undone.text_joined()); + assert!(!has_timeline_result_image(&undone)); + assert!(bridge.timeline_result_captures.lock().unwrap().is_empty()); + } + + #[test] + fn timeline_image_batched_delete_captures_once_with_exact_counts() { + let (dispatcher, bridge) = timeline_image_dispatcher(2); + + let result = dispatcher.dispatch( + "remove_clips", + serde_json::json!({"clipIds": ["clip-0", "clip-1"]}), + ); + + assert!(!result.is_error, "{}", result.text_joined()); + assert!(has_timeline_result_image(&result)); + let captures = bridge.timeline_result_captures.lock().unwrap(); + assert_eq!(captures.len(), 1); + assert_eq!(captures[0].mutation.visible_clip_count_before, 2); + assert_eq!(captures[0].mutation.visible_clip_count_after, 0); + } + + #[test] + fn timeline_image_capture_failure_preserves_edit_and_appends_sanitized_warning() { + let (dispatcher, bridge) = timeline_image_dispatcher(1); + *bridge.timeline_result_capture_error.lock().unwrap() = true; + + let result = + dispatcher.dispatch("remove_clips", serde_json::json!({"clipIds": ["clip-0"]})); + + assert!(!result.is_error, "{}", result.text_joined()); + assert_eq!( + dispatcher + .timeline() + .tracks + .iter() + .map(|track| track.clips.len()) + .sum::(), + 0 + ); + assert!(!has_timeline_result_image(&result)); + assert!(result + .text_joined() + .contains("Timeline preview unavailable.")); + assert!(!result.text_joined().contains("PRIVATE_CAPTURE_PATH")); + } + + #[test] + fn timeline_image_visibility_failure_preserves_edit_and_appends_sanitized_warning() { + let (dispatcher, bridge) = timeline_image_dispatcher(1); + *bridge.timeline_visibility_error.lock().unwrap() = + Some("PRIVATE_VISIBILITY_PATH=/Users/private/project.opentake".into()); + + let result = + dispatcher.dispatch("remove_clips", serde_json::json!({"clipIds": ["clip-0"]})); + + assert!(!result.is_error, "{}", result.text_joined()); + assert_eq!( + dispatcher + .timeline() + .tracks + .iter() + .map(|track| track.clips.len()) + .sum::(), + 0, + "visibility failure must not roll back the committed deletion" + ); + assert!(matches!(result.content.first(), Some(Block::Text { .. }))); + assert!(matches!( + result.content.get(1), + Some(Block::Text { text }) if text == TIMELINE_RESULT_WARNING + )); + assert!(!has_timeline_result_image(&result)); + assert!(!result.text_joined().contains("PRIVATE_VISIBILITY_PATH")); + assert!(bridge.timeline_result_captures.lock().unwrap().is_empty()); + } + fn inspected_transcript() -> TranscriptionResult { TranscriptionResult { text: "hello world".into(), diff --git a/crates/opentake-agent/src/mcp/media_bridge.rs b/crates/opentake-agent/src/mcp/media_bridge.rs index 113e31b4..5ad1bceb 100644 --- a/crates/opentake-agent/src/mcp/media_bridge.rs +++ b/crates/opentake-agent/src/mcp/media_bridge.rs @@ -23,9 +23,10 @@ //! Both methods default to `Err("unsupported")` so a hand-rolled bridge (or the //! absence of one) never breaks the build. -use opentake_domain::ClipType; +use opentake_domain::{ClipType, Timeline}; use opentake_media::{MediaCancelToken, TranscriptionResult}; +use crate::mcp::core_handle::CoreRevision; use crate::tools::result::Block; /// Maximum inline `import_media.source.bytes` payload size before base64 decode. @@ -40,6 +41,27 @@ pub const IMPORT_BYTES_DECODED_MAX: usize = 11 * 1024 * 1024; /// Leaves 1 MiB of JSON envelope headroom around the advertised base64 cap. pub const MCP_REQUEST_BODY_MAX: usize = IMPORT_BYTES_BASE64_MAX + 1024 * 1024; +/// Matches the browser's `MAX_CHAT_IMAGE_BASE64_CHARS`. A host result larger +/// than this never enters persisted chat state or an MCP response. +pub const TIMELINE_RESULT_IMAGE_BASE64_MAX: usize = 1024 * 1024; + +/// Post-commit facts used to decide whether a timeline result image belongs to +/// one successful mutation. +#[derive(Debug, Clone)] +pub struct TimelineMutationReceipt { + pub visible_clip_count_before: usize, + pub visible_clip_count_after: usize, + pub committed_revision: Option, +} + +/// Immutable input for a post-commit timeline capture. The exact committed +/// timeline travels with its revision so a host can reject stale project bytes. +#[derive(Debug, Clone)] +pub struct TimelineResultCaptureRequest { + pub timeline: Timeline, + pub mutation: TimelineMutationReceipt, +} + /// One composited timeline frame produced by [`MediaBridge::inspect_timeline`], /// ready to become MCP image content. `bytes` are already-encoded image data /// (JPEG in the production path) — the agent crate never links an image encoder; @@ -334,6 +356,29 @@ pub struct SearchMediaResult { /// so the [`Dispatcher`](super::dispatch::Dispatcher) can hold `Arc` across threads (matching [`CoreHandle`](super::core_handle)). pub trait MediaBridge: Send + Sync { + /// Count meaningful visual leaves through the host's authoritative render + /// plan. The dispatcher intentionally does not duplicate compositor + /// visibility rules in the agent crate. + fn visible_timeline_clip_count(&self, _timeline: &Timeline) -> Result { + Err(BridgeError::unavailable( + "timeline result visibility is not available in this build", + )) + } + + /// Produce one bounded PNG content block for the exact committed timeline. + /// The host owns compositing, encoding, retained source authority, and the + /// final project-revision check. `cancel` is the caller's original request + /// token and must be propagated to every blocking render operation. + fn capture_timeline_result( + &self, + _request: &TimelineResultCaptureRequest, + _cancel: &MediaCancelToken, + ) -> Result { + Err(BridgeError::unavailable( + "timeline result capture is not available in this build", + )) + } + /// Inspect one source asset with real decoded frames and optional on-device /// transcription. The default is explicitly unavailable so non-desktop /// embedders do not advertise a fake success. diff --git a/crates/opentake-agent/src/mcp/mod.rs b/crates/opentake-agent/src/mcp/mod.rs index 9da0a0d8..ee1ad35a 100644 --- a/crates/opentake-agent/src/mcp/mod.rs +++ b/crates/opentake-agent/src/mcp/mod.rs @@ -18,5 +18,8 @@ pub mod generation; pub mod media_bridge; mod media_catalog; pub mod motion; +pub mod motion_documents; pub mod server; pub mod vision; + +pub use server::{AuthenticatedMcpClient, BearerAuthorizer, ManagedMcpEndpoint, ManagedMcpError}; diff --git a/crates/opentake-agent/src/mcp/motion.rs b/crates/opentake-agent/src/mcp/motion.rs index 99c48f88..08de3415 100644 --- a/crates/opentake-agent/src/mcp/motion.rs +++ b/crates/opentake-agent/src/mcp/motion.rs @@ -42,6 +42,15 @@ pub struct MotionCommit { pub content_hash: String, pub action_name: String, pub output: MotionOutputMetadata, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_document: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MotionDocumentReference { + pub document_id: String, + pub revision_hash: String, } #[derive(Debug, Serialize)] diff --git a/crates/opentake-agent/src/mcp/motion_documents.rs b/crates/opentake-agent/src/mcp/motion_documents.rs new file mode 100644 index 00000000..acb74395 --- /dev/null +++ b/crates/opentake-agent/src/mcp/motion_documents.rs @@ -0,0 +1,809 @@ +//! Typed, capability-confined Agent access to project Motion Studio documents. +//! +//! Admission captures the exact host project while its lifecycle lease is +//! held; execution happens only after that lease is released so the desktop +//! bridge can take publication locks and run Chromium/FFmpeg without reversing +//! the project lock order. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::tools::errors::{decode_tool_args, ToolArgs, ToolError}; +use crate::tools::names::ToolName; +use crate::tools::result::{Block, PublicErrorKind, ToolResult}; + +pub const MAX_MOTION_DOCUMENTS: usize = 128; +pub const MAX_MOTION_DOCUMENT_SOURCE_BYTES: usize = 1024 * 1024; +pub const MAX_MOTION_DOCUMENT_EDITS: usize = 256; +pub const MAX_MOTION_DOCUMENT_TITLE_CHARS: usize = 120; +pub const MAX_MOTION_PREVIEW_DIMENSION: u32 = 4096; +// Keep this capability boundary aligned with the production Chromium renderer +// (`opentake_motion::limits::MAX_FRAMES`) without adding a renderer dependency +// to the Agent crate. +pub const MAX_MOTION_PREVIEW_FRAMES: u32 = 3_600; +pub const MAX_MOTION_PREVIEW_PNG_BASE64: usize = 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MotionDocumentTool { + List, + Read, + Create, + Patch, + Preview, + Publish, +} + +impl MotionDocumentTool { + pub const ALL: [Self; 6] = [ + Self::List, + Self::Read, + Self::Create, + Self::Patch, + Self::Preview, + Self::Publish, + ]; + + pub fn from_tool_name(tool: ToolName) -> Option { + match tool { + ToolName::ListMotionDocuments => Some(Self::List), + ToolName::ReadMotionDocument => Some(Self::Read), + ToolName::CreateMotionDocument => Some(Self::Create), + ToolName::PatchMotionDocument => Some(Self::Patch), + ToolName::PreviewMotionDocument => Some(Self::Preview), + ToolName::PublishMotionDocument => Some(Self::Publish), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MotionDocumentSummary { + pub document_id: String, + pub title: String, + pub revision_hash: String, + pub updated_at: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MotionDocument { + pub summary: MotionDocumentSummary, + pub html: String, + pub css: String, + #[serde(default)] + pub parameters: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MotionTextReplacement { + pub start: usize, + pub end: usize, + pub replacement: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MotionDocumentPatchRequest { + pub document_id: String, + pub file: String, + pub baseline_hash: String, + pub edits: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MotionDocumentPreviewRequest { + pub document_id: String, + pub revision_hash: String, + pub width: u32, + pub height: u32, + pub fps: u32, + pub duration_frames: u32, + pub frame: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MotionDocumentPublishRequest { + pub document_id: String, + pub revision_hash: String, + pub width: u32, + pub height: u32, + pub fps: u32, + pub duration_frames: i32, + pub start_frame: Option, + pub track_index: Option, + pub clip_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MotionDocumentRequest { + List, + Read { document_id: String }, + Create { title: Option }, + Patch(MotionDocumentPatchRequest), + Preview(MotionDocumentPreviewRequest), + Publish(MotionDocumentPublishRequest), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MotionPreviewDiagnostic { + pub severity: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub line: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub column: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MotionDocumentPreview { + pub revision_hash: String, + pub frame: u32, + pub png_base64: String, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MotionDocumentPublish { + pub clip_id: String, + pub asset_id: String, + pub duration_frames: i32, + pub duration_seconds: f64, + pub fps: f64, + pub width: u32, + pub height: u32, + pub source_document: MotionDocumentReference, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MotionDocumentReference { + pub document_id: String, + pub revision_hash: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum MotionDocumentResponse { + Documents(Vec), + Document(MotionDocument), + Preview(MotionDocumentPreview), + Published(MotionDocumentPublish), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MotionDocumentBridgeErrorKind { + InvalidArguments, + ResourceNotFound, + Conflict, + CapabilityUnavailable, + Cancelled, + RenderFailed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MotionDocumentBridgeError { + pub kind: MotionDocumentBridgeErrorKind, + pub message: String, + pub current_revision_hash: Option, +} + +impl MotionDocumentBridgeError { + pub fn new(kind: MotionDocumentBridgeErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + current_revision_hash: None, + } + } + + pub fn conflict(current_revision_hash: Option) -> Self { + Self { + kind: MotionDocumentBridgeErrorKind::Conflict, + message: "Motion Studio document revision conflict".into(), + current_revision_hash, + } + } +} + +pub trait AdmittedMotionDocumentOperation: Send { + fn execute( + self: Box, + cancel: &opentake_media::MediaCancelToken, + ) -> Result; +} + +pub trait MotionDocumentBridge: Send + Sync { + fn can_edit_motion_documents(&self) -> bool; + + fn admit( + &self, + request: MotionDocumentRequest, + ) -> Result, MotionDocumentBridgeError>; +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReadArgs { + document_id: String, +} +impl ToolArgs for ReadArgs { + const ALLOWED_KEYS: &'static [&'static str] = &["documentId"]; +} + +#[derive(Debug, Default, Deserialize)] +struct EmptyArgs {} +impl ToolArgs for EmptyArgs { + const ALLOWED_KEYS: &'static [&'static str] = &[]; +} + +#[derive(Debug, Default, Deserialize)] +struct CreateArgs { + title: Option, +} +impl ToolArgs for CreateArgs { + const ALLOWED_KEYS: &'static [&'static str] = &["title"]; +} + +#[derive(Debug, Default, Deserialize)] +struct ReplacementArgs { + start: usize, + end: usize, + replacement: String, +} +impl ToolArgs for ReplacementArgs { + const ALLOWED_KEYS: &'static [&'static str] = &["start", "end", "replacement"]; +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PatchArgs { + document_id: String, + file: String, + baseline_hash: String, + edits: Vec, +} +impl ToolArgs for PatchArgs { + const ALLOWED_KEYS: &'static [&'static str] = &["documentId", "file", "baselineHash", "edits"]; +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PreviewArgs { + document_id: String, + revision_hash: String, + width: u32, + height: u32, + fps: u32, + duration_frames: u32, + frame: u32, +} +impl ToolArgs for PreviewArgs { + const ALLOWED_KEYS: &'static [&'static str] = &[ + "documentId", + "revisionHash", + "width", + "height", + "fps", + "durationFrames", + "frame", + ]; +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PublishArgs { + document_id: String, + revision_hash: String, + width: u32, + height: u32, + fps: u32, + duration_frames: i32, + start_frame: Option, + track_index: Option, + clip_id: Option, +} +impl ToolArgs for PublishArgs { + const ALLOWED_KEYS: &'static [&'static str] = &[ + "documentId", + "revisionHash", + "width", + "height", + "fps", + "durationFrames", + "startFrame", + "trackIndex", + "clipId", + ]; +} + +pub(crate) fn decode_request( + tool: ToolName, + args: &Value, +) -> Result { + match MotionDocumentTool::from_tool_name(tool) { + Some(MotionDocumentTool::List) => { + let _: EmptyArgs = decode_tool_args(args, "")?; + Ok(MotionDocumentRequest::List) + } + Some(MotionDocumentTool::Read) => { + let args: ReadArgs = decode_tool_args(args, "")?; + validate_document_id(&args.document_id)?; + Ok(MotionDocumentRequest::Read { + document_id: args.document_id, + }) + } + Some(MotionDocumentTool::Create) => { + let args: CreateArgs = decode_tool_args(args, "")?; + if let Some(title) = args.title.as_deref() { + validate_title(title)?; + } + Ok(MotionDocumentRequest::Create { title: args.title }) + } + Some(MotionDocumentTool::Patch) => { + let args: PatchArgs = decode_tool_args(args, "")?; + validate_document_id(&args.document_id)?; + validate_hash(&args.baseline_hash, "baselineHash")?; + if !matches!(args.file.as_str(), "index.html" | "styles.css") { + return Err(ToolError::new( + "file must be exactly 'index.html' or 'styles.css'", + )); + } + if args.edits.is_empty() || args.edits.len() > MAX_MOTION_DOCUMENT_EDITS { + return Err(ToolError::new( + "edits must contain between 1 and 256 replacements", + )); + } + let mut inserted_bytes = 0usize; + let edits = args + .edits + .iter() + .enumerate() + .map(|(index, value)| { + let edit: ReplacementArgs = + decode_tool_args(value, &format!("edits[{index}]"))?; + if edit.start > edit.end { + return Err(ToolError::new(format!( + "edits[{index}]: start must not exceed end" + ))); + } + inserted_bytes = inserted_bytes + .checked_add(edit.replacement.len()) + .ok_or_else(|| ToolError::new("edits exceed their byte limit"))?; + if inserted_bytes > MAX_MOTION_DOCUMENT_SOURCE_BYTES { + return Err(ToolError::new("edits exceed their byte limit")); + } + Ok(MotionTextReplacement { + start: edit.start, + end: edit.end, + replacement: edit.replacement, + }) + }) + .collect::, _>>()?; + Ok(MotionDocumentRequest::Patch(MotionDocumentPatchRequest { + document_id: args.document_id, + file: args.file, + baseline_hash: args.baseline_hash, + edits, + })) + } + Some(MotionDocumentTool::Preview) => { + let args: PreviewArgs = decode_tool_args(args, "")?; + validate_document_id(&args.document_id)?; + validate_hash(&args.revision_hash, "revisionHash")?; + validate_render_bounds(args.width, args.height, args.fps, args.duration_frames)?; + if args.frame >= args.duration_frames { + return Err(ToolError::new("frame must be inside durationFrames")); + } + Ok(MotionDocumentRequest::Preview( + MotionDocumentPreviewRequest { + document_id: args.document_id, + revision_hash: args.revision_hash, + width: args.width, + height: args.height, + fps: args.fps, + duration_frames: args.duration_frames, + frame: args.frame, + }, + )) + } + Some(MotionDocumentTool::Publish) => { + let args: PublishArgs = decode_tool_args(args, "")?; + validate_document_id(&args.document_id)?; + validate_hash(&args.revision_hash, "revisionHash")?; + let frames = u32::try_from(args.duration_frames) + .map_err(|_| ToolError::new("durationFrames must be positive"))?; + validate_render_bounds(args.width, args.height, args.fps, frames)?; + if !args.width.is_multiple_of(2) || !args.height.is_multiple_of(2) { + return Err(ToolError::new( + "publish width and height must be even numbers", + )); + } + match (&args.clip_id, args.start_frame) { + (None, Some(start)) if start >= 0 => {} + (Some(clip_id), None) => validate_safe_id(clip_id, "clipId")?, + (None, _) => { + return Err(ToolError::new( + "startFrame is required when clipId is omitted", + )) + } + (Some(_), Some(_)) => { + return Err(ToolError::new( + "startFrame must be omitted when clipId is provided", + )) + } + } + if args.clip_id.is_some() && args.track_index.is_some() { + return Err(ToolError::new( + "trackIndex must be omitted when clipId is provided", + )); + } + Ok(MotionDocumentRequest::Publish( + MotionDocumentPublishRequest { + document_id: args.document_id, + revision_hash: args.revision_hash, + width: args.width, + height: args.height, + fps: args.fps, + duration_frames: args.duration_frames, + start_frame: args.start_frame, + track_index: args.track_index, + clip_id: args.clip_id, + }, + )) + } + None => Err(ToolError::new("not a Motion Studio document tool")), + } +} + +pub(crate) fn result_from_operation( + tool: ToolName, + operation: Box, + cancel: &opentake_media::MediaCancelToken, +) -> ToolResult { + if cancel.is_cancelled() { + return ToolResult::error("Cancelled"); + } + match operation.execute(cancel) { + Ok(response) => model_safe_response(response), + Err(error) => result_from_error(tool, error), + } +} + +pub(crate) fn result_from_error(tool: ToolName, error: MotionDocumentBridgeError) -> ToolResult { + if error.kind == MotionDocumentBridgeErrorKind::Conflict { + let current_revision_hash = error + .current_revision_hash + .filter(|hash| validate_hash_value(hash).is_ok()); + return ToolResult::ok( + serde_json::json!({ + "status": "conflict", + "currentRevisionHash": current_revision_hash, + "remediation": "Read the document again and reapply the intended patch explicitly." + }) + .to_string(), + ); + } + let (kind, detail) = match error.kind { + MotionDocumentBridgeErrorKind::InvalidArguments => ( + PublicErrorKind::InvalidArguments(tool), + "Motion Studio document arguments are invalid.", + ), + MotionDocumentBridgeErrorKind::ResourceNotFound => ( + PublicErrorKind::ResourceNotFound(tool), + "The Motion Studio document or clip was not found.", + ), + MotionDocumentBridgeErrorKind::CapabilityUnavailable => ( + PublicErrorKind::CapabilityUnavailable(tool), + "Motion Studio rendering is unavailable.", + ), + MotionDocumentBridgeErrorKind::Cancelled | MotionDocumentBridgeErrorKind::RenderFailed => { + return ToolResult::error(match error.kind { + MotionDocumentBridgeErrorKind::Cancelled => { + "Motion Studio operation was cancelled." + } + _ => "Motion Studio rendering failed.", + }) + } + MotionDocumentBridgeErrorKind::Conflict => unreachable!(), + }; + ToolResult::public_error(kind, detail) +} + +fn model_safe_response(response: MotionDocumentResponse) -> ToolResult { + match response { + MotionDocumentResponse::Documents(documents) => { + if documents.len() > MAX_MOTION_DOCUMENTS + || documents.iter().any(|item| validate_summary(item).is_err()) + { + return ToolResult::error("Motion Studio document response exceeded its bounds"); + } + ToolResult::ok(serde_json::json!({"documents": documents}).to_string()) + } + MotionDocumentResponse::Document(document) => { + if validate_document(&document).is_err() { + return ToolResult::error("Motion Studio document response exceeded its bounds"); + } + ToolResult::ok( + serde_json::to_string(&document) + .unwrap_or_else(|_| "{\"status\":\"unavailable\"}".into()), + ) + } + MotionDocumentResponse::Preview(preview) => { + if validate_hash_value(&preview.revision_hash).is_err() + || preview.png_base64.is_empty() + || preview.png_base64.len() > MAX_MOTION_PREVIEW_PNG_BASE64 + || preview.diagnostics.len() > 32 + || preview.diagnostics.iter().any(|diagnostic| { + !matches!(diagnostic.severity.as_str(), "error" | "warning" | "info") + || diagnostic.message.is_empty() + || diagnostic.message.len() > 512 + || diagnostic.message.chars().any(char::is_control) + }) + { + return ToolResult::error("Motion Studio preview response exceeded its bounds"); + } + ToolResult::blocks(vec![ + Block::text( + serde_json::json!({ + "status": "previewed", + "revisionHash": preview.revision_hash, + "frame": preview.frame, + "diagnostics": preview.diagnostics, + }) + .to_string(), + ), + Block::image(preview.png_base64, "image/png"), + ]) + } + MotionDocumentResponse::Published(published) => { + if validate_safe_id(&published.clip_id, "clipId").is_err() + || validate_safe_id(&published.asset_id, "assetId").is_err() + || validate_document_id(&published.source_document.document_id).is_err() + || validate_hash_value(&published.source_document.revision_hash).is_err() + || !published.duration_seconds.is_finite() + || !published.fps.is_finite() + { + return ToolResult::error("Motion Studio publish response was invalid"); + } + ToolResult::ok( + serde_json::json!({ + "status": "published", + "clipId": published.clip_id, + "assetId": published.asset_id, + "durationFrames": published.duration_frames, + "durationSeconds": published.duration_seconds, + "fps": published.fps, + "width": published.width, + "height": published.height, + "sourceDocument": published.source_document, + }) + .to_string(), + ) + } + } +} + +fn validate_render_bounds( + width: u32, + height: u32, + fps: u32, + duration_frames: u32, +) -> Result<(), ToolError> { + if width < 2 + || height < 2 + || width > MAX_MOTION_PREVIEW_DIMENSION + || height > MAX_MOTION_PREVIEW_DIMENSION + { + return Err(ToolError::new("width and height are outside their bounds")); + } + if !(1..=120).contains(&fps) { + return Err(ToolError::new("fps must be between 1 and 120")); + } + if !(1..=MAX_MOTION_PREVIEW_FRAMES).contains(&duration_frames) { + return Err(ToolError::new("durationFrames is outside its bounds")); + } + Ok(()) +} + +fn validate_document(document: &MotionDocument) -> Result<(), ToolError> { + validate_summary(&document.summary)?; + if document.html.len() > MAX_MOTION_DOCUMENT_SOURCE_BYTES + || document.css.len() > MAX_MOTION_DOCUMENT_SOURCE_BYTES + { + return Err(ToolError::new("document source exceeds its byte limit")); + } + let parameter_bytes = serde_json::to_vec(&document.parameters) + .map_err(|_| ToolError::new("document parameters are invalid"))?; + if parameter_bytes.len() > MAX_MOTION_DOCUMENT_SOURCE_BYTES { + return Err(ToolError::new( + "document parameters exceed their byte limit", + )); + } + Ok(()) +} + +fn validate_summary(summary: &MotionDocumentSummary) -> Result<(), ToolError> { + validate_document_id(&summary.document_id)?; + validate_title(&summary.title)?; + validate_hash(&summary.revision_hash, "revisionHash")?; + if summary.updated_at == 0 { + return Err(ToolError::new("updatedAt must be positive")); + } + Ok(()) +} + +fn validate_document_id(value: &str) -> Result<(), ToolError> { + let bytes = value.as_bytes(); + let canonical = bytes.len() == 36 + && bytes.iter().enumerate().all(|(index, byte)| match index { + 8 | 13 | 18 | 23 => *byte == b'-', + _ => byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase(), + }); + if !canonical { + return Err(ToolError::new("documentId must be a canonical UUID")); + } + Ok(()) +} + +fn validate_title(value: &str) -> Result<(), ToolError> { + if value.trim().is_empty() + || value.chars().count() > MAX_MOTION_DOCUMENT_TITLE_CHARS + || value.chars().any(char::is_control) + { + return Err(ToolError::new("title is invalid")); + } + Ok(()) +} + +fn validate_hash(value: &str, field: &str) -> Result<(), ToolError> { + validate_hash_value(value).map_err(|_| ToolError::new(format!("{field} is invalid"))) +} + +fn validate_hash_value(value: &str) -> Result<(), ()> { + if value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + Ok(()) + } else { + Err(()) + } +} + +fn validate_safe_id(value: &str, field: &str) -> Result<(), ToolError> { + if value.is_empty() + || value.len() > 128 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) + { + return Err(ToolError::new(format!("{field} is invalid"))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ID: &str = "2b9c865b-cd8d-4d8f-b3bb-455cf3bf5c55"; + const HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + #[test] + fn motion_document_tools_are_typed_and_bounded() { + const { assert!(MAX_MOTION_DOCUMENT_SOURCE_BYTES <= 1024 * 1024) }; + assert_eq!(MotionDocumentTool::ALL.len(), 6); + } + + #[test] + fn patch_requires_hash_and_rejects_path_like_file_names() { + let missing = decode_request( + ToolName::PatchMotionDocument, + &serde_json::json!({ + "documentId": ID, + "file": "index.html", + "edits": [{"start": 0, "end": 0, "replacement": "x"}] + }), + ); + assert!(missing.is_err()); + for file in ["../index.html", "/tmp/index.html", "link/styles.css"] { + let result = decode_request( + ToolName::PatchMotionDocument, + &serde_json::json!({ + "documentId": ID, + "file": file, + "baselineHash": HASH, + "edits": [{"start": 0, "end": 0, "replacement": "x"}] + }), + ); + assert!(result.is_err(), "accepted {file}"); + } + } + + #[test] + fn preview_and_publish_are_strictly_bounded() { + for (width, height) in [(1, 1080), (1920, 1)] { + let undersized = decode_request( + ToolName::PreviewMotionDocument, + &serde_json::json!({ + "documentId": ID, "revisionHash": HASH, + "width": width, "height": height, "fps": 60, + "durationFrames": 120, "frame": 0 + }), + ); + assert!(undersized.is_err()); + } + let preview = decode_request( + ToolName::PreviewMotionDocument, + &serde_json::json!({ + "documentId": ID, "revisionHash": HASH, + "width": 8192, "height": 1080, "fps": 60, + "durationFrames": 120, "frame": 0 + }), + ); + assert!(preview.is_err()); + let excessive_duration = decode_request( + ToolName::PreviewMotionDocument, + &serde_json::json!({ + "documentId": ID, "revisionHash": HASH, + "width": 1920, "height": 1080, "fps": 60, + "durationFrames": 3601, "frame": 0 + }), + ); + assert!(excessive_duration.is_err()); + let publish = decode_request( + ToolName::PublishMotionDocument, + &serde_json::json!({ + "documentId": ID, "revisionHash": HASH, + "width": 1920, "height": 1080, "fps": 60, + "durationFrames": 120, "startFrame": 0, "clipId": "both" + }), + ); + assert!(publish.is_err()); + } + + #[test] + fn conflict_is_structured_and_non_error() { + struct Conflict; + impl AdmittedMotionDocumentOperation for Conflict { + fn execute( + self: Box, + _cancel: &opentake_media::MediaCancelToken, + ) -> Result { + Err(MotionDocumentBridgeError::conflict(Some(HASH.into()))) + } + } + let result = result_from_operation( + ToolName::PatchMotionDocument, + Box::new(Conflict), + &opentake_media::MediaCancelToken::new(), + ); + assert!(!result.is_error); + assert!(result.text_joined().contains("\"status\":\"conflict\"")); + assert!(result.text_joined().contains(HASH)); + } + + #[test] + fn model_results_have_no_filesystem_path_surface() { + let response = MotionDocumentResponse::Document(MotionDocument { + summary: MotionDocumentSummary { + document_id: ID.into(), + title: "Title".into(), + revision_hash: HASH.into(), + updated_at: 1, + }, + html: "
真实字符
".into(), + css: "main { color: white; }".into(), + parameters: BTreeMap::new(), + }); + let text = model_safe_response(response).text_joined(); + assert!(text.contains("\"documentId\"")); + assert!(!text.contains("/tmp")); + assert!(!text.contains("projectPath")); + assert!(!text.contains("directory")); + } +} diff --git a/crates/opentake-agent/src/mcp/server.rs b/crates/opentake-agent/src/mcp/server.rs index d65f95d0..454360dd 100644 --- a/crates/opentake-agent/src/mcp/server.rs +++ b/crates/opentake-agent/src/mcp/server.rs @@ -13,6 +13,7 @@ //! - [`serve`] binds the loopback listener and runs the server. use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; use std::fmt::Write as _; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -81,11 +82,15 @@ enum DispatchAuthority { } impl DispatchAuthority { - fn try_enter(&self) -> Result, McpError> { + fn try_enter( + &self, + request_cancel: opentake_media::MediaCancelToken, + client: Option, + ) -> Result, McpError> { match self { Self::Direct => Ok(None), Self::Gated { activity, .. } => activity - .try_enter() + .try_enter(request_cancel, client) .map(Some) .ok_or_else(turn_inactive_error), } @@ -123,6 +128,13 @@ struct DispatchActivity { struct DispatchActivityState { accepting: bool, active: usize, + invalidated: HashSet, + requests: Vec, +} + +struct ActiveDispatch { + client: Option, + cancel: opentake_media::MediaCancelToken, } impl DispatchActivity { @@ -131,22 +143,37 @@ impl DispatchActivity { state: Mutex::new(DispatchActivityState { accepting: true, active: 0, + invalidated: HashSet::new(), + requests: Vec::new(), }), changed: tokio::sync::Notify::new(), }) } - fn try_enter(self: &Arc) -> Option { + fn try_enter( + self: &Arc, + request_cancel: opentake_media::MediaCancelToken, + client: Option, + ) -> Option { let mut state = self .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if !state.accepting { + if !state.accepting + || client + .as_ref() + .is_some_and(|client| state.invalidated.contains(client)) + { return None; } state.active = state.active.saturating_add(1); + state.requests.push(ActiveDispatch { + client, + cancel: request_cancel.clone(), + }); Some(DispatchPermit { activity: self.clone(), + request_cancel, }) } @@ -161,6 +188,24 @@ impl DispatchActivity { } } + /// Stop admission and synchronously cancel every admitted request-local + /// worker. This is independent of the host's optional whole-turn + /// cancellation hook, so managed endpoints drain even for gates that leave + /// that hook at its no-op default. + fn stop_and_cancel(&self) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.accepting = false; + for request in &state.requests { + request.cancel.cancel(); + } + if state.active == 0 { + self.changed.notify_one(); + } + } + async fn wait_zero(&self) { loop { let changed = self.changed.notified(); @@ -176,6 +221,51 @@ impl DispatchActivity { } } + fn invalidate_client(&self, client: &AuthenticatedMcpClient) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.invalidated.insert(client.clone()); + for request in &state.requests { + if request.client.as_ref() == Some(client) { + request.cancel.cancel(); + } + } + if !state + .requests + .iter() + .any(|request| request.client.as_ref() == Some(client)) + { + self.changed.notify_waiters(); + } + } + + fn restore_client(&self, client: &AuthenticatedMcpClient) { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .invalidated + .remove(client); + } + + async fn wait_client_zero(&self, client: &AuthenticatedMcpClient) { + loop { + let changed = self.changed.notified(); + let active = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .requests + .iter() + .any(|request| request.client.as_ref() == Some(client)); + if !active { + return; + } + changed.await; + } + } + #[cfg(test)] fn active(&self) -> usize { self.state @@ -187,6 +277,7 @@ impl DispatchActivity { struct DispatchPermit { activity: Arc, + request_cancel: opentake_media::MediaCancelToken, } impl Drop for DispatchPermit { @@ -197,9 +288,14 @@ impl Drop for DispatchPermit { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); state.active = state.active.saturating_sub(1); - if state.active == 0 { - self.activity.changed.notify_one(); + if let Some(index) = state + .requests + .iter() + .position(|tracked| tracked.cancel.same_instance(&self.request_cancel)) + { + state.requests.swap_remove(index); } + self.activity.changed.notify_waiters(); } } @@ -423,6 +519,7 @@ impl McpServer { name: String, args: Value, request_cancelled: CancellationToken, + client: Option, ) -> Result { if request_cancelled.is_cancelled() && matches!(&self.authority, DispatchAuthority::Gated { .. }) @@ -433,10 +530,10 @@ impl McpServer { let admission_permit = self .admission .try_enter(dispatch_admission_class(&name, &args))?; - let permit = self.authority.try_enter()?; + let cancel = opentake_media::MediaCancelToken::new(); + let permit = self.authority.try_enter(cancel.clone(), client)?; let dispatcher = self.dispatcher.clone(); let authority = self.authority.clone(); - let cancel = opentake_media::MediaCancelToken::new(); let worker_cancel = cancel.clone(); let worker_authority = authority.clone(); let mut worker = tokio::task::spawn_blocking(move || { @@ -498,7 +595,12 @@ impl ServerHandler for McpServer { // rmcp cancels `context.ct` for the protocol's explicit // `notifications/cancelled`. This does not claim raw TCP disconnect // detection; it is the MCP cancellation semantic exposed by rmcp. - self.dispatch_tool(name, args, context.ct).await + let client = context + .extensions + .get::() + .and_then(|parts| parts.extensions.get::()) + .cloned(); + self.dispatch_tool(name, args, context.ct, client).await } } @@ -612,44 +714,227 @@ async fn localhost_guard( } } -fn bearer_token_matches(headers: &axum::http::HeaderMap, expected: &str) -> bool { +/// The external client authenticated for an MCP HTTP request. Credential +/// generations distinguish a freshly regenerated long-lived credential from a +/// prior credential for the same client identity. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct AuthenticatedMcpClient { + pub client_id: Arc, + pub credential_generation: u64, +} + +/// Resolves a syntactically valid bearer candidate without retaining or +/// reporting its secret value. Implementations are responsible for comparing +/// their active credentials in constant time and returning the matching client. +pub trait BearerAuthorizer: Send + Sync { + fn authorize(&self, token: &str) -> Option; +} + +struct SingleBearerAuthorizer { + token: Arc, + client: AuthenticatedMcpClient, +} + +impl SingleBearerAuthorizer { + fn new(token: Arc) -> Self { + Self { + token, + client: AuthenticatedMcpClient { + client_id: Arc::from("ephemeral"), + credential_generation: 0, + }, + } + } +} + +impl BearerAuthorizer for SingleBearerAuthorizer { + fn authorize(&self, candidate: &str) -> Option { + (candidate.len() == self.token.len() + && bool::from(candidate.as_bytes().ct_eq(self.token.as_bytes()))) + .then(|| self.client.clone()) + } +} + +fn bearer_candidate(headers: &axum::http::HeaderMap) -> Option<&str> { let mut values = headers.get_all(axum::http::header::AUTHORIZATION).iter(); - let Some(value) = values.next() else { - return false; - }; + let value = values.next()?; if values.next().is_some() { - return false; + return None; } let Ok(value) = value.to_str() else { - return false; + return None; }; - let Some((scheme, supplied)) = value.split_once(' ') else { - return false; - }; - scheme.eq_ignore_ascii_case("bearer") - && supplied.len() == expected.len() - && bool::from(supplied.as_bytes().ct_eq(expected.as_bytes())) + let (scheme, supplied) = value.split_once(' ')?; + (scheme.eq_ignore_ascii_case("bearer") + && !supplied.is_empty() + && !supplied.chars().any(char::is_whitespace)) + .then_some(supplied) } -/// Authenticate every route on a per-turn endpoint before any MCP session is -/// created. The fixed-size token is compared in constant time after its public -/// length and scheme have been validated. -async fn ephemeral_bearer_guard( - axum::extract::State(expected): axum::extract::State>, - request: axum::extract::Request, - next: axum::middleware::Next, -) -> axum::response::Response { +fn authentication_required() -> axum::response::Response { use axum::response::IntoResponse; - if bearer_token_matches(request.headers(), &expected) { - next.run(request).await + ( + axum::http::StatusCode::UNAUTHORIZED, + [(axum::http::header::WWW_AUTHENTICATE, "Bearer")], + "OpenTake MCP authentication required", + ) + .into_response() +} + +/// Authenticate every route before any MCP session is created. This boundary +/// parses the bearer syntax once, delegates credential matching, and adds only +/// the authenticated public identity to the request extensions. +#[derive(Clone)] +struct ManagedAuthorizationState { + authorizer: Arc, + sessions: Option>, +} + +struct ManagedClientSessions { + state: Mutex, + manager: Arc, +} + +#[derive(Default)] +struct ManagedClientSessionsState { + owners: HashMap, AuthenticatedMcpClient>, + invalidated: HashSet, +} + +impl ManagedClientSessions { + fn new( + manager: Arc, + ) -> Arc { + Arc::new(Self { + state: Mutex::new(ManagedClientSessionsState::default()), + manager, + }) + } + + fn permits(&self, session_id: &str, client: &AuthenticatedMcpClient) -> bool { + let state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + !state.invalidated.contains(client) + && state + .owners + .get(session_id) + .is_some_and(|owner| owner == client) + } + + fn bind(&self, session_id: Arc, client: AuthenticatedMcpClient) -> bool { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.invalidated.contains(&client) { + return false; + } + state.owners.insert(session_id, client); + true + } + + fn remove(&self, session_id: &str) { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .owners + .remove(session_id); + } + + fn invalidate(&self, client: &AuthenticatedMcpClient) -> Vec> { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.invalidated.insert(client.clone()); + let sessions = state + .owners + .iter() + .filter(|(_, owner)| *owner == client) + .map(|(session, _)| session.clone()) + .collect::>(); + for session in &sessions { + state.owners.remove(session); + } + sessions + } + + fn restore(&self, client: &AuthenticatedMcpClient) { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .invalidated + .remove(client); + } + + async fn close(&self, session_id: &Arc) -> Result<(), String> { + use rmcp::transport::streamable_http_server::session::SessionManager as _; + self.manager + .close_session(session_id) + .await + .map_err(|error| error.to_string()) + } +} + +fn unknown_managed_session() -> axum::response::Response { + use axum::response::IntoResponse; + ( + axum::http::StatusCode::NOT_FOUND, + "Not Found: Session not found", + ) + .into_response() +} + +async fn bearer_authorization_guard( + axum::extract::State(state): axum::extract::State, + mut request: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + let Some(client) = + bearer_candidate(request.headers()).and_then(|token| state.authorizer.authorize(token)) + else { + return authentication_required(); + }; + let session_id = request + .headers() + .get("mcp-session-id") + .and_then(|value| value.to_str().ok()) + .map(Arc::::from); + if let (Some(sessions), Some(session_id)) = (&state.sessions, &session_id) { + if !sessions.permits(session_id, &client) { + return unknown_managed_session(); + } + } + let deleting = request.method() == axum::http::Method::DELETE; + request.extensions_mut().insert(client.clone()); + let response = next.run(request).await; + let Some(sessions) = state.sessions else { + return response; + }; + if let Some(session_id) = session_id { + if (deleting && response.status().is_success()) + || response.status() == axum::http::StatusCode::NOT_FOUND + { + sessions.remove(&session_id); + } + return response; + } + let Some(session_id) = response + .headers() + .get("mcp-session-id") + .and_then(|value| value.to_str().ok()) + .map(Arc::::from) + else { + return response; + }; + if sessions.bind(session_id.clone(), client) { + response } else { - ( - axum::http::StatusCode::UNAUTHORIZED, - [(axum::http::header::WWW_AUTHENTICATE, "Bearer")], - "OpenTake MCP authentication required", - ) - .into_response() + let _ = sessions.close(&session_id).await; + authentication_required() } } @@ -927,14 +1212,18 @@ pub fn build_router_with_all_capability_bridges_for_port( )) } +struct GatedRouterTransport { + shutdown: CancellationToken, + expected_port: u16, + authorization: Option, +} + fn build_gated_router_for_port( dispatcher: Arc, instructions: String, gate: Arc, activity: Arc, - shutdown: CancellationToken, - expected_port: u16, - bearer_token: Option>, + transport: GatedRouterTransport, ) -> axum::Router { use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; use rmcp::transport::streamable_http_server::{ @@ -944,8 +1233,17 @@ fn build_gated_router_for_port( use tower_http::limit::RequestBodyLimitLayer; let mut config = StreamableHttpServerConfig::default(); - config.cancellation_token = shutdown; + config.cancellation_token = transport.shutdown; let admission = DispatchAdmission::new(); + let session_manager = transport.authorization.as_ref().map_or_else( + || Arc::new(LocalSessionManager::default()), + |authorization| { + authorization.sessions.as_ref().map_or_else( + || Arc::new(LocalSessionManager::default()), + |sessions| sessions.manager.clone(), + ) + }, + ); let service = StreamableHttpService::new( move || { Ok(McpServer::from_gated_dispatcher( @@ -956,7 +1254,7 @@ fn build_gated_router_for_port( admission.clone(), )) }, - Arc::new(LocalSessionManager::default()), + session_manager, config, ); let service = ServiceBuilder::new() @@ -973,13 +1271,13 @@ fn build_gated_router_for_port( .layer(axum::middleware::from_fn(content_type_guard)) .layer(axum::middleware::from_fn(protocol_version_guard)) .layer(axum::middleware::from_fn_with_state( - expected_port, + transport.expected_port, localhost_guard, )); - match bearer_token { - Some(token) => router.layer(axum::middleware::from_fn_with_state( - token, - ephemeral_bearer_guard, + match transport.authorization { + Some(authorization) => router.layer(axum::middleware::from_fn_with_state( + authorization, + bearer_authorization_guard, )), None => router, } @@ -1084,60 +1382,278 @@ impl Drop for EphemeralMcpEndpoint { } } -/// Bind a per-turn project-authorized MCP server on a fresh IPv4 loopback port. -pub async fn bind_ephemeral_gated( - dispatcher: Arc, - registry: Arc>, - gate: Arc, -) -> Result { - bind_ephemeral_gated_on( - SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), - dispatcher, - registry, - gate, - ) - .await +#[derive(Debug, thiserror::Error)] +pub enum ManagedMcpError { + #[error("could not use the managed OpenTake MCP listener")] + Bind(#[source] std::io::Error), + #[error("the managed OpenTake MCP endpoint failed")] + Serve(#[source] std::io::Error), + #[error("the managed OpenTake MCP endpoint task failed")] + Join, } -async fn bind_ephemeral_gated_on( +/// A long-lived, externally authorized MCP endpoint. The authorizer is queried +/// for every request, so credential revocation and regeneration take effect +/// without restarting the listener. +#[must_use = "the endpoint must be shut down and awaited before release"] +pub struct ManagedMcpEndpoint { addr: SocketAddr, + shutdown: CancellationToken, + activity: Arc, + cancel_gate: Arc, + client_sessions: Arc, + join: Option>>, + closed: bool, +} + +impl ManagedMcpEndpoint { + pub fn addr(&self) -> SocketAddr { + self.addr + } + + /// Stop admission and transport sessions. Call [`Self::wait`] afterwards to + /// wait for admitted blocking work and the listener task to finish. + pub fn shutdown(&self) { + self.activity.stop_and_cancel(); + self.cancel_gate.request_cancel(); + self.shutdown.cancel(); + } + + /// Invalidate one exact credential generation, terminate only its rmcp + /// sessions, cancel only its admitted dispatch workers, and await their + /// drain without interrupting unrelated clients or listener admission. + pub async fn cancel_client( + &self, + client: &AuthenticatedMcpClient, + ) -> Result<(), ManagedMcpError> { + self.activity.invalidate_client(client); + let sessions = self.client_sessions.invalidate(client); + self.activity.wait_client_zero(client).await; + for session in sessions { + self.client_sessions + .close(&session) + .await + .map_err(|error| ManagedMcpError::Serve(std::io::Error::other(error)))?; + } + Ok(()) + } + + /// Re-admit a generation when durable credential mutation failed before + /// publication. Its prior sessions remain terminated; fresh ones may start. + pub fn restore_client(&self, client: &AuthenticatedMcpClient) { + self.client_sessions.restore(client); + self.activity.restore_client(client); + } + + /// Complete shutdown safely after [`Self::shutdown`] has stopped admission. + pub async fn wait(mut self) -> Result<(), ManagedMcpError> { + self.shutdown(); + self.activity.wait_zero().await; + let result = match self.join.as_mut() { + Some(join) => match join.await { + Ok(Ok(())) => Ok(()), + Ok(Err(error)) => Err(ManagedMcpError::Serve(error)), + Err(error) => { + tracing::error!( + target: "opentake::mcp::private", + task_cancelled = error.is_cancelled(), + task_panic = error.is_panic(), + "managed MCP listener task failed" + ); + Err(ManagedMcpError::Join) + } + }, + None => Err(ManagedMcpError::Join), + }; + self.join.take(); + self.closed = true; + result + } +} + +impl Drop for ManagedMcpEndpoint { + fn drop(&mut self) { + if self.closed { + return; + } + self.shutdown(); + if let Some(join) = self.join.take() { + reap_managed_listener(self.activity.clone(), join); + } + } +} + +async fn drain_managed_listener( + activity: Arc, + join: tokio::task::JoinHandle>, +) { + activity.wait_zero().await; + if let Err(error) = join.await { + tracing::error!( + target: "opentake::mcp::private", + task_cancelled = error.is_cancelled(), + task_panic = error.is_panic(), + "managed MCP listener reaper task failed" + ); + } +} + +/// Preserve the managed drain invariant even when its owner is dropped from a +/// synchronous context after the originating Tokio runtime has ended. +fn reap_managed_listener( + activity: Arc, + join: tokio::task::JoinHandle>, +) { + match tokio::runtime::Handle::try_current() { + Ok(handle) => { + handle.spawn(drain_managed_listener(activity, join)); + } + Err(_) => { + let spawn = std::thread::Builder::new() + .name("opentake-mcp-reaper".to_owned()) + .spawn(move || { + match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime.block_on(drain_managed_listener(activity, join)), + Err(error) => tracing::error!( + target: "opentake::mcp::private", + %error, + "could not start managed MCP listener reaper runtime" + ), + } + }); + if let Err(error) = spawn { + tracing::error!( + target: "opentake::mcp::private", + %error, + "could not start managed MCP listener reaper thread" + ); + } + } + } +} + +/// Serve a long-lived externally authorized MCP endpoint on a caller-bound +/// loopback listener. Passing the listener directly makes bind behavior +/// deterministic for integration tests and lets the Tauri shell own port +/// selection without duplicating transport setup. +pub async fn bind_managed_gated_on( + listener: tokio::net::TcpListener, dispatcher: Arc, registry: Arc>, gate: Arc, -) -> Result { - if !addr.ip().is_loopback() { - return Err(EphemeralMcpError::Bind(std::io::Error::new( + authorizer: Arc, +) -> Result { + let bound_addr = listener.local_addr().map_err(ManagedMcpError::Bind)?; + if !bound_addr.ip().is_loopback() { + return Err(ManagedMcpError::Bind(std::io::Error::new( std::io::ErrorKind::InvalidInput, - "private MCP endpoint requires a loopback address", + "managed MCP endpoint requires a loopback address", ))); } - let listener = tokio::net::TcpListener::bind(addr) - .await - .map_err(EphemeralMcpError::Bind)?; - let bound_addr = listener.local_addr().map_err(EphemeralMcpError::Bind)?; - let mut secret = [0_u8; 32]; - getrandom::fill(&mut secret).map_err(EphemeralMcpError::Entropy)?; - let mut encoded_secret = String::with_capacity(secret.len() * 2); - for byte in secret { - write!(&mut encoded_secret, "{byte:02x}").expect("writing to a String cannot fail"); - } - let bearer_token: Arc = encoded_secret.into(); let instructions = registry .read() .map(|registry| assemble_system_prompt(®istry, "default")) .unwrap_or_default(); let activity = DispatchActivity::new(); let shutdown = CancellationToken::new(); - let stopped = CancellationToken::new(); + let session_manager = Arc::new( + rmcp::transport::streamable_http_server::session::local::LocalSessionManager::default(), + ); + let client_sessions = ManagedClientSessions::new(session_manager); let cancel_gate = gate.clone(); let router = build_gated_router_for_port( dispatcher, instructions, gate, activity.clone(), - shutdown.clone(), - bound_addr.port(), - Some(bearer_token.clone()), + GatedRouterTransport { + shutdown: shutdown.clone(), + expected_port: bound_addr.port(), + authorization: Some(ManagedAuthorizationState { + authorizer, + sessions: Some(client_sessions.clone()), + }), + }, + ); + let listener_shutdown = shutdown.clone(); + let join = tokio::spawn(async move { + axum::serve(listener, router) + .with_graceful_shutdown(listener_shutdown.cancelled_owned()) + .await + }); + Ok(ManagedMcpEndpoint { + addr: bound_addr, + shutdown, + activity, + cancel_gate, + client_sessions, + join: Some(join), + closed: false, + }) +} + +/// Bind a per-turn project-authorized MCP server on a fresh IPv4 loopback port. +pub async fn bind_ephemeral_gated( + dispatcher: Arc, + registry: Arc>, + gate: Arc, +) -> Result { + bind_ephemeral_gated_on( + SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), + dispatcher, + registry, + gate, + ) + .await +} + +async fn bind_ephemeral_gated_on( + addr: SocketAddr, + dispatcher: Arc, + registry: Arc>, + gate: Arc, +) -> Result { + if !addr.ip().is_loopback() { + return Err(EphemeralMcpError::Bind(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "private MCP endpoint requires a loopback address", + ))); + } + let listener = tokio::net::TcpListener::bind(addr) + .await + .map_err(EphemeralMcpError::Bind)?; + let bound_addr = listener.local_addr().map_err(EphemeralMcpError::Bind)?; + let mut secret = [0_u8; 32]; + getrandom::fill(&mut secret).map_err(EphemeralMcpError::Entropy)?; + let mut encoded_secret = String::with_capacity(secret.len() * 2); + for byte in secret { + write!(&mut encoded_secret, "{byte:02x}").expect("writing to a String cannot fail"); + } + let bearer_token: Arc = encoded_secret.into(); + let instructions = registry + .read() + .map(|registry| assemble_system_prompt(®istry, "default")) + .unwrap_or_default(); + let activity = DispatchActivity::new(); + let shutdown = CancellationToken::new(); + let stopped = CancellationToken::new(); + let cancel_gate = gate.clone(); + let router = build_gated_router_for_port( + dispatcher, + instructions, + gate, + activity.clone(), + GatedRouterTransport { + shutdown: shutdown.clone(), + expected_port: bound_addr.port(), + authorization: Some(ManagedAuthorizationState { + authorizer: Arc::new(SingleBearerAuthorizer::new(bearer_token.clone())), + sessions: None, + }), + }, ); let listener_shutdown = shutdown.clone(); let listener_stopped = stopped.clone(); @@ -1187,9 +1703,11 @@ pub async fn serve_gated_dispatcher( instructions, gate, DispatchActivity::new(), - CancellationToken::new(), - bound_addr.port(), - None, + GatedRouterTransport { + shutdown: CancellationToken::new(), + expected_port: bound_addr.port(), + authorization: None, + }, ); tracing::info!("MCP server listening on http://{bound_addr}/mcp"); axum::serve(listener, router).await @@ -1288,6 +1806,24 @@ mod tests { use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Condvar; + struct CatalogMotionDocumentBridge; + + impl crate::mcp::motion_documents::MotionDocumentBridge for CatalogMotionDocumentBridge { + fn can_edit_motion_documents(&self) -> bool { + true + } + + fn admit( + &self, + _request: crate::mcp::motion_documents::MotionDocumentRequest, + ) -> Result< + Box, + crate::mcp::motion_documents::MotionDocumentBridgeError, + > { + unreachable!("catalog inspection never admits an operation") + } + } + struct TestHandle { core: AppCore, } @@ -1322,6 +1858,49 @@ mod tests { McpServer::new(Arc::new(TestHandle::new()), registry) } + #[test] + fn motion_document_bridge_registers_exact_server_schemas() { + let registry = Arc::new(RwLock::new(PluginRegistry::with_builtins())); + let dispatcher = Arc::new( + Dispatcher::new(Arc::new(TestHandle::new()), registry) + .with_motion_document_bridge(Some(Arc::new(CatalogMotionDocumentBridge))), + ); + let server = McpServer::from_gated_dispatcher( + dispatcher, + String::new(), + Arc::new(CountingGate::new(true)), + DispatchActivity::new(), + DispatchAdmission::new(), + ); + let tools = server.tools(); + for expected in ToolName::MOTION_DOCUMENTS { + assert!( + tools + .iter() + .any(|tool| tool.name.as_ref() == expected.as_str()), + "missing {}", + expected.as_str() + ); + } + let patch = tools + .iter() + .find(|tool| tool.name.as_ref() == "patch_motion_document") + .expect("patch schema"); + assert_eq!( + patch.input_schema.get("additionalProperties"), + Some(&Value::Bool(false)) + ); + assert_eq!( + patch.input_schema.get("required"), + Some(&serde_json::json!([ + "documentId", + "file", + "baselineHash", + "edits" + ])) + ); + } + struct CountingGate { dispatches: AtomicUsize, cancellations: AtomicUsize, @@ -1433,6 +2012,54 @@ mod tests { } } + struct RequestTokenOnlyGate { + entered: Mutex>>, + } + + impl RequestTokenOnlyGate { + fn new(entered: tokio::sync::oneshot::Sender<()>) -> Self { + Self { + entered: Mutex::new(Some(entered)), + } + } + } + + impl ChatTurnGate for RequestTokenOnlyGate { + fn timeline(&self, dispatcher: &Dispatcher) -> Option { + Some(dispatcher.timeline()) + } + + fn dispatch( + &self, + _dispatcher: &Dispatcher, + _name: &str, + _args: Value, + ) -> Option { + panic!("managed requests must use the request-local cancellation path") + } + + fn dispatch_cancellable( + &self, + _dispatcher: &Dispatcher, + _name: &str, + _args: Value, + request_cancel: &opentake_media::MediaCancelToken, + ) -> Option { + if let Some(entered) = self + .entered + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + { + let _ = entered.send(()); + } + while !request_cancel.is_cancelled() { + std::thread::yield_now(); + } + Some(ToolResult::ok("request cancelled")) + } + } + struct RecordingBlockingGate { blocked_name: Option<&'static str>, entered: tokio::sync::mpsc::UnboundedSender, @@ -1511,6 +2138,151 @@ mod tests { McpServer::from_gated_dispatcher(dispatcher, String::new(), gate, activity, admission) } + struct TestBearerAuthorizer { + credentials: RwLock>, + } + + impl TestBearerAuthorizer { + fn with_credential(token: &str, client_id: &str, credential_generation: u64) -> Self { + Self { + credentials: RwLock::new(vec![( + (token).to_owned(), + AuthenticatedMcpClient { + client_id: Arc::from(client_id), + credential_generation, + }, + )]), + } + } + + fn replace_credential(&self, token: &str, client_id: &str, credential_generation: u64) { + *self + .credentials + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = vec![( + token.to_owned(), + AuthenticatedMcpClient { + client_id: Arc::from(client_id), + credential_generation, + }, + )]; + } + + fn revoke_all(&self) { + self.credentials + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); + } + } + + impl BearerAuthorizer for TestBearerAuthorizer { + fn authorize(&self, candidate: &str) -> Option { + self.credentials + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .find_map(|(token, client)| { + (token.len() == candidate.len() + && bool::from(token.as_bytes().ct_eq(candidate.as_bytes()))) + .then(|| client.clone()) + }) + } + } + + #[derive(Clone, Default)] + struct CapturingSubscriber { + events: Arc>>, + } + + impl tracing::Subscriber for CapturingSubscriber { + fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool { + true + } + + fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::span::Id { + tracing::span::Id::from_u64(1) + } + + fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {} + + fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {} + + fn event(&self, event: &tracing::Event<'_>) { + struct Visitor<'a>(&'a mut String); + + impl tracing::field::Visit for Visitor<'_> { + fn record_debug( + &mut self, + field: &tracing::field::Field, + value: &dyn std::fmt::Debug, + ) { + let _ = write!(self.0, " {}={value:?}", field.name()); + } + } + + let mut recorded = event.metadata().name().to_owned(); + event.record(&mut Visitor(&mut recorded)); + self.events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(recorded); + } + + fn enter(&self, _span: &tracing::span::Id) {} + + fn exit(&self, _span: &tracing::span::Id) {} + + fn register_callsite( + &self, + _metadata: &'static tracing::Metadata<'static>, + ) -> tracing::subscriber::Interest { + tracing::subscriber::Interest::always() + } + + fn max_level_hint(&self) -> Option { + Some(tracing::level_filters::LevelFilter::TRACE) + } + } + + fn managed_fixture() -> ( + Arc, + Arc>, + Arc, + ) { + let registry = Arc::new(RwLock::new(PluginRegistry::with_builtins())); + let dispatcher = Arc::new(Dispatcher::new( + Arc::new(TestHandle::new()), + registry.clone(), + )); + (dispatcher, registry, Arc::new(CountingGate::new(true))) + } + + fn initialize_body() -> Value { + serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": { "name": "managed-test", "version": "0" } + } + }) + } + + async fn bind_managed_test_endpoint( + authorizer: Arc, + ) -> ManagedMcpEndpoint { + let (dispatcher, registry, gate) = managed_fixture(); + let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind managed test listener"); + bind_managed_gated_on(listener, dispatcher, registry, gate, authorizer) + .await + .expect("bind managed endpoint") + } + #[test] fn lists_every_advertised_tool() { let server = server(); @@ -1615,6 +2387,7 @@ mod tests { "get_timeline".into(), serde_json::json!({}), CancellationToken::new(), + None, ) .await .expect("authorized gate result"); @@ -1627,6 +2400,7 @@ mod tests { "get_timeline".into(), serde_json::json!({}), CancellationToken::new(), + None, ) .await .expect_err("stale gate must fail closed"); @@ -1647,7 +2421,12 @@ mod tests { let worker_cancel = request_cancel.clone(); let task = tokio::spawn(async move { worker_server - .dispatch_tool("get_timeline".into(), serde_json::json!({}), worker_cancel) + .dispatch_tool( + "get_timeline".into(), + serde_json::json!({}), + worker_cancel, + None, + ) .await }); @@ -1668,10 +2447,14 @@ mod tests { #[tokio::test] async fn stopping_admission_rejects_new_calls_and_waits_for_active_permit() { let activity = DispatchActivity::new(); - let permit = activity.try_enter().expect("first dispatch admitted"); + let permit = activity + .try_enter(opentake_media::MediaCancelToken::new(), None) + .expect("first dispatch admitted"); activity.stop_accepting(); assert!( - activity.try_enter().is_none(), + activity + .try_enter(opentake_media::MediaCancelToken::new(), None) + .is_none(), "new dispatch must be rejected" ); @@ -1725,6 +2508,7 @@ mod tests { "get_timeline".into(), serde_json::json!({}), CancellationToken::new(), + None, ) .await }); @@ -1734,6 +2518,7 @@ mod tests { "get_media".into(), serde_json::json!({}), CancellationToken::new(), + None, ) .await }); @@ -1750,6 +2535,7 @@ mod tests { "list_folders".into(), serde_json::json!({}), CancellationToken::new(), + None, ), ) .await @@ -1805,6 +2591,7 @@ mod tests { "add_clips".into(), serde_json::json!({}), CancellationToken::new(), + None, ) .await }); @@ -1819,6 +2606,7 @@ mod tests { "remove_clips".into(), serde_json::json!({}), CancellationToken::new(), + None, ) .await .expect_err("a second mutation must fail busy"); @@ -1831,6 +2619,7 @@ mod tests { "get_timeline".into(), serde_json::json!({}), CancellationToken::new(), + None, ) .await .expect("read admitted beside mutation"); @@ -2053,6 +2842,582 @@ mod tests { second.close().await.expect("close second endpoint"); } + #[tokio::test] + async fn managed_authentication_failures_have_one_redacted_public_shape() { + let valid = "managed-valid-credential"; + let wrong = "managed-wrong-credential"; + let authorizer = Arc::new(TestBearerAuthorizer::with_credential(valid, "external", 7)); + let endpoint = bind_managed_test_endpoint(authorizer.clone()).await; + let client = reqwest::Client::new(); + + let missing = client + .post(format!("http://{}/mcp", endpoint.addr())) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body()) + .send() + .await + .expect("send missing credential"); + let wrong = client + .post(format!("http://{}/mcp", endpoint.addr())) + .bearer_auth(wrong) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body()) + .send() + .await + .expect("send wrong credential"); + let malformed = client + .post(format!("http://{}/mcp", endpoint.addr())) + .header("authorization", format!("Bearer {valid} extra")) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body()) + .send() + .await + .expect("send malformed credential"); + authorizer.revoke_all(); + let revoked = client + .post(format!("http://{}/mcp", endpoint.addr())) + .bearer_auth(valid) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body()) + .send() + .await + .expect("send revoked credential"); + + let mut shapes = Vec::new(); + for response in [missing, wrong, malformed, revoked] { + let status = response.status(); + let www_authenticate = response.headers().get("www-authenticate").cloned(); + let body = response.text().await.expect("read authentication response"); + assert!( + !body.contains(valid), + "authentication response leaked valid credential" + ); + assert!( + !body.contains("managed-wrong-credential"), + "authentication response leaked candidate credential" + ); + shapes.push((status, www_authenticate, body)); + } + assert!(shapes.iter().all(|shape| shape == &shapes[0])); + assert_eq!(shapes[0].0, reqwest::StatusCode::UNAUTHORIZED); + + endpoint.shutdown(); + endpoint.wait().await.expect("stop managed endpoint"); + } + + #[tokio::test] + async fn managed_authorizer_observes_regeneration_before_new_initialize() { + let old_token = "managed-generation-one"; + let new_token = "managed-generation-two"; + let authorizer = Arc::new(TestBearerAuthorizer::with_credential( + old_token, "external", 1, + )); + let endpoint = bind_managed_test_endpoint(authorizer.clone()).await; + let client = reqwest::Client::new(); + authorizer.replace_credential(new_token, "external", 2); + + let old = client + .post(format!("http://{}/mcp", endpoint.addr())) + .bearer_auth(old_token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body()) + .send() + .await + .expect("send old credential"); + assert_eq!(old.status(), reqwest::StatusCode::UNAUTHORIZED); + + let regenerated = client + .post(format!("http://{}/mcp", endpoint.addr())) + .bearer_auth(new_token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body()) + .send() + .await + .expect("send regenerated credential"); + assert!(regenerated.status().is_success()); + + endpoint.shutdown(); + endpoint.wait().await.expect("stop managed endpoint"); + } + + #[tokio::test] + async fn managed_session_is_owned_by_the_authenticated_client_generation() { + let authorizer = Arc::new(TestBearerAuthorizer { + credentials: RwLock::new(vec![ + ( + "client-a-token".to_owned(), + AuthenticatedMcpClient { + client_id: Arc::from("client-a"), + credential_generation: 1, + }, + ), + ( + "client-b-token".to_owned(), + AuthenticatedMcpClient { + client_id: Arc::from("client-b"), + credential_generation: 1, + }, + ), + ]), + }); + let endpoint = bind_managed_test_endpoint(authorizer).await; + let client = reqwest::Client::new(); + let url = format!("http://{}/mcp", endpoint.addr()); + let initialized = client + .post(&url) + .bearer_auth("client-a-token") + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body()) + .send() + .await + .expect("initialize client A session"); + let session = initialized + .headers() + .get("mcp-session-id") + .expect("client A session id") + .clone(); + + let foreign = client + .post(&url) + .bearer_auth("client-b-token") + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", session.clone()) + .header("mcp-protocol-version", "2025-06-18") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + })) + .send() + .await + .expect("attempt foreign session reuse"); + assert_eq!(foreign.status(), reqwest::StatusCode::NOT_FOUND); + + let owner = client + .post(&url) + .bearer_auth("client-a-token") + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", session) + .header("mcp-protocol-version", "2025-06-18") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/list", + "params": {} + })) + .send() + .await + .expect("use owned session"); + assert!(owner.status().is_success()); + + endpoint.shutdown(); + endpoint.wait().await.expect("stop managed endpoint"); + } + + #[tokio::test] + async fn managed_cancel_client_terminates_only_target_sessions() { + let authorizer = Arc::new(TestBearerAuthorizer { + credentials: RwLock::new(vec![ + ( + "target-token".to_owned(), + AuthenticatedMcpClient { + client_id: Arc::from("target"), + credential_generation: 1, + }, + ), + ( + "survivor-token".to_owned(), + AuthenticatedMcpClient { + client_id: Arc::from("survivor"), + credential_generation: 1, + }, + ), + ]), + }); + let endpoint = bind_managed_test_endpoint(authorizer).await; + let client = reqwest::Client::new(); + let url = format!("http://{}/mcp", endpoint.addr()); + let mut sessions = Vec::new(); + for token in ["target-token", "survivor-token"] { + let response = client + .post(&url) + .bearer_auth(token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body()) + .send() + .await + .expect("initialize managed session"); + sessions.push( + response + .headers() + .get("mcp-session-id") + .expect("managed session id") + .clone(), + ); + } + + endpoint + .cancel_client(&AuthenticatedMcpClient { + client_id: Arc::from("target"), + credential_generation: 1, + }) + .await + .expect("cancel target client"); + + for (token, session, expected) in [ + ("target-token", &sessions[0], reqwest::StatusCode::NOT_FOUND), + ("survivor-token", &sessions[1], reqwest::StatusCode::OK), + ] { + let response = client + .post(&url) + .bearer_auth(token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", session.clone()) + .header("mcp-protocol-version", "2025-06-18") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + })) + .send() + .await + .expect("use managed session after selective cancellation"); + assert_eq!(response.status(), expected); + } + + endpoint.shutdown(); + endpoint.wait().await.expect("stop managed endpoint"); + } + + #[tokio::test] + async fn managed_endpoint_keeps_loopback_origin_and_host_guards() { + let token = "managed-loopback-credential"; + let endpoint = bind_managed_test_endpoint(Arc::new(TestBearerAuthorizer::with_credential( + token, "external", 1, + ))) + .await; + let client = reqwest::Client::new(); + let url = format!("http://{}/mcp", endpoint.addr()); + + let remote_host = client + .post(&url) + .bearer_auth(token) + .header( + "host", + format!("attacker.example:{}", endpoint.addr().port()), + ) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body()) + .send() + .await + .expect("send remote host"); + assert_eq!(remote_host.status(), reqwest::StatusCode::FORBIDDEN); + + let remote_origin = client + .post(&url) + .bearer_auth(token) + .header( + "origin", + format!("http://attacker.example:{}", endpoint.addr().port()), + ) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body()) + .send() + .await + .expect("send remote origin"); + assert_eq!(remote_origin.status(), reqwest::StatusCode::FORBIDDEN); + + let loopback_origin = client + .post(&url) + .bearer_auth(token) + .header( + "origin", + format!("http://127.0.0.1:{}", endpoint.addr().port()), + ) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body()) + .send() + .await + .expect("send loopback origin"); + assert!(loopback_origin.status().is_success()); + + endpoint.shutdown(); + endpoint.wait().await.expect("stop managed endpoint"); + } + + #[tokio::test] + async fn managed_shutdown_stops_listener_admission_and_workers() { + let token = "managed-shutdown-credential"; + let registry = Arc::new(RwLock::new(PluginRegistry::with_builtins())); + let dispatcher = Arc::new(Dispatcher::new( + Arc::new(TestHandle::new()), + registry.clone(), + )); + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel(); + let gate = Arc::new(BlockingGate::new(entered_tx, cancel_tx)); + let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind managed shutdown listener"); + let endpoint = bind_managed_gated_on( + listener, + dispatcher, + registry, + gate.clone(), + Arc::new(TestBearerAuthorizer::with_credential(token, "external", 1)), + ) + .await + .expect("bind managed endpoint"); + let addr = endpoint.addr(); + let client = reqwest::Client::new(); + let url = format!("http://{addr}/mcp"); + let initialized = client + .post(&url) + .bearer_auth(token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body()) + .send() + .await + .expect("initialize managed session"); + let session = initialized + .headers() + .get("mcp-session-id") + .expect("stateful managed session") + .clone(); + let call_client = client.clone(); + let call_url = url.clone(); + let call = tokio::spawn(async move { + call_client + .post(call_url) + .bearer_auth(token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", session) + .header("mcp-protocol-version", "2025-06-18") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { "name": "get_timeline", "arguments": {} } + })) + .send() + .await + }); + entered_rx + .await + .expect("blocking managed worker entered gate"); + endpoint.shutdown(); + cancel_rx + .await + .expect("managed shutdown requested gate cancellation"); + let mut stopped = tokio::spawn(async move { endpoint.wait().await }); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(20), &mut stopped) + .await + .is_err(), + "managed shutdown returned before its admitted worker finished" + ); + gate.release(); + let _ = call.await.expect("managed call joined"); + stopped + .await + .expect("managed endpoint task joined") + .expect("stop managed endpoint"); + assert!(tokio::net::TcpStream::connect(addr).await.is_err()); + } + + #[tokio::test] + async fn managed_shutdown_cancels_request_local_workers_when_gate_cancel_is_noop() { + let token = "managed-request-token-credential"; + let registry = Arc::new(RwLock::new(PluginRegistry::with_builtins())); + let dispatcher = Arc::new(Dispatcher::new( + Arc::new(TestHandle::new()), + registry.clone(), + )); + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let gate = Arc::new(RequestTokenOnlyGate::new(entered_tx)); + let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind request-token managed listener"); + let endpoint = bind_managed_gated_on( + listener, + dispatcher, + registry, + gate, + Arc::new(TestBearerAuthorizer::with_credential(token, "external", 1)), + ) + .await + .expect("bind managed endpoint"); + let client = reqwest::Client::new(); + let url = format!("http://{}/mcp", endpoint.addr()); + let initialized = client + .post(&url) + .bearer_auth(token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body()) + .send() + .await + .expect("initialize request-token session"); + let session = initialized + .headers() + .get("mcp-session-id") + .expect("stateful request-token session") + .clone(); + let call_client = client.clone(); + let call_url = url.clone(); + let call = tokio::spawn(async move { + call_client + .post(call_url) + .bearer_auth(token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", session) + .header("mcp-protocol-version", "2025-06-18") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { "name": "get_timeline", "arguments": {} } + })) + .send() + .await + }); + entered_rx + .await + .expect("request-local worker entered no-op gate"); + + endpoint.shutdown(); + tokio::time::timeout(std::time::Duration::from_secs(5), endpoint.wait()) + .await + .expect("managed shutdown must not wait for a no-op gate") + .expect("managed endpoint stopped"); + let response = call + .await + .expect("request-local call joined") + .expect("request-local call completed"); + assert!(response.status().is_success()); + } + + #[test] + fn dropping_managed_endpoint_after_its_runtime_stops_does_not_panic() { + let endpoint = { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build endpoint runtime"); + runtime.block_on(async { + bind_managed_test_endpoint(Arc::new(TestBearerAuthorizer::with_credential( + "managed-drop-credential", + "external", + 1, + ))) + .await + }) + }; + + drop(endpoint); + } + + #[tokio::test] + async fn managed_authorization_attaches_client_identity_to_request_extensions() { + use axum::extract::Extension; + use tower::ServiceExt as _; + + async fn identity(Extension(client): Extension) -> String { + format!("{}:{}", client.client_id, client.credential_generation) + } + + let router = axum::Router::new() + .route("/identity", axum::routing::get(identity)) + .layer(axum::middleware::from_fn_with_state( + ManagedAuthorizationState { + authorizer: Arc::new(TestBearerAuthorizer::with_credential( + "extension-token", + "external", + 9, + )), + sessions: None, + }, + bearer_authorization_guard, + )); + let response = router + .oneshot( + axum::http::Request::builder() + .uri("/identity") + .header("authorization", "Bearer extension-token") + .body(axum::body::Body::empty()) + .expect("identity request"), + ) + .await + .expect("identity response"); + assert_eq!(response.status(), axum::http::StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), 1024) + .await + .expect("read identity response"); + assert_eq!(&body[..], b"external:9"); + } + + #[test] + fn bearer_authorization_never_records_candidate_tokens() { + use tower::ServiceExt as _; + + let router = axum::Router::new() + .route("/", axum::routing::get(|| async { "authorized" })) + .layer(axum::middleware::from_fn_with_state( + ManagedAuthorizationState { + authorizer: Arc::new(TestBearerAuthorizer::with_credential( + "active-token", + "external", + 1, + )), + sessions: None, + }, + bearer_authorization_guard, + )); + let subscriber = CapturingSubscriber::default(); + let events = subscriber.events.clone(); + let dispatch = tracing::Dispatch::new(subscriber); + tracing::dispatcher::with_default(&dispatch, || { + for candidate in ["wrong-candidate-a", "wrong-candidate-b"] { + let response = futures::executor::block_on( + router.clone().oneshot( + axum::http::Request::builder() + .uri("/") + .header("authorization", format!("Bearer {candidate}")) + .body(axum::body::Body::empty()) + .expect("authorization request"), + ), + ) + .expect("authorization response"); + assert_eq!(response.status(), axum::http::StatusCode::UNAUTHORIZED); + } + }); + let captured = events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .join("\n"); + assert!(!captured.contains("wrong-candidate-a")); + assert!(!captured.contains("wrong-candidate-b")); + } + #[test] fn host_guard_accepts_local_rejects_remote() { assert!(host_is_local("127.0.0.1:19789", MCP_PORT)); diff --git a/crates/opentake-agent/src/tools/descriptions.rs b/crates/opentake-agent/src/tools/descriptions.rs index df810189..62f5aa98 100644 --- a/crates/opentake-agent/src/tools/descriptions.rs +++ b/crates/opentake-agent/src/tools/descriptions.rs @@ -110,6 +110,18 @@ pub fn description(tool: ToolName) -> &'static str { ToolName::EditMotionGraphic => "Re-renders an existing OpenTake motion graphic as one durable undoable workflow while preserving its timeline clipId and placement. Pass the clipId and either replacement self-contained HTML/CSS/JS for a code-authored graphic or parameter overrides for a template-authored graphic. Ordinary video clips and unsupported source types are rejected with typed errors.", + ToolName::ListMotionDocuments => "Lists the current project's Motion Studio documents as bounded summaries with documentId, title, revisionHash, and updatedAt. No filesystem paths are exposed.", + + ToolName::ReadMotionDocument => "Reads one exact Motion Studio document revision, including its real index.html, styles.css, parameters, and revisionHash. Call this before patching and use the returned revisionHash as baselineHash.", + + ToolName::CreateMotionDocument => "Creates one project-confined Motion Studio document from the visible starter template and returns its complete HTML/CSS plus revisionHash. The title is optional.", + + ToolName::PatchMotionDocument => "Applies bounded UTF-8 byte-range replacements to exactly index.html or styles.css. baselineHash is mandatory; a stale baseline returns a structured conflict without changing either file. Read the document again and explicitly reapply the intended change after a conflict.", + + ToolName::PreviewMotionDocument => "Renders one bounded frame from the exact saved Motion Studio revision with the production Chromium renderer. Returns a PNG and diagnostics; it never changes the document or timeline.", + + ToolName::PublishMotionDocument => "Publishes the exact saved Motion Studio revision through the production Chromium/FFmpeg atomic timeline path. Omit clipId and provide startFrame to add a clip; provide clipId and omit startFrame to replace that existing Motion clip. Returns committed clip/media ids and source revision.", + ToolName::TrackMotion => "Analyzes a bounded source region and returns editable position keyframes that follow the subject. Defaults to preview-only; set apply=true only after reviewing confidence and samples. Applying is one undoable edit. The tool is advertised only when a production tracking backend is available.", ToolName::GenerateMatte => "Generates a frame-aligned reusable alpha matte for one clip without modifying the source asset. Defaults to preview-only and reports model/version/progress metadata. Applying the matte is one undoable edit. The tool is advertised only when an installed compatible model is available.", ToolName::RemoveObject => "Produces a non-destructive derivative for the selected mask and frame range. Defaults to preview-only; provider costs require costAuthorized=true. Apply imports and swaps the reviewed derivative as one undoable workflow. Cancellation or failure leaves media and timeline unchanged.", @@ -724,6 +736,89 @@ pub fn input_schema(tool: ToolName) -> Value { &["clipId"], ), + ToolName::ListMotionDocuments => object(json!({}), &[]), + + ToolName::ReadMotionDocument => object( + json!({ + "documentId": {"type": "string", "format": "uuid", "description": "Motion Studio document id from list_motion_documents."} + }), + &["documentId"], + ), + + ToolName::CreateMotionDocument => object( + json!({ + "title": {"type": "string", "minLength": 1, "maxLength": 120, "description": "Optional visible document title."} + }), + &[], + ), + + ToolName::PatchMotionDocument => object( + json!({ + "documentId": {"type": "string", "format": "uuid"}, + "file": {"type": "string", "enum": ["index.html", "styles.css"]}, + "baselineHash": {"type": "string", "pattern": "^[0-9a-f]{64}$", "description": "Exact revisionHash returned by read_motion_document."}, + "edits": { + "type": "array", + "minItems": 1, + "maxItems": 256, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "start": {"type": "integer", "minimum": 0, "description": "UTF-8 byte offset, inclusive."}, + "end": {"type": "integer", "minimum": 0, "description": "UTF-8 byte offset, exclusive."}, + "replacement": {"type": "string"} + }, + "required": ["start", "end", "replacement"] + } + } + }), + &["documentId", "file", "baselineHash", "edits"], + ), + + ToolName::PreviewMotionDocument => object( + json!({ + "documentId": {"type": "string", "format": "uuid"}, + "revisionHash": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "width": {"type": "integer", "minimum": 2, "maximum": 4096}, + "height": {"type": "integer", "minimum": 2, "maximum": 4096}, + "fps": {"type": "integer", "minimum": 1, "maximum": 120}, + "durationFrames": {"type": "integer", "minimum": 1, "maximum": 3600}, + "frame": {"type": "integer", "minimum": 0} + }), + &[ + "documentId", + "revisionHash", + "width", + "height", + "fps", + "durationFrames", + "frame", + ], + ), + + ToolName::PublishMotionDocument => object( + json!({ + "documentId": {"type": "string", "format": "uuid"}, + "revisionHash": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "width": {"type": "integer", "minimum": 2, "maximum": 4096}, + "height": {"type": "integer", "minimum": 2, "maximum": 4096}, + "fps": {"type": "integer", "minimum": 1, "maximum": 120}, + "durationFrames": {"type": "integer", "minimum": 1, "maximum": 3600}, + "startFrame": {"type": "integer", "minimum": 0, "description": "Required for add; omit for edit."}, + "trackIndex": {"type": "integer", "minimum": 0, "description": "Optional existing visual track for add."}, + "clipId": {"type": "string", "description": "Existing Motion clip to replace; omit for add."} + }), + &[ + "documentId", + "revisionHash", + "width", + "height", + "fps", + "durationFrames", + ], + ), + ToolName::TrackMotion => object( json!({ "clipId": {"type": "string"}, diff --git a/crates/opentake-agent/src/tools/names.rs b/crates/opentake-agent/src/tools/names.rs index e6ab16ad..6f04c194 100644 --- a/crates/opentake-agent/src/tools/names.rs +++ b/crates/opentake-agent/src/tools/names.rs @@ -61,6 +61,13 @@ pub enum ToolName { // --- OpenTake Motion Canvas graphics (docs/MOTION-GRAPHICS-PLUGIN.md, Issue #34) --- AddMotionGraphic, EditMotionGraphic, + // --- Project-confined Motion Studio authoring --- + ListMotionDocuments, + ReadMotionDocument, + CreateMotionDocument, + PatchMotionDocument, + PreviewMotionDocument, + PublishMotionDocument, // --- Advanced AI workflows (capability-gated by the desktop host) --- TrackMotion, GenerateMatte, @@ -149,6 +156,12 @@ impl ToolName { ToolName::ApplyEffect => "apply_effect", ToolName::AddMotionGraphic => "add_motion_graphic", ToolName::EditMotionGraphic => "edit_motion_graphic", + ToolName::ListMotionDocuments => "list_motion_documents", + ToolName::ReadMotionDocument => "read_motion_document", + ToolName::CreateMotionDocument => "create_motion_document", + ToolName::PatchMotionDocument => "patch_motion_document", + ToolName::PreviewMotionDocument => "preview_motion_document", + ToolName::PublishMotionDocument => "publish_motion_document", ToolName::TrackMotion => "track_motion", ToolName::GenerateMatte => "generate_matte", ToolName::RemoveObject => "remove_object", @@ -220,6 +233,17 @@ impl ToolName { /// all other hosts. pub const MOTION: [ToolName; 2] = [ToolName::AddMotionGraphic, ToolName::EditMotionGraphic]; + /// Motion Studio document tools appended only while the host can capture + /// current-project authority and execute the typed document bridge. + pub const MOTION_DOCUMENTS: [ToolName; 6] = [ + ToolName::ListMotionDocuments, + ToolName::ReadMotionDocument, + ToolName::CreateMotionDocument, + ToolName::PatchMotionDocument, + ToolName::PreviewMotionDocument, + ToolName::PublishMotionDocument, + ]; + /// Vision-analysis tools appended only by a host with a live frame-sampling /// / saliency backend. They remain known for strict compatibility parsing /// in all other hosts. @@ -244,7 +268,7 @@ impl ToolName { /// hidden from discovery until a real backend exists. Keeping this set lets /// strict argument validation and compatibility tests cover future tools /// without advertising placeholder behavior to models. - pub const KNOWN: [ToolName; 54] = [ + pub const KNOWN: [ToolName; 60] = [ ToolName::GetTimeline, ToolName::GetMedia, ToolName::InspectMedia, @@ -290,6 +314,12 @@ impl ToolName { ToolName::ApplyEffect, ToolName::AddMotionGraphic, ToolName::EditMotionGraphic, + ToolName::ListMotionDocuments, + ToolName::ReadMotionDocument, + ToolName::CreateMotionDocument, + ToolName::PatchMotionDocument, + ToolName::PreviewMotionDocument, + ToolName::PublishMotionDocument, ToolName::TrackMotion, ToolName::GenerateMatte, ToolName::RemoveObject, @@ -358,9 +388,9 @@ mod tests { } #[test] - fn advertised_set_is_38_and_known_set_is_54() { + fn advertised_set_is_38_and_known_set_is_60() { assert_eq!(ToolName::ALL.len(), 38); - assert_eq!(ToolName::KNOWN.len(), 54); + assert_eq!(ToolName::KNOWN.len(), 60); assert!(ToolName::ALL .iter() .all(|tool| ToolName::KNOWN.contains(tool))); @@ -446,6 +476,24 @@ mod tests { assert!(!ToolName::UPSTREAM.contains(&ToolName::EditMotionGraphic)); } + #[test] + fn motion_document_tools_have_expected_wire_names() { + let expected = [ + (ToolName::ListMotionDocuments, "list_motion_documents"), + (ToolName::ReadMotionDocument, "read_motion_document"), + (ToolName::CreateMotionDocument, "create_motion_document"), + (ToolName::PatchMotionDocument, "patch_motion_document"), + (ToolName::PreviewMotionDocument, "preview_motion_document"), + (ToolName::PublishMotionDocument, "publish_motion_document"), + ]; + for (tool, wire) in expected { + assert_eq!(tool.as_str(), wire); + assert_eq!(ToolName::from_str(wire), Ok(tool)); + assert!(!ToolName::ALL.contains(&tool)); + assert!(!ToolName::UPSTREAM.contains(&tool)); + } + } + #[test] fn a_tier_effect_tools_have_expected_wire_names() { assert_eq!(ToolName::SetColorGrade.as_str(), "set_color_grade"); diff --git a/crates/opentake-agent/tests/advertised_tool_acceptance.rs b/crates/opentake-agent/tests/advertised_tool_acceptance.rs index 233c2a3c..b26ccbe5 100644 --- a/crates/opentake-agent/tests/advertised_tool_acceptance.rs +++ b/crates/opentake-agent/tests/advertised_tool_acceptance.rs @@ -52,6 +52,7 @@ impl MotionBridge for DeterministicMotionBridge { content_hash: PRIVATE_ADD_HASH.into(), action_name: "Add Motion Graphic".into(), output: output_metadata(PRIVATE_ADD_HASH), + source_document: None, }) } @@ -66,6 +67,7 @@ impl MotionBridge for DeterministicMotionBridge { content_hash: PRIVATE_EDIT_HASH.into(), action_name: "Edit Motion Graphic".into(), output: output_metadata(PRIVATE_EDIT_HASH), + source_document: None, }) } } diff --git a/crates/opentake-core/src/core.rs b/crates/opentake-core/src/core.rs index 3824f553..2867a171 100644 --- a/crates/opentake-core/src/core.rs +++ b/crates/opentake-core/src/core.rs @@ -30,14 +30,14 @@ use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, RwLock, RwLockReadGuard}; +use std::sync::{Arc, Mutex, MutexGuard, RwLock, RwLockReadGuard}; use opentake_domain::{ ClipType, GenerationInput, MediaAsset, MediaManifest, MediaManifestEntry, MediaProxy, Timeline, }; use opentake_ops::command::{ClipEntry, EditCommand, EditResult}; use opentake_ops::IdGen; -use opentake_project::{GenerationLog, ProjectCompatibility, ProjectRootIdentity}; +use opentake_project::{GenerationLog, ProjectCompatibility, ProjectRootIdentity, ThumbnailUpdate}; use same_file::Handle; use crate::deps::CoreDeps; @@ -364,6 +364,7 @@ impl DeferredCoreEvents { pub struct AppCore { session: Arc>, project_identity_workflow: Arc>, + project_bundle_publication: Arc>, project_identity_transition: Arc>>, events: EventBus, deps: Arc, @@ -393,6 +394,7 @@ impl AppCore { editor: EditorSession::new_project(), })), project_identity_workflow: Arc::new(RwLock::new(())), + project_bundle_publication: Arc::new(Mutex::new(())), project_identity_transition: Arc::new(Mutex::new(Vec::new())), events: EventBus::new(), deps: Arc::new(deps), @@ -534,6 +536,16 @@ impl AppCore { .unwrap_or_else(|poisoned| poisoned.into_inner()) } + /// Serialize publications that replace a complete project bundle with + /// project-local component commits. Both classes of writer must hold this + /// gate before they snapshot or publish bundle contents, otherwise a full + /// replacement can silently discard a just-published component revision. + pub fn lock_project_bundle_publication(&self) -> MutexGuard<'_, ()> { + self.project_bundle_publication + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + /// Register a synchronous project-identity transition hook. `true` runs /// immediately before replacement or Save As waits for the exclusive /// identity lease; `false` runs after the lease is released, on success or @@ -912,7 +924,13 @@ impl AppCore { path: Option, thumbnail: Option>, ) -> Result { - self.save_project_with_thumbnail_at_identity(None, None, path, thumbnail) + self.save_project_with_thumbnail_update_at_identity( + None, + None, + path, + thumbnail.map_or(ThumbnailUpdate::Preserve, ThumbnailUpdate::Replace), + || true, + ) } /// Save only if the project session still has the caller's exact identity. @@ -926,20 +944,59 @@ impl AppCore { path: Option, thumbnail: Option>, ) -> Result { - self.save_project_with_thumbnail_at_identity( + self.save_project_with_thumbnail_update_at_identity( + Some(expected_project_epoch), + expected_project_path, + path, + thumbnail.map_or(ThumbnailUpdate::Preserve, ThumbnailUpdate::Replace), + || true, + ) + } + + /// Save only if the project identity still matches, with an explicit + /// authoritative cover mutation. + pub fn save_project_with_thumbnail_update_for_project( + &self, + expected_project_epoch: u64, + expected_project_path: Option<&Path>, + path: Option, + thumbnail: ThumbnailUpdate, + ) -> Result { + self.save_project_with_thumbnail_update_at_identity( Some(expected_project_epoch), expected_project_path, path, thumbnail, + || true, ) } - fn save_project_with_thumbnail_at_identity( + /// Identity-bound explicit cover save whose final caller checkpoint runs + /// under the same session lock immediately before persistence begins. + pub fn save_project_with_thumbnail_update_for_project_if( + &self, + expected_project_epoch: u64, + expected_project_path: Option<&Path>, + path: Option, + thumbnail: ThumbnailUpdate, + can_commit: impl FnOnce() -> bool, + ) -> Result { + self.save_project_with_thumbnail_update_at_identity( + Some(expected_project_epoch), + expected_project_path, + path, + thumbnail, + can_commit, + ) + } + + fn save_project_with_thumbnail_update_at_identity( &self, expected_project_epoch: Option, expected_project_path: Option<&Path>, path: Option, - thumbnail: Option>, + thumbnail: ThumbnailUpdate, + can_commit: impl FnOnce() -> bool, ) -> Result { let changes_identity = path.is_some(); if changes_identity { @@ -956,10 +1013,14 @@ impl AppCore { || session.editor.project_dir() != expected_project_path }) { Err(CoreError::StaleProject) + } else if !can_commit() { + Err(CoreError::Unsupported( + "project cover save was cancelled before commit", + )) } else { session .editor - .save_project_with_thumbnail(path, thumbnail) + .save_project_with_thumbnail_update(path, thumbnail) .map(|written| (written, session.project_epoch)) } }; @@ -1110,6 +1171,7 @@ impl AppCore { mutate: impl FnOnce(&mut EditorSession, &dyn IdGen) -> Result, persist: impl FnOnce(&mut EditorSession) -> Result, ) -> Result { + let bundle_publication = self.lock_project_bundle_publication(); let (value, count, written) = { let mut session = self.lock(); ensure_project_identity(&session, expected_project_epoch, expected_project_dir)?; @@ -1127,6 +1189,9 @@ impl AppCore { } } }; + // Event subscribers may synchronously re-enter project component + // stores, so publication locks must be released before broadcasting. + drop(bundle_publication); self.events.emit(&CoreEvent::MediaChanged { project_epoch: expected_project_epoch, count, @@ -1279,6 +1344,40 @@ impl AppCore { ) } + /// Commit a project-managed Motion render while the caller holds the + /// complete-bundle publication gate. Events are queued so the caller can + /// first disarm any retained-file rollback guard, release the publication + /// gate, and only then notify synchronous subscribers. + #[allow(clippy::too_many_arguments)] + pub fn commit_motion_media_for_project_deferred( + &self, + publication: &MutexGuard<'_, ()>, + expected_project_epoch: u64, + expected_version: u64, + expected_project_dir: &Path, + path: impl AsRef, + name: impl Into, + probe: &ProbedMedia, + provenance: GenerationInput, + placement: MotionPlacement, + events: &mut DeferredCoreEvents, + ) -> Result { + self.commit_generated_media_for_project_deferred( + publication, + expected_project_epoch, + expected_version, + expected_project_dir, + path, + name, + ClipType::Video, + probe, + provenance, + placement, + "Add Motion Graphic", + events, + ) + } + /// Atomically register a completed generated audio/video file and place or /// replace its timeline clip. The generated file must already be a /// regular, non-symlink child of the active bundle's `media/` directory. @@ -1299,6 +1398,46 @@ impl AppCore { provenance: GenerationInput, placement: MotionPlacement, action_name: &str, + ) -> Result { + let publication = self.lock_project_bundle_publication(); + let mut events = DeferredCoreEvents::default(); + let commit = self.commit_generated_media_for_project_deferred( + &publication, + expected_project_epoch, + expected_version, + expected_project_dir, + path, + name, + kind, + probe, + provenance, + placement, + action_name, + &mut events, + )?; + drop(publication); + self.emit_deferred(events); + Ok(commit) + } + + /// Deferred-event form of [`Self::commit_generated_media_for_project`]. + /// The guard parameter makes the required serialization with complete + /// bundle replacement explicit at every call site. + #[allow(clippy::too_many_arguments)] + pub fn commit_generated_media_for_project_deferred( + &self, + _publication: &MutexGuard<'_, ()>, + expected_project_epoch: u64, + expected_version: u64, + expected_project_dir: &Path, + path: impl AsRef, + name: impl Into, + kind: ClipType, + probe: &ProbedMedia, + provenance: GenerationInput, + placement: MotionPlacement, + action_name: &str, + events: &mut DeferredCoreEvents, ) -> Result { let path = path.as_ref(); let media_dir = expected_project_dir.join(opentake_project::layout::MEDIA_DIR); @@ -1408,15 +1547,15 @@ impl AppCore { } }; - self.events.emit(&CoreEvent::TimelineChanged { + events.push(CoreEvent::TimelineChanged { project_epoch: expected_project_epoch, version: commit.edit.timeline_version, }); - self.events.emit(&CoreEvent::MediaChanged { + events.push(CoreEvent::MediaChanged { project_epoch: expected_project_epoch, count, }); - self.events.emit(&CoreEvent::ProjectSaved { + events.push(CoreEvent::ProjectSaved { path: written.to_string_lossy().into_owned(), project_epoch: expected_project_epoch, }); @@ -2384,6 +2523,278 @@ mod tests { worker.join().unwrap(); } + #[test] + fn bundle_publication_gate_blocks_complete_generation_replacement() { + let bundle = project_bundle("generation-publication-gate"); + let core = AppCore::new(); + core.open_project(&bundle).unwrap(); + let snapshot = core.runtime_snapshot(); + let publication = core.lock_project_bundle_publication(); + let replacement = core.clone(); + let destination = bundle.clone(); + let (started, entered) = std::sync::mpsc::channel(); + let (sent, received) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + started.send(()).unwrap(); + let result = replacement.persist_generation_mutation_using( + snapshot.project_epoch, + &destination, + |_editor, _ids| Ok(()), + |_editor| Ok(destination.clone()), + ); + sent.send(result).unwrap(); + }); + + entered + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap(); + assert!(received + .recv_timeout(std::time::Duration::from_millis(50)) + .is_err()); + drop(publication); + received + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("generation replacement proceeds after publication gate releases") + .unwrap(); + worker.join().unwrap(); + let _ = std::fs::remove_dir_all(bundle); + } + + #[test] + fn generation_events_reenter_bundle_publication_after_commit_without_deadlock() { + let bundle = project_bundle("generation-publication-reentry"); + let core = AppCore::new(); + core.open_project(&bundle).unwrap(); + let snapshot = core.runtime_snapshot(); + let reentrant = core.clone(); + let (event_sent, event_received) = std::sync::mpsc::channel(); + core.subscribe(move |event| { + if matches!(event, CoreEvent::ProjectSaved { .. }) { + let _publication = reentrant.lock_project_bundle_publication(); + event_sent.send(()).unwrap(); + } + }); + let worker_core = core.clone(); + let destination = bundle.clone(); + let (done_sent, done_received) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let result = worker_core.persist_generation_mutation_using( + snapshot.project_epoch, + &destination, + |_editor, _ids| Ok(()), + |_editor| Ok(destination.clone()), + ); + done_sent.send(result).unwrap(); + }); + + event_received + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("subscriber can re-enter publication after commit"); + done_received + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("generation publication completes") + .unwrap(); + worker.join().unwrap(); + let _ = std::fs::remove_dir_all(bundle); + } + + #[test] + fn motion_media_commit_defers_events_until_bundle_publication_releases() { + let bundle = project_bundle("motion-publication-events"); + let core = AppCore::new(); + core.open_project(&bundle).unwrap(); + let media_dir = opentake_project::layout::media_dir(&bundle); + std::fs::create_dir_all(&media_dir).unwrap(); + let rendered = media_dir.join("motion-deferred.mp4"); + std::fs::write(&rendered, b"validated-render-fixture").unwrap(); + let snapshot = core.runtime_snapshot(); + let reentrant = core.clone(); + let (event_sent, event_received) = std::sync::mpsc::channel(); + core.subscribe(move |event| { + if matches!(event, CoreEvent::ProjectSaved { .. }) { + let _publication = reentrant.lock_project_bundle_publication(); + event_sent.send(()).unwrap(); + } + }); + + let publication = core.lock_project_bundle_publication(); + let mut events = DeferredCoreEvents::default(); + core.commit_motion_media_for_project_deferred( + &publication, + snapshot.project_epoch, + snapshot.version, + &bundle, + &rendered, + "Motion Deferred", + &ProbedMedia::default(), + GenerationInput::default(), + MotionPlacement::Add { + start_frame: 0, + duration_frames: 1, + track_index: Some(0), + }, + &mut events, + ) + .unwrap(); + assert!(event_received.try_recv().is_err()); + drop(publication); + core.emit_deferred(events); + event_received + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("subscriber can re-enter publication after motion commit"); + + let _ = std::fs::remove_dir_all(bundle); + } + + #[test] + fn motion_publication_serializes_with_complete_bundle_replacement() { + let bundle = project_bundle("motion-complete-replacement"); + let core = AppCore::new(); + core.open_project(&bundle).unwrap(); + let media_dir = opentake_project::layout::media_dir(&bundle); + std::fs::create_dir_all(&media_dir).unwrap(); + let rendered = media_dir.join("motion-complete.mp4"); + let rendered_bytes = b"complete-motion-render"; + std::fs::write(&rendered, rendered_bytes).unwrap(); + let snapshot = core.runtime_snapshot(); + + let publication = core.lock_project_bundle_publication(); + let replacement = core.clone(); + let destination = bundle.clone(); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + started_tx.send(()).unwrap(); + let result = replacement.persist_generation_mutation_using( + snapshot.project_epoch, + &destination, + |_editor, _ids| Ok(()), + |editor| editor.save_generation_state(), + ); + done_tx.send(result).unwrap(); + }); + started_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap(); + + let current = core.runtime_snapshot(); + let mut events = DeferredCoreEvents::default(); + let committed = core + .commit_motion_media_for_project_deferred( + &publication, + current.project_epoch, + current.version, + &bundle, + &rendered, + "Motion Complete", + &ProbedMedia::default(), + GenerationInput::default(), + MotionPlacement::Add { + start_frame: 0, + duration_frames: 1, + track_index: Some(0), + }, + &mut events, + ) + .unwrap(); + assert!(done_rx + .recv_timeout(std::time::Duration::from_millis(50)) + .is_err()); + drop(publication); + core.emit_deferred(events); + done_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("complete replacement proceeds after motion publication") + .unwrap(); + worker.join().unwrap(); + + let reopened = AppCore::new(); + reopened.open_project(&bundle).unwrap(); + assert!(reopened + .media() + .entries + .iter() + .any(|entry| entry.id == committed.media.id)); + assert_eq!(std::fs::read(&rendered).unwrap(), rendered_bytes); + let _ = std::fs::remove_dir_all(bundle); + } + + #[test] + fn motion_publication_identity_lease_keeps_save_as_copy_complete() { + let bundle = project_bundle("motion-save-as-source"); + let destination = project_bundle("motion-save-as-target"); + let _ = std::fs::remove_dir_all(&destination); + let core = AppCore::new(); + core.open_project(&bundle).unwrap(); + let media_dir = opentake_project::layout::media_dir(&bundle); + std::fs::create_dir_all(&media_dir).unwrap(); + let rendered = media_dir.join("motion-save-as.mp4"); + let rendered_bytes = b"complete-before-save-as"; + std::fs::write(&rendered, rendered_bytes).unwrap(); + + let publication = core.lock_project_bundle_publication(); + let identity = core.lock_project_identity_workflow(); + let save_as_core = core.clone(); + let save_as_destination = destination.clone(); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + started_tx.send(()).unwrap(); + let result = save_as_core.save_project(Some(save_as_destination)); + done_tx.send(result).unwrap(); + }); + started_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap(); + + let snapshot = core.runtime_snapshot(); + let mut events = DeferredCoreEvents::default(); + let committed = core + .commit_motion_media_for_project_deferred( + &publication, + snapshot.project_epoch, + snapshot.version, + &bundle, + &rendered, + "Motion Save As", + &ProbedMedia::default(), + GenerationInput::default(), + MotionPlacement::Add { + start_frame: 0, + duration_frames: 1, + track_index: Some(0), + }, + &mut events, + ) + .unwrap(); + assert!(done_rx + .recv_timeout(std::time::Duration::from_millis(50)) + .is_err()); + assert!(!destination.exists()); + drop(identity); + drop(publication); + core.emit_deferred(events); + done_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("Save As proceeds after Motion releases identity") + .unwrap(); + worker.join().unwrap(); + + let reopened = AppCore::new(); + reopened.open_project(&destination).unwrap(); + assert!(reopened + .media() + .entries + .iter() + .any(|entry| entry.id == committed.media.id)); + assert_eq!( + std::fs::read(destination.join("media/motion-save-as.mp4")).unwrap(), + rendered_bytes + ); + let _ = std::fs::remove_dir_all(bundle); + let _ = std::fs::remove_dir_all(destination); + } + #[test] fn identity_bound_save_never_writes_a_replacement_project_to_the_old_request_target() { let first = project_bundle("save-identity-first"); @@ -2897,10 +3308,12 @@ mod tests { #[test] fn open_save_roundtrip_through_core_emits_lifecycle_events() { + static SAVE_ROUNDTRIP_SEQ: AtomicU64 = AtomicU64::new(0); let dir = std::env::temp_dir().join(format!( - "opentake-core-appcore-{}-{}.opentake", + "opentake-core-appcore-{}-{}-{}.opentake", std::process::id(), - line!() + line!(), + SAVE_ROUNDTRIP_SEQ.fetch_add(1, Ordering::Relaxed), )); let _ = std::fs::remove_dir_all(&dir); diff --git a/crates/opentake-core/src/events.rs b/crates/opentake-core/src/events.rs index fa318957..3ee887ae 100644 --- a/crates/opentake-core/src/events.rs +++ b/crates/opentake-core/src/events.rs @@ -153,7 +153,11 @@ impl EventBus { .map(|(_, listener)| Arc::clone(listener)) .collect(); for listener in listeners { - listener(event); + // Subscribers are application adapters, not part of the durable + // core transaction. One faulty listener must never turn an + // already-committed mutation into an apparent command failure or + // prevent later mirrors from receiving the same event. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| listener(event))); } } } @@ -203,6 +207,25 @@ mod tests { ); } + #[test] + fn panicking_subscriber_is_isolated_from_publishers_and_later_subscribers() { + let bus = EventBus::new(); + bus.subscribe(|_| panic!("subscriber failure")); + let delivered = Arc::new(Mutex::new(0_u32)); + let sink = Arc::clone(&delivered); + bus.subscribe(move |_| *sink.lock().unwrap() += 1); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + bus.emit(&CoreEvent::ProjectSaved { + path: "p".into(), + project_epoch: 1, + }); + })); + + assert!(result.is_ok()); + assert_eq!(*delivered.lock().unwrap(), 1); + } + #[test] fn unsubscribe_stops_delivery() { let bus = EventBus::new(); diff --git a/crates/opentake-core/src/session.rs b/crates/opentake-core/src/session.rs index 6e0faf5e..79f6bcf9 100644 --- a/crates/opentake-core/src/session.rs +++ b/crates/opentake-core/src/session.rs @@ -43,7 +43,7 @@ use opentake_ops::command::{self, EditCommand, EditResult}; use opentake_ops::{EditorState, IdGen}; use opentake_project::{ GenerationLog, GenerationLogEntry, Project, ProjectCompatibility, ProjectRoot, - ProjectRootIdentity, + ProjectRootIdentity, ThumbnailUpdate, }; use same_file::Handle; @@ -406,6 +406,18 @@ impl EditorSession { &mut self, path: Option, thumbnail: Option>, + ) -> Result { + self.save_project_with_thumbnail_update( + path, + thumbnail.map_or(ThumbnailUpdate::Preserve, ThumbnailUpdate::Replace), + ) + } + + /// Persist with an explicit authoritative cover mutation. + pub fn save_project_with_thumbnail_update( + &mut self, + path: Option, + thumbnail: ThumbnailUpdate, ) -> Result { self.ensure_mutable()?; // Remember the currently-open bundle before we adopt any new target, so @@ -432,9 +444,6 @@ impl EditorSession { Project::new_with_compatibility(target.clone(), self.compatibility.clone()); project.timeline = self.state.timeline.clone(); project.manifest = self.state.manifest.clone(); - // Cover image (upstream `snapshotThumbnail` → `thumbnail.jpg`): only set - // when the caller produced bytes; otherwise leave the on-disk cover as-is. - project.thumbnail = thumbnail; // Preserve an existing valid-but-empty optional component across // Save-As; otherwise only create the log once there are rows. if self.generation_log_component_present || !self.generation_log.entries.is_empty() { @@ -442,10 +451,14 @@ impl EditorSession { } let new_root = if same_target { let root = self.project_root.as_ref().ok_or(CoreError::NoProjectOpen)?; - project.save_to_root(root)?; + project.save_to_root_with_thumbnail_update(root, thumbnail)?; None } else { - Some(project.publish_complete_to(&target, self.project_root.as_ref())?) + Some(project.publish_complete_to_with_thumbnail_update( + &target, + self.project_root.as_ref(), + thumbnail, + )?) }; self.project_dir = Some(target.clone()); @@ -1974,6 +1987,24 @@ mod tests { assert_eq!(std::fs::read(dir.join("thumbnail.jpg")).unwrap(), jpeg); } + #[test] + fn explicit_thumbnail_remove_is_distinct_from_capture_failure_preserve() { + let tmp = TmpDir::new("thumb-remove"); + let dir = tmp.path().join("Remove.opentake"); + let mut session = EditorSession::new_project(); + session.state = EditorState::from_timeline(one_video_track()); + let jpeg = vec![0xFF, 0xD8, 4, 2, 0xFF, 0xD9]; + session + .save_project_with_thumbnail(Some(dir.clone()), Some(jpeg)) + .unwrap(); + + session + .save_project_with_thumbnail_update(None, ThumbnailUpdate::Remove) + .unwrap(); + + assert!(!dir.join("thumbnail.jpg").exists()); + } + #[test] fn save_as_with_no_source_media_dir_is_ok() { let tmp = TmpDir::new("nomedia"); diff --git a/crates/opentake-media/src/decode/frame.rs b/crates/opentake-media/src/decode/frame.rs index 169ece8c..4e1e4e22 100644 --- a/crates/opentake-media/src/decode/frame.rs +++ b/crates/opentake-media/src/decode/frame.rs @@ -10,6 +10,7 @@ //! ffmpeg invocation requires the binary and is covered by ignore-by-default //! integration tests. +use std::io::{Seek, SeekFrom}; use std::path::Path; use std::thread; use std::time::Duration; @@ -446,6 +447,25 @@ fn frame_args_with_color( args } +fn frame_args_for_input( + input: &str, + req: &FrameRequest, + color: Option<&opentake_domain::MediaColorMetadata>, +) -> Vec { + let mut args = frame_args_with_color(Path::new(input), req, color); + let seek_index = args + .iter() + .position(|argument| argument == "-ss") + .expect("frame args always contain seek"); + let seek = args.drain(seek_index..seek_index + 2).collect::>(); + let input_index = args + .iter() + .position(|argument| argument == "-i") + .expect("frame args always contain input"); + args.splice(input_index + 2..input_index + 2, seek); + args +} + /// Decode the frame at/after `req.time_secs`, returning `(actual_secs, frame)`. pub fn decode_frame_at(path: &Path, req: &FrameRequest) -> Result<(f64, RgbaFrame)> { decode_frame_at_cancellable(path, req, &MediaCancelToken::new()) @@ -558,6 +578,121 @@ pub fn decode_frame_at_cancellable( } } +/// Decode from an already-open regular file. The retained handle is cloned, +/// rewound, and becomes ffmpeg's stdin (`fd:`); no pathname fallback occurs. +pub fn decode_frame_file_at_cancellable( + file: &std::fs::File, + req: &FrameRequest, + cancel: &MediaCancelToken, +) -> Result<(f64, RgbaFrame)> { + if cancel.is_cancelled() { + return Err(MediaError::Cancelled); + } + let color = crate::probe::probe_file(file) + .ok() + .and_then(|probe| probe.color); + let mut input = file.try_clone()?; + input.seek(SeekFrom::Start(0))?; + let mut child = ff::ffmpeg() + .args(frame_args_for_input("fd:", req, color.as_ref())) + .spawn() + .map_err(|error| MediaError::Ffmpeg(format!("spawn: {error}")))?; + cancel.child_spawned(); + let mut stdin = child + .take_stdin() + .ok_or_else(|| MediaError::Ffmpeg("retained frame stdin missing".to_string()))?; + let feeder = thread::Builder::new() + .name("opentake-retained-frame-input".to_string()) + .spawn(move || std::io::copy(&mut input, &mut stdin)) + .map_err(MediaError::Io)?; + let result = decode_first_child_frame(&mut child, req.time_secs, cancel); + match feeder.join() { + Ok(Ok(_)) => result, + Ok(Err(error)) if error.kind() == std::io::ErrorKind::BrokenPipe => result, + Ok(Err(error)) => Err(MediaError::Io(error)), + Err(_) => Err(MediaError::Ffmpeg( + "retained frame input feeder panicked".to_string(), + )), + } +} + +fn decode_first_child_frame( + child: &mut ffmpeg_sidecar::child::FfmpegChild, + requested_time: f64, + cancel: &MediaCancelToken, +) -> Result<(f64, RgbaFrame)> { + let iter = match child.iter() { + Ok(iter) => iter, + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(MediaError::Ffmpeg(format!("iter: {error}"))); + } + }; + let reader_cancel = cancel.clone(); + let reader = match thread::Builder::new() + .name("opentake-retained-frame-events".to_string()) + .spawn(move || { + reader_cancel.reader_started(); + let result = iter + .filter_map(|event| match event { + FfmpegEvent::OutputFrame(frame) if frame.width > 0 && frame.height > 0 => { + Some(( + requested_time.max(frame.timestamp as f64), + RgbaFrame::new(frame.width, frame.height, frame.data), + )) + } + _ => None, + }) + .next(); + reader_cancel.reader_finished(); + result + }) { + Ok(reader) => reader, + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(MediaError::Ffmpeg(format!( + "spawn frame event reader: {error}" + ))); + } + }; + loop { + if cancel.checkpoint() { + let _ = child.kill(); + let _ = child.wait(); + let _ = reader.join(); + return Err(MediaError::Cancelled); + } + if reader.is_finished() { + let _ = child.kill(); + let _ = child.wait(); + let result = reader + .join() + .map_err(|_| MediaError::Ffmpeg("frame event reader panicked".to_string()))?; + return result + .ok_or_else(|| MediaError::Decode(format!("no frame at {requested_time:.3}s"))); + } + match child.as_inner_mut().try_wait() { + Ok(Some(_)) => { + let result = reader + .join() + .map_err(|_| MediaError::Ffmpeg("frame event reader panicked".to_string()))?; + return result.ok_or_else(|| { + MediaError::Decode(format!("no frame at {requested_time:.3}s")) + }); + } + Ok(None) => thread::sleep(FRAME_CHILD_POLL_INTERVAL), + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + let _ = reader.join(); + return Err(MediaError::Io(error)); + } + } + } +} + /// Decode one cancellable frame and encode it as PNG bytes without publishing /// a cache file. Project-scoped prewarm jobs stage these bytes and let their /// epoch guard perform the final atomic rename. diff --git a/crates/opentake-media/src/decode/mod.rs b/crates/opentake-media/src/decode/mod.rs index d0fe7653..f0e24cf9 100644 --- a/crates/opentake-media/src/decode/mod.rs +++ b/crates/opentake-media/src/decode/mod.rs @@ -8,9 +8,10 @@ pub mod stream; pub use audio_stream::{decode_pcm_interleaved, decode_pcm_interleaved_cancellable}; pub use frame::{ - convert_frame_rate, decode_frame_at, decode_frame_at_cancellable, decode_frames_at, - decode_frames_at_cancellable, fit_within, interpolate_frame_pair, FrameInterpolationFallback, - FrameInterpolationMode, FrameInterpolationResult, FrameRateSample, FrameRequest, + convert_frame_rate, decode_frame_at, decode_frame_at_cancellable, + decode_frame_file_at_cancellable, decode_frames_at, decode_frames_at_cancellable, fit_within, + interpolate_frame_pair, FrameInterpolationFallback, FrameInterpolationMode, + FrameInterpolationResult, FrameRateSample, FrameRequest, }; pub use pcm::{ extract_pcm, extract_pcm_cancellable, extract_pcm_cancellable_with_progress, PcmBuffer, diff --git a/crates/opentake-media/src/lib.rs b/crates/opentake-media/src/lib.rs index c65ed5e7..dbbd82c3 100644 --- a/crates/opentake-media/src/lib.rs +++ b/crates/opentake-media/src/lib.rs @@ -155,24 +155,29 @@ pub use frame::RgbaFrame; pub use color::{hdr_decode_input_args, hdr_tonemap_filter}; pub use probe::{parse_probe, probe, MediaProbe}; -pub use proxy::{create_proxy, file_sha256, ProxyProgressCallback, ProxyRequest, ProxyResult}; +pub use proxy::{ + create_proxy, file_sha256, file_sha256_file_cancellable, ProxyProgressCallback, ProxyRequest, + ProxyResult, +}; pub use decode::{ - convert_frame_rate, decode_frame_at, decode_frame_at_cancellable, decode_frames_at, - decode_frames_at_cancellable, decode_pcm_interleaved, decode_pcm_interleaved_cancellable, - extract_pcm, extract_pcm_cancellable, extract_pcm_cancellable_with_progress, - interpolate_frame_pair, FrameInterpolationFallback, FrameInterpolationMode, - FrameInterpolationResult, FrameRateSample, FrameRequest, PcmBuffer, PcmFormat, - PcmProgressCallback, PcmSpec, StreamDecodeControl, StreamVideoFrame, VideoStream, - VideoStreamRequest, DEFAULT_VIDEO_STREAM_QUEUE_CAPACITY, + convert_frame_rate, decode_frame_at, decode_frame_at_cancellable, + decode_frame_file_at_cancellable, decode_frames_at, decode_frames_at_cancellable, + decode_pcm_interleaved, decode_pcm_interleaved_cancellable, extract_pcm, + extract_pcm_cancellable, extract_pcm_cancellable_with_progress, interpolate_frame_pair, + FrameInterpolationFallback, FrameInterpolationMode, FrameInterpolationResult, FrameRateSample, + FrameRequest, PcmBuffer, PcmFormat, PcmProgressCallback, PcmSpec, StreamDecodeControl, + StreamVideoFrame, VideoStream, VideoStreamRequest, DEFAULT_VIDEO_STREAM_QUEUE_CAPACITY, }; pub use encode::{ExportPreset, ExportResolution, VideoCodec, VideoEncoder}; pub use thumbnail::{ - capture_project_thumbnail, image_thumbnail, pick_thumbnail_source, video_thumbnail_times, - video_thumbnails, PartialThumbCallback, ThumbnailCacheMeta, ThumbnailKind, ThumbnailSource, - VideoThumb, + capture_project_composite_thumbnail, capture_project_thumbnail, + encode_project_composite_thumbnail, image_thumbnail, pick_thumbnail_source, + representative_project_thumbnail_frame, video_thumbnail_times, video_thumbnails, + PartialThumbCallback, ProjectCompositeThumbnailSnapshot, ThumbnailCacheMeta, ThumbnailKind, + ThumbnailSource, VideoThumb, PROJECT_COMPOSITE_COVER_BOUNDS, }; pub use timecode::{parse_smpte_timecode, read_start_timecode_frame}; diff --git a/crates/opentake-media/src/proxy.rs b/crates/opentake-media/src/proxy.rs index c8f54c94..ad5e90fd 100644 --- a/crates/opentake-media/src/proxy.rs +++ b/crates/opentake-media/src/proxy.rs @@ -48,7 +48,7 @@ pub fn file_sha256(path: &Path) -> Result { file_sha256_file_cancellable(&file, &MediaCancelToken::new()) } -fn file_sha256_file_cancellable(file: &File, cancel: &MediaCancelToken) -> Result { +pub fn file_sha256_file_cancellable(file: &File, cancel: &MediaCancelToken) -> Result { let mut reader = BufReader::new(file.try_clone()?); reader.seek(SeekFrom::Start(0))?; let mut hasher = Sha256::new(); diff --git a/crates/opentake-media/src/thumbnail/mod.rs b/crates/opentake-media/src/thumbnail/mod.rs index 0c8a7ca3..3f788006 100644 --- a/crates/opentake-media/src/thumbnail/mod.rs +++ b/crates/opentake-media/src/thumbnail/mod.rs @@ -9,7 +9,10 @@ pub mod project; pub mod sprite; pub use project::{ - capture_project_thumbnail, pick_thumbnail_source, ThumbnailKind, ThumbnailSource, + capture_project_composite_thumbnail, capture_project_thumbnail, + encode_project_composite_thumbnail, pick_thumbnail_source, + representative_project_thumbnail_frame, ProjectCompositeThumbnailSnapshot, ThumbnailKind, + ThumbnailSource, PROJECT_COMPOSITE_COVER_BOUNDS, }; pub use sprite::{ encode_sprite, load_sprite, save_sprite, EncodedSpriteArtifact, ThumbnailCacheMeta, VideoThumb, diff --git a/crates/opentake-media/src/thumbnail/project.rs b/crates/opentake-media/src/thumbnail/project.rs index aaa3fd14..fcffd5a2 100644 --- a/crates/opentake-media/src/thumbnail/project.rs +++ b/crates/opentake-media/src/thumbnail/project.rs @@ -1,21 +1,20 @@ //! Project cover thumbnail — the representative-frame capture written into a -//! bundle's `thumbnail.jpg` on save. 1:1 port of upstream -//! `VideoProject.captureThumbnail` (`Project/VideoProject.swift:261-300`). +//! bundle's `thumbnail.jpg` on save. //! -//! Upstream walks `timeline.tracks where track.type == .video`, then each clip -//! in order, and returns the first frame it can grab: +//! Upstream `VideoProject.captureThumbnail` walks +//! `timeline.tracks where track.type == .video`, then each clip in order, and +//! returns the first frame it can grab: //! - an **image** clip → `ImageEncoder.thumbnail(url, maxPixelSize: 640)` → //! `encodeJPEG(quality: 0.7)`; //! - a **video** clip → `AVAssetImageGenerator` (`maximumSize = 320×180`, //! `appliesPreferredTrackTransform`) seeked to //! `CMTime(value: clip.trimStartFrame, timescale: fps)` → JPEG `quality 0.7`. //! -//! The **pick** ([`pick_thumbnail_source`]) is a pure function over the timeline -//! and manifest (resolvable-file filter lives here because the media layer, -//! unlike `opentake-domain`, may touch the filesystem), so the track/clip -//! selection rule is unit-testable without ffmpeg. The **capture** -//! ([`capture_project_thumbnail`]) decodes and JPEG-encodes and therefore needs -//! ffmpeg / a real image file. +//! OpenTake retains that source-only path for compatibility, but Home covers use +//! [`capture_project_composite_thumbnail`]: the desktop renderer supplies the +//! same composited RGBA frame used by preview/export, and this module applies +//! the deterministic 16:9 cover policy plus JPEG encoding. The media layer does +//! not own a second renderer. use std::path::{Path, PathBuf}; @@ -33,6 +32,9 @@ pub const IMAGE_COVER_MAX_PIXEL: u32 = 640; /// `generator.maximumSize = CGSize(width: 320, height: 180)`. pub const VIDEO_COVER_MAX_SIZE: (u32, u32) = (320, 180); +/// Default bounded 16:9 surface for an authoritative project composite. +pub const PROJECT_COMPOSITE_COVER_BOUNDS: (u32, u32) = (640, 360); + /// Seek tolerance (seconds) for the video cover grab. Upstream's /// `AVAssetImageGenerator` uses its default tolerances (not zero); a modest /// window keeps the grab cheap and reliably lands a decodable frame near the @@ -44,6 +46,27 @@ pub const VIDEO_COVER_TOLERANCE_SECS: f64 = 1.0; /// hardcoded) per the media-layer "no magic thresholds" rule. pub const PROJECT_THUMB_JPEG_QUALITY: u8 = 72; +/// Borrowed state needed to validate/select a representative composite without +/// coupling the media crate to the desktop renderer. +#[derive(Clone, Copy, Debug)] +pub struct ProjectCompositeThumbnailSnapshot<'a> { + timeline: &'a Timeline, + project_base: Option<&'a Path>, +} + +impl<'a> ProjectCompositeThumbnailSnapshot<'a> { + pub fn new(timeline: &'a Timeline, project_base: Option<&'a Path>) -> Self { + Self { + timeline, + project_base, + } + } + + pub fn representative_frame(self, manifest: &MediaManifest) -> Option { + representative_project_thumbnail_frame(self.timeline, manifest, self.project_base) + } +} + /// Which decode path a picked clip needs. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ThumbnailKind { @@ -130,6 +153,119 @@ pub fn capture_project_thumbnail( encode_source(&source, fps).ok() } +/// Choose a deterministic frame inside the first visible, resolvable visual +/// clip. A valid outgoing cross-dissolve prefers its midpoint so a cover can +/// truthfully represent both sides of an authored transition; otherwise the +/// clip midpoint avoids unstable half-open boundaries. +pub fn representative_project_thumbnail_frame( + timeline: &Timeline, + manifest: &MediaManifest, + project_base: Option<&Path>, +) -> Option { + let resolver = MediaResolver::new(manifest, project_base); + for track in timeline.tracks.iter().filter(|track| !track.hidden) { + for (index, clip) in track.clips.iter().enumerate() { + if !clip_is_compositable(clip, timeline, &resolver) || clip.duration_frames <= 0 { + continue; + } + if let (Some(transition), Some(incoming)) = + (clip.transition_out.as_ref(), track.clips.get(index + 1)) + { + let transition_matches = transition.kind + == opentake_domain::TransitionKind::CrossDissolve + && (transition.from_clip_id.is_empty() || transition.from_clip_id == clip.id) + && transition.to_clip_id == incoming.id + && incoming.start_frame == clip.end_frame() + && incoming.duration_frames > 0 + && clip_is_compositable(incoming, timeline, &resolver); + if transition_matches { + let duration = transition + .duration_frames + .max(1) + .min(clip.duration_frames) + .min(incoming.duration_frames); + let transition_start = clip.end_frame() - duration; + return Some((transition_start + duration / 2).min(clip.end_frame() - 1)); + } + } + return Some(clip.start_frame + (clip.duration_frames - 1) / 2); + } + } + None +} + +fn clip_is_compositable( + clip: &opentake_domain::Clip, + timeline: &Timeline, + resolver: &MediaResolver<'_>, +) -> bool { + if !clip.media_type.is_visual() { + return false; + } + if let Some(sequence_id) = clip.nested_sequence_id.as_deref() { + return timeline + .nested_sequences + .iter() + .any(|sequence| sequence.id == sequence_id && sequence.timeline.total_frames() > 0); + } + match clip.media_type { + ClipType::Text => { + clip.text_content + .as_deref() + .is_some_and(|content| !content.trim().is_empty()) + && clip.text_style.is_some() + } + ClipType::Video | ClipType::Image | ClipType::Lottie => resolver + .expected_path(&clip.media_ref) + .is_some_and(|path| path.is_file()), + ClipType::Audio => false, + } +} + +/// JPEG-encode an already-authoritative project composite. `frame` must be the +/// RGBA result returned by the shared preview/export compositor for +/// `snapshot.representative_frame(manifest)`. This function deliberately owns +/// only cover geometry and encoding. +/// +/// The output is the largest exact 16:9 raster that fits inside `bounds`. +/// The project canvas is resized to fit and centered over an opaque black +/// background. This keeps the bounded Home surface predictable without +/// cropping authored canvas content or relying on JPEG alpha handling. +pub fn capture_project_composite_thumbnail( + snapshot: ProjectCompositeThumbnailSnapshot<'_>, + manifest: &MediaManifest, + frame: &crate::frame::RgbaFrame, + bounds: (u32, u32), +) -> Option> { + snapshot.representative_frame(manifest)?; + encode_project_composite_thumbnail(frame, bounds) +} + +/// Apply only the bounded cover-surface policy and JPEG encoding to a frame +/// already selected and produced by the authoritative render plan. This form is +/// used by strict retained-source capture so media paths are not reopened just +/// to repeat representative-frame selection. +pub fn encode_project_composite_thumbnail( + frame: &crate::frame::RgbaFrame, + bounds: (u32, u32), +) -> Option> { + let (width, height) = bounded_sixteen_by_nine(bounds)?; + let rgba = image::RgbaImage::from_raw(frame.width, frame.height, frame.rgba.clone())?; + let contained = image::DynamicImage::ImageRgba8(rgba) + .resize(width, height, image::imageops::FilterType::Lanczos3) + .to_rgb8(); + let mut cover = image::RgbImage::from_pixel(width, height, image::Rgb([0, 0, 0])); + let x = i64::from(width.saturating_sub(contained.width()) / 2); + let y = i64::from(height.saturating_sub(contained.height()) / 2); + image::imageops::overlay(&mut cover, &contained, x, y); + encode_dynamic_jpeg(&image::DynamicImage::ImageRgb8(cover)).ok() +} + +fn bounded_sixteen_by_nine(bounds: (u32, u32)) -> Option<(u32, u32)> { + let scale = (bounds.0 / 16).min(bounds.1 / 9); + (scale > 0).then_some((scale * 16, scale * 9)) +} + /// Decode the picked clip's cover frame and JPEG-encode it. Split out so the /// (ffmpeg-dependent) capture is a single fallible step the caller degrades to /// `None`. @@ -156,7 +292,11 @@ fn encode_source(source: &ThumbnailSource, fps: i32) -> Result> { fn encode_jpeg(frame: &crate::frame::RgbaFrame) -> Result> { let rgba = image::RgbaImage::from_raw(frame.width, frame.height, frame.rgba.clone()) .ok_or_else(|| crate::error::MediaError::Encode("thumbnail: bad rgba buffer".into()))?; - let rgb = image::DynamicImage::ImageRgba8(rgba).to_rgb8(); + encode_dynamic_jpeg(&image::DynamicImage::ImageRgba8(rgba)) +} + +fn encode_dynamic_jpeg(image: &image::DynamicImage) -> Result> { + let rgb = image.to_rgb8(); let mut jpg_bytes = Vec::new(); { let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality( @@ -179,7 +319,8 @@ fn encode_jpeg(frame: &crate::frame::RgbaFrame) -> Result> { mod tests { use super::*; use opentake_domain::{ - Clip, ClipType, MediaManifest, MediaManifestEntry, MediaSource, Timeline, Track, + Clip, ClipType, MediaManifest, MediaManifestEntry, MediaSource, Point, TextStyle, Timeline, + Track, Transform, Transition, TransitionKind, }; use std::fs; use std::path::PathBuf; @@ -248,6 +389,117 @@ mod tests { p } + fn touch_color_png(dir: &Path, name: &str, color: [u8; 4]) -> PathBuf { + let p = dir.join(name); + image::RgbaImage::from_pixel(320, 180, image::Rgba(color)) + .save(&p) + .unwrap(); + p + } + + fn composite_fixture(dir: &Path) -> (Timeline, MediaManifest, crate::frame::RgbaFrame) { + let background = touch_color_png(dir, "background.png", [210, 20, 20, 255]); + let incoming = touch_color_png(dir, "incoming.png", [20, 180, 20, 255]); + let overlay = touch_color_png(dir, "overlay.png", [20, 40, 220, 255]); + let mut manifest = MediaManifest::new(); + manifest + .entries + .push(entry("background", ClipType::Video, &background)); + manifest + .entries + .push(entry("incoming", ClipType::Video, &incoming)); + manifest + .entries + .push(entry("overlay", ClipType::Image, &overlay)); + + let mut background_clip = clip("background-clip", "background", ClipType::Video, 0); + background_clip.duration_frames = 30; + background_clip.transition_out = Some(Transition { + from_clip_id: "background-clip".into(), + to_clip_id: "incoming-clip".into(), + kind: TransitionKind::CrossDissolve, + duration_frames: 10, + }); + let mut incoming_clip = clip("incoming-clip", "incoming", ClipType::Video, 0); + incoming_clip.start_frame = 30; + incoming_clip.duration_frames = 30; + let mut background_track = Track::new("background-track", ClipType::Video); + background_track.clips = vec![background_clip, incoming_clip]; + + let mut overlay_clip = clip("overlay-clip", "overlay", ClipType::Image, 0); + overlay_clip.start_frame = 20; + overlay_clip.duration_frames = 20; + overlay_clip.transform = Transform::from_center(Point { x: 0.78, y: 0.5 }, 0.3, 0.6); + overlay_clip.transform.rotation = 8.0; + let mut overlay_track = Track::new("overlay-track", ClipType::Video); + overlay_track.clips.push(overlay_clip); + + let mut text_clip = clip("text-clip", "", ClipType::Text, 0); + text_clip.start_frame = 20; + text_clip.duration_frames = 20; + text_clip.text_content = Some("Composite".into()); + text_clip.text_style = Some(TextStyle::default()); + text_clip.transform = Transform::from_center(Point { x: 0.28, y: 0.5 }, 0.4, 0.2); + let mut text_track = Track::new("text-track", ClipType::Text); + text_track.clips.push(text_clip); + + let mut timeline = Timeline::new(); + timeline.width = 320; + timeline.height = 180; + timeline.tracks = vec![background_track, overlay_track, text_track]; + + // A hand-derived stand-in for the authoritative renderer output at the + // cross-dissolve midpoint: red background, green transition evidence, + // transformed blue overlay, and white text evidence. + let mut pixels = image::RgbaImage::from_pixel(320, 180, image::Rgba([210, 20, 20, 255])); + for y in 150..180 { + for x in 0..320 { + pixels.put_pixel(x, y, image::Rgba([20, 180, 20, 255])); + } + } + for y in 40..140 { + for x in 210..300 { + pixels.put_pixel(x, y, image::Rgba([20, 40, 220, 255])); + } + } + for y in 76..96 { + for x in 25..145 { + pixels.put_pixel(x, y, image::Rgba([245, 245, 245, 255])); + } + } + ( + timeline, + manifest, + crate::frame::RgbaFrame { + width: 320, + height: 180, + rgba: pixels.into_raw(), + }, + ) + } + + fn desired_composite_capture( + timeline: &Timeline, + manifest: &MediaManifest, + frame: &crate::frame::RgbaFrame, + bounds: (u32, u32), + ) -> Option> { + capture_project_composite_thumbnail( + ProjectCompositeThumbnailSnapshot::new(timeline, None), + manifest, + frame, + bounds, + ) + } + + fn desired_representative_frame( + timeline: &Timeline, + manifest: &MediaManifest, + project_base: Option<&Path>, + ) -> Option { + representative_project_thumbnail_frame(timeline, manifest, project_base) + } + #[test] fn pick_returns_none_for_empty_timeline() { let tl = Timeline::new(); @@ -373,4 +625,169 @@ mod tests { let manifest = MediaManifest::new(); assert!(capture_project_thumbnail(&tl, &manifest, None).is_none()); } + + #[test] + fn composite_thumbnail_contains_background_transition_overlay_transform_and_text_evidence() { + let dir = TmpDir::new("composite-layers"); + let (timeline, manifest, composite) = composite_fixture(dir.path()); + + let bytes = + desired_composite_capture(&timeline, &manifest, &composite, VIDEO_COVER_MAX_SIZE) + .expect("composite JPEG"); + let decoded = image::load_from_memory(&bytes) + .expect("decode cover") + .to_rgb8(); + + let background = decoded.get_pixel(180, 20).0; + let transition = decoded.get_pixel(180, 168).0; + let overlay = decoded.get_pixel(250, 90).0; + let text = decoded.get_pixel(80, 85).0; + assert!(background[0] > 150 && background[1] < 80, "{background:?}"); + assert!(transition[1] > 110 && transition[0] < 100, "{transition:?}"); + assert!(overlay[2] > 140 && overlay[0] < 100, "{overlay:?}"); + assert!(text.iter().all(|channel| *channel > 190), "{text:?}"); + } + + #[test] + fn composite_thumbnail_uses_transition_midpoint_as_stable_representative_frame() { + let dir = TmpDir::new("composite-representative"); + let (timeline, manifest, _) = composite_fixture(dir.path()); + + assert_eq!( + desired_representative_frame(&timeline, &manifest, None), + Some(25) + ); + } + + #[test] + fn composite_thumbnail_is_deterministically_bounded_to_sixteen_by_nine() { + let dir = TmpDir::new("composite-bounds"); + let (timeline, manifest, composite) = composite_fixture(dir.path()); + + let first = desired_composite_capture(&timeline, &manifest, &composite, (200, 200)) + .expect("first JPEG"); + let second = desired_composite_capture(&timeline, &manifest, &composite, (200, 200)) + .expect("second JPEG"); + let decoded = image::load_from_memory(&first).expect("decode bounded cover"); + + assert_eq!((decoded.width(), decoded.height()), (192, 108)); + assert_eq!(first, second); + } + + #[test] + fn composite_thumbnail_letterboxes_portrait_canvas_without_cropping() { + let dir = TmpDir::new("composite-portrait-letterbox"); + let source = touch_color_png(dir.path(), "portrait.png", [220, 30, 20, 255]); + let mut manifest = MediaManifest::new(); + manifest + .entries + .push(entry("portrait", ClipType::Image, &source)); + let mut timeline = Timeline::new(); + timeline.width = 180; + timeline.height = 320; + let mut track = Track::new("video", ClipType::Video); + track + .clips + .push(clip("portrait-clip", "portrait", ClipType::Image, 0)); + timeline.tracks.push(track); + let portrait = crate::frame::RgbaFrame::new(180, 320, [220, 30, 20, 255].repeat(180 * 320)); + + let bytes = desired_composite_capture( + &timeline, + &manifest, + &portrait, + PROJECT_COMPOSITE_COVER_BOUNDS, + ) + .expect("portrait cover"); + let decoded = image::load_from_memory(&bytes).unwrap().to_rgb8(); + + assert_eq!((decoded.width(), decoded.height()), (640, 360)); + assert!(decoded + .get_pixel(0, 180) + .0 + .iter() + .all(|channel| *channel < 12)); + let center = decoded.get_pixel(320, 180).0; + assert!(center[0] > 180 && center[1] < 70, "{center:?}"); + assert!(decoded + .get_pixel(639, 180) + .0 + .iter() + .all(|channel| *channel < 12)); + } + + #[test] + fn composite_thumbnail_letterboxes_four_by_three_canvas_at_exact_boundaries() { + let dir = TmpDir::new("composite-four-three-letterbox"); + let source = touch_color_png(dir.path(), "four-three.png", [20, 80, 220, 255]); + let mut manifest = MediaManifest::new(); + manifest + .entries + .push(entry("four-three", ClipType::Image, &source)); + let mut timeline = Timeline::new(); + timeline.width = 400; + timeline.height = 300; + let mut track = Track::new("video", ClipType::Video); + track + .clips + .push(clip("four-three-clip", "four-three", ClipType::Image, 0)); + timeline.tracks.push(track); + let frame = crate::frame::RgbaFrame::new(400, 300, [20, 80, 220, 255].repeat(400 * 300)); + + let bytes = + desired_composite_capture(&timeline, &manifest, &frame, PROJECT_COMPOSITE_COVER_BOUNDS) + .expect("four-by-three cover"); + let decoded = image::load_from_memory(&bytes).unwrap().to_rgb8(); + + assert!(decoded + .get_pixel(79, 180) + .0 + .iter() + .all(|channel| *channel < 15)); + let first_content = decoded.get_pixel(82, 180).0; + assert!(first_content[2] > 160, "{first_content:?}"); + let last_content = decoded.get_pixel(557, 180).0; + assert!(last_content[2] > 160, "{last_content:?}"); + assert!(decoded + .get_pixel(560, 180) + .0 + .iter() + .all(|channel| *channel < 15)); + } + + #[test] + fn composite_thumbnail_returns_none_for_empty_project() { + assert!(desired_composite_capture( + &Timeline::new(), + &MediaManifest::new(), + &crate::frame::RgbaFrame { + width: 2, + height: 2, + rgba: vec![0; 16], + }, + VIDEO_COVER_MAX_SIZE, + ) + .is_none()); + } + + #[test] + fn composite_thumbnail_returns_none_when_every_visual_source_is_offline() { + let dir = TmpDir::new("composite-offline"); + let missing = dir.path().join("missing.png"); + let mut manifest = MediaManifest::new(); + manifest + .entries + .push(entry("missing", ClipType::Image, &missing)); + let mut timeline = Timeline::new(); + let mut track = Track::new("video", ClipType::Video); + track + .clips + .push(clip("missing-clip", "missing", ClipType::Image, 0)); + timeline.tracks.push(track); + + assert_eq!( + desired_representative_frame(&timeline, &manifest, None), + None + ); + } } diff --git a/crates/opentake-motion/src/cache.rs b/crates/opentake-motion/src/cache.rs index a1d68a07..37bd7f4e 100644 --- a/crates/opentake-motion/src/cache.rs +++ b/crates/opentake-motion/src/cache.rs @@ -33,13 +33,15 @@ pub fn content_hash(req: &MotionRenderRequest) -> String { // Version the key format so a future change to what we hash invalidates old // entries instead of silently colliding. - hasher.update(b"opentake-motion/v1\n"); + hasher.update(b"opentake-motion/v2\n"); // Numeric/flags first (fixed-width, no ambiguity). hasher.update(b"fps="); hasher.update(req.fps.to_le_bytes()); hasher.update(b";frames="); hasher.update(req.duration_frames.to_le_bytes()); + hasher.update(b";start="); + hasher.update(req.start_frame.to_le_bytes()); hasher.update(b";w="); hasher.update(req.width.to_le_bytes()); hasher.update(b";h="); @@ -282,6 +284,9 @@ mod tests { let mut longer = base.clone(); longer.duration_frames = 120; assert_ne!(content_hash(&base), content_hash(&longer)); + + let offset = base.clone().with_start_frame(1); + assert_ne!(content_hash(&base), content_hash(&offset)); } #[test] diff --git a/crates/opentake-motion/src/integration.rs b/crates/opentake-motion/src/integration.rs index 6e21919d..f3c7a9c8 100644 --- a/crates/opentake-motion/src/integration.rs +++ b/crates/opentake-motion/src/integration.rs @@ -21,12 +21,51 @@ //! keeps this crate's default dependency surface free of a decoder while still //! being fully testable. +use std::io::Read; use std::path::Path; use opentake_render::{DecodedFrame, FrameProvider, SourceMetrics}; use crate::source::RenderedClip; +/// Maximum encoded PNG returned across the Tauri preview boundary. The live +/// renderer also bounds dimensions, but encoded bytes need their own cap before +/// base64 expansion in the WebView process. +pub const MAX_PREVIEW_PNG_BYTES: usize = 8 * 1024 * 1024; + +/// Read the one frame produced by a preview render without trusting file +/// metadata alone. Growth after metadata is caught by the `take(limit + 1)` +/// boundary and non-PNG cache corruption fails closed. +pub fn read_single_preview_png(clip: &RenderedClip) -> crate::MotionResult> { + if clip.frames.len() != 1 { + return Err(crate::MotionError::render_failed( + "preview renderer must return exactly one frame", + )); + } + let mut file = std::fs::File::open(&clip.frames[0])?; + let metadata = file.metadata()?; + if !metadata.is_file() || metadata.len() > MAX_PREVIEW_PNG_BYTES as u64 { + return Err(crate::MotionError::render_failed( + "preview PNG exceeds its byte limit", + )); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + Read::by_ref(&mut file) + .take(MAX_PREVIEW_PNG_BYTES as u64 + 1) + .read_to_end(&mut bytes)?; + if bytes.len() > MAX_PREVIEW_PNG_BYTES { + return Err(crate::MotionError::render_failed( + "preview PNG exceeds its byte limit", + )); + } + if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + return Err(crate::MotionError::render_failed( + "preview renderer returned a non-PNG frame", + )); + } + Ok(bytes) +} + /// A function that decodes a frame file into straight-or-premultiplied RGBA. /// Returns `None` on a missing/corrupt file (the compositor treats that frame as /// absent, same as a failed video decode). @@ -202,4 +241,19 @@ mod tests { let src = MotionClipSource::new(clip, image_decoder); assert!(src.decoded_frame("ref", -5).is_some()); } + + #[test] + fn single_preview_png_is_bounded_and_validated() { + let (mut clip, _tmp) = render_clip(false); + clip.frames.truncate(1); + assert!(read_single_preview_png(&clip) + .unwrap() + .starts_with(b"\x89PNG\r\n\x1a\n")); + + std::fs::write(&clip.frames[0], b"not-png").unwrap(); + assert!(read_single_preview_png(&clip) + .unwrap_err() + .to_string() + .contains("non-PNG")); + } } diff --git a/crates/opentake-motion/src/lib.rs b/crates/opentake-motion/src/lib.rs index a9271649..aa4de21f 100644 --- a/crates/opentake-motion/src/lib.rs +++ b/crates/opentake-motion/src/lib.rs @@ -53,7 +53,9 @@ pub mod source; // Flat re-export of the public API for ergonomic downstream use. pub use cache::{content_hash, MotionCache}; pub use error::{MotionError, MotionResult}; -pub use integration::{FrameDecoder, MotionClipSource}; +pub use integration::{ + read_single_preview_png, FrameDecoder, MotionClipSource, MAX_PREVIEW_PNG_BYTES, +}; pub use manifest::{ DurationMode, DurationSpec, FpsPolicy, MotionPlugin, MotionPluginAuthor, ParamSpec, }; @@ -61,5 +63,8 @@ pub use renderer::{ deterministic_clock_script, HeadlessChromiumRenderer, MotionCancellationToken, MotionRenderer, StubRenderer, }; -pub use sandbox::{AllowedOrigin, SandboxPolicy}; -pub use source::{limits, MotionRenderRequest, MotionSource, ParamValue, RenderedClip}; +pub use sandbox::{AllowedOrigin, SandboxPolicy, OFFLINE_DOCUMENT_CSP}; +pub use source::{ + limits, MotionDocumentSource, MotionRenderRequest, MotionSource, MotionSourceDiagnostic, + ParamValue, RenderedClip, +}; diff --git a/crates/opentake-motion/src/renderer.rs b/crates/opentake-motion/src/renderer.rs index 10e79961..3ad66f8a 100644 --- a/crates/opentake-motion/src/renderer.rs +++ b/crates/opentake-motion/src/renderer.rs @@ -78,6 +78,16 @@ pub fn deterministic_clock_script() -> &'static str { var current = 0; var listeners = []; var randomState = 0x6d2b79f5; + function pinAnimations(seconds) { + if (!document.getAnimations) return; + var animations = document.getAnimations(); + for (var i = 0; i < animations.length; i++) { + try { + animations[i].pause(); + animations[i].currentTime = seconds * 1000; + } catch (e) { /* a detached animation may disappear while seeking */ } + } + } try { Date.now = function () { return Math.round(current * 1000); }; } catch (e) {} try { Object.defineProperty(performance, 'now', { @@ -110,11 +120,15 @@ pub fn deterministic_clock_script() -> &'static str { }); } } catch (e) { /* timeline may be read-only; listeners still fire */ } + pinAnimations(seconds); var pending = []; for (var i = 0; i < listeners.length; i++) { try { pending.push(Promise.resolve(listeners[i](seconds))); } catch (e) {} } await Promise.all(pending); + // A seek listener may create a CSS/Web Animation. Freeze those at the + // same exact playhead before the compositor is allowed to paint. + pinAnimations(seconds); }, // Authors register frame callbacks: OpenTake.onSeek(t => { ... }). onSeek: function (fn) { if (typeof fn === 'function') listeners.push(fn); } @@ -485,12 +499,12 @@ impl HeadlessChromiumRenderer { format!("data:text/html;charset=utf-8,{encoded}") } - /// The plan of per-frame virtual-time stamps the backend will seek through: - /// `[0/fps, 1/fps, ..., (n-1)/fps]`. Pure helper that documents and tests the - /// time grid without launching anything. + /// The plan of per-frame virtual-time stamps the backend will seek through, + /// beginning at `start_frame / fps`. Pure helper that documents and tests + /// the time grid without launching anything. pub fn frame_time_grid(req: &MotionRenderRequest) -> Vec { (0..req.duration_frames) - .map(|i| i as f64 / req.fps as f64) + .map(|i| (req.start_frame + i) as f64 / req.fps as f64) .collect() } @@ -500,6 +514,17 @@ impl HeadlessChromiumRenderer { &self, req: &MotionRenderRequest, cancellation: &MotionCancellationToken, + ) -> MotionResult { + self.render_with_cancellation_and_progress(req, cancellation, &|_, _| {}) + } + + /// Render with cooperative cancellation and report each durably written + /// frame. Cache hits report the complete frame count in one callback. + pub fn render_with_cancellation_and_progress( + &self, + req: &MotionRenderRequest, + cancellation: &MotionCancellationToken, + progress: &dyn Fn(u32, u32), ) -> MotionResult { let validated = (|| { req.validate()?; @@ -516,11 +541,11 @@ impl HeadlessChromiumRenderer { #[cfg(feature = "chromium")] { - chromium_backend::render(self, req, cancellation) + chromium_backend::render(self, req, cancellation, progress) } #[cfg(not(feature = "chromium"))] { - let _ = (&self.cache, cancellation); + let _ = (&self.cache, cancellation, progress); Err(MotionError::renderer_unavailable( "headless-Chromium backend is not compiled in; build with the \ `chromium` feature, or use StubRenderer for offline/deterministic rendering", @@ -785,8 +810,9 @@ mod chromium_backend { renderer: &HeadlessChromiumRenderer, req: &MotionRenderRequest, cancellation: &MotionCancellationToken, + progress: &dyn Fn(u32, u32), ) -> MotionResult { - let result = render_inner(renderer, req, cancellation); + let result = render_inner(renderer, req, cancellation, progress); if result.is_err() { renderer.browser_pool.invalidate_idle(); } @@ -797,6 +823,7 @@ mod chromium_backend { renderer: &HeadlessChromiumRenderer, req: &MotionRenderRequest, cancellation: &MotionCancellationToken, + progress: &dyn Fn(u32, u32), ) -> MotionResult { if cancellation.is_cancelled() { return Err(MotionError::Cancelled); @@ -834,6 +861,7 @@ mod chromium_backend { let hash = content_hash(req); if renderer.cache.is_cached(req) { + progress(req.duration_frames, req.duration_frames); return Ok(clip_from_cache(req, hash, renderer.cache.dir_for(req))); } @@ -850,6 +878,7 @@ mod chromium_backend { check_abort(cancellation, deadline, renderer.policy.timeout)?; if renderer.cache.is_cached(req) { browser.commit_reuse(); + progress(req.duration_frames, req.duration_frames); return Ok(clip_from_cache(req, hash, renderer.cache.dir_for(req))); } @@ -1035,6 +1064,10 @@ mod chromium_backend { let path = MotionCache::frame_file(&dir, index); std::fs::write(&path, png)?; frames.push(path); + progress( + u32::try_from(index).unwrap_or(u32::MAX).saturating_add(1), + req.duration_frames, + ); } cdp.close_target(&target_id)?; @@ -5546,6 +5579,9 @@ mod tests { assert!(s.contains("seek")); assert!(s.contains("currentTime")); assert!(s.contains("onSeek")); + assert!(s.contains("document.getAnimations")); + assert!(s.contains("animations[i].pause()")); + assert!(s.contains("animations[i].currentTime = seconds * 1000")); } #[test] diff --git a/crates/opentake-motion/src/sandbox.rs b/crates/opentake-motion/src/sandbox.rs index 12c34d39..9c3537d0 100644 --- a/crates/opentake-motion/src/sandbox.rs +++ b/crates/opentake-motion/src/sandbox.rs @@ -35,6 +35,13 @@ pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); /// ship as a template package with audited assets instead. pub const DEFAULT_MAX_DOCUMENT_BYTES: usize = 256 * 1024; +/// CSP embedded in project-authored HTML/CSS documents before Chromium adds +/// its independent request interception. It permits only inline styles; the +/// deterministic clock is injected by CDP before author content and needs no +/// document script permission. Network, filesystem-adjacent, navigation, frame, +/// worker, media, form, and author-script capabilities stay closed. +pub const OFFLINE_DOCUMENT_CSP: &str = "default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:; connect-src 'none'; media-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-src 'none'; worker-src 'none'"; + /// An allowed network origin (scheme + host[:port]), e.g. /// `https://cdn.jsdelivr.net`. Compared case-insensitively by exact prefix on the /// request URL's origin. We deliberately do NOT support wildcards: each origin a diff --git a/crates/opentake-motion/src/source.rs b/crates/opentake-motion/src/source.rs index a3850885..60dcab53 100644 --- a/crates/opentake-motion/src/source.rs +++ b/crates/opentake-motion/src/source.rs @@ -11,6 +11,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use crate::error::{MotionError, MotionResult}; +use crate::sandbox::OFFLINE_DOCUMENT_CSP; /// Hard caps on a render request, expressed as types so out-of-range inputs are /// rejected at the boundary rather than melting an offscreen engine. These mirror @@ -139,6 +140,178 @@ impl MotionSource { } } +/// A project-authored HTML fragment and stylesheet. Motion Studio intentionally +/// exposes HTML/CSS only: executable author scripts, event handlers, remote +/// resources, navigation, nested documents, and filesystem URLs are rejected +/// before the source reaches Chromium's independent sandbox. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MotionDocumentSource { + pub html: String, + pub css: String, +} + +/// Precise editor-facing source diagnostic. Line and column are one-based +/// Unicode scalar positions so the web adapter can map them to CodeMirror. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MotionSourceDiagnostic { + pub message: String, + pub line: u32, + pub column: u32, +} + +impl MotionDocumentSource { + pub fn new(html: impl Into, css: impl Into) -> Self { + Self { + html: html.into(), + css: css.into(), + } + } + + /// Compile the two stored sources into the exact self-contained document + /// consumed by preview and final publishing. + pub fn inline_document(&self) -> Result { + if self.html.trim().is_empty() { + return Err(source_diagnostic( + &self.html, + 0, + "HTML must contain visible document content", + )); + } + validate_html_fragment(&self.html)?; + validate_stylesheet(&self.css)?; + Ok(format!( + r#"
{html}
"#, + css = self.css, + html = self.html, + )) + } +} + +fn validate_html_fragment(html: &str) -> Result<(), MotionSourceDiagnostic> { + let lower = html.to_ascii_lowercase(); + for (token, description) in [ + (" Result<(), MotionSourceDiagnostic> { + let lower = css.to_ascii_lowercase(); + for (token, description) in [ + (" Option<(usize, &str)> { + let bytes = lower.as_bytes(); + let mut index = 0; + while index < bytes.len() { + let boundary = index == 0 + || bytes[index - 1].is_ascii_whitespace() + || matches!(bytes[index - 1], b'<' | b'/' | b'\'' | b'"'); + if !boundary || !bytes[index].is_ascii_alphabetic() { + index += 1; + continue; + } + let start = index; + while index < bytes.len() + && (bytes[index].is_ascii_alphanumeric() || matches!(bytes[index], b'-' | b':' | b'_')) + { + index += 1; + } + let name = &lower[start..index]; + while index < bytes.len() && bytes[index].is_ascii_whitespace() { + index += 1; + } + if bytes.get(index) == Some(&b'=') + && (name.starts_with("on") + || matches!( + name, + "src" + | "srcset" + | "srcdoc" + | "href" + | "xlink:href" + | "action" + | "formaction" + | "poster" + | "data" + | "background" + )) + { + return Some((start, name)); + } + } + None +} + +fn source_diagnostic( + source: &str, + byte_offset: usize, + message: impl Into, +) -> MotionSourceDiagnostic { + let offset = byte_offset.min(source.len()); + let prefix = &source[..offset]; + let line = prefix.bytes().filter(|byte| *byte == b'\n').count() + 1; + let column = prefix + .rsplit_once('\n') + .map_or(prefix, |(_, tail)| tail) + .chars() + .count() + + 1; + MotionSourceDiagnostic { + message: message.into(), + line: u32::try_from(line).unwrap_or(u32::MAX), + column: u32::try_from(column).unwrap_or(u32::MAX), + } +} + /// `true` for `#RGB`-style hex colors: `#RRGGBB` or `#RRGGBBAA` (case-insensitive). pub(crate) fn is_hex_color(s: &str) -> bool { let Some(hexpart) = s.strip_prefix('#') else { @@ -157,9 +330,13 @@ pub struct MotionRenderRequest { pub source: MotionSource, /// Timeline frames per second (the project fps the clip is composited at). pub fps: u32, - /// Number of frames to produce. Frame `i` is captured at `t = i / fps` - /// seconds of virtual time (docs §3). + /// Number of frames to produce. Frame `i` is captured at + /// `t = (start_frame + i) / fps` seconds of virtual time (docs §3). pub duration_frames: u32, + /// Absolute source frame for the first output frame. Full publishes begin + /// at zero; single-frame previews set this to the requested playhead. + #[serde(default)] + pub start_frame: u32, /// Canvas width in pixels. pub width: u32, /// Canvas height in pixels. @@ -183,6 +360,7 @@ impl MotionRenderRequest { source, fps, duration_frames, + start_frame: 0, width, height, transparent: true, @@ -195,6 +373,12 @@ impl MotionRenderRequest { self } + /// Render an output window beginning at an absolute animation frame. + pub fn with_start_frame(mut self, start_frame: u32) -> Self { + self.start_frame = start_frame; + self + } + /// Validate ranges against [`limits`] and the source. Pure; call before /// handing the request to any renderer. pub fn validate(&self) -> MotionResult<()> { @@ -213,6 +397,18 @@ impl MotionRenderRequest { limits::MAX_FRAMES ))); } + if self + .start_frame + .checked_add(self.duration_frames) + .is_none_or(|end| end > limits::MAX_FRAMES) + { + return Err(MotionError::invalid_request(format!( + "frame window {}+{} exceeds the {}-frame limit", + self.start_frame, + self.duration_frames, + limits::MAX_FRAMES + ))); + } for (label, dim) in [("width", self.width), ("height", self.height)] { if !(limits::MIN_DIMENSION..=limits::MAX_DIMENSION).contains(&dim) { return Err(MotionError::invalid_request(format!( @@ -366,6 +562,85 @@ mod tests { assert!((req.duration_seconds() - 5.0).abs() < 1e-9); } + #[test] + fn preview_document_source_is_self_contained_visible_and_offline() { + let document = MotionDocumentSource::new( + "

让创意动起来

Real Motion

", + "main { animation: arrive 1s both; } @keyframes arrive { from { opacity: 0 } to { opacity: 1 } }", + ) + .inline_document() + .expect("safe HTML/CSS document"); + + assert!(document.contains("让创意动起来")); + assert!(document.contains("Real Motion")); + assert!(document.contains("@keyframes arrive")); + assert!(document.contains("default-src 'none'")); + assert!(document.contains("script-src 'none'")); + assert!(!document.contains("safe\n", + "main { color: white; }", + ) + .inline_document() + .expect_err("HTML scripts are outside the HTML/CSS authoring contract"); + + assert_eq!(error.line, 2); + assert_eq!(error.column, 1); + assert!(error.message.contains("script")); + } + + #[test] + fn preview_document_source_rejects_network_and_executable_surfaces() { + for (html, css, expected) in [ + ("
x
", "", "onload"), + ( + "", + "", + "onload", + ), + ("", "", "src"), + ("
x
", "@import 'theme.css';", "imports"), + ( + "
x
", + "main { background: url(file:///tmp/private.png) }", + "URLs", + ), + ] { + let error = MotionDocumentSource::new(html, css) + .inline_document() + .expect_err("active or URL-bearing author input must fail closed"); + assert!( + error.message.contains(expected), + "unexpected diagnostic for {html:?} / {css:?}: {error:?}" + ); + } + } + + #[test] + fn preview_request_uses_an_absolute_integer_start_frame() { + let request = MotionRenderRequest::new(MotionSource::code("
"), 30, 1, 640, 360) + .with_start_frame(42); + assert_eq!(request.start_frame, 42); + assert!(request.validate().is_ok()); + assert_eq!( + crate::renderer::HeadlessChromiumRenderer::frame_time_grid(&request), + vec![1.4] + ); + + let overflow = MotionRenderRequest::new(MotionSource::code("
"), 30, 2, 640, 360) + .with_start_frame(limits::MAX_FRAMES - 1); + assert!(overflow.validate().is_err()); + } + #[test] fn request_rejects_zero_fps_and_overlong_duration() { let bad_fps = MotionRenderRequest::new(MotionSource::code("x"), 0, 10, 100, 100); diff --git a/crates/opentake-motion/tests/chromium.rs b/crates/opentake-motion/tests/chromium.rs index f0d2b9d6..e7e759e9 100644 --- a/crates/opentake-motion/tests/chromium.rs +++ b/crates/opentake-motion/tests/chromium.rs @@ -32,7 +32,8 @@ mod live { use opentake_motion::{ HeadlessChromiumRenderer, MotionCache, MotionCancellationToken, MotionClipSource, - MotionError, MotionRenderRequest, MotionRenderer, MotionSource, SandboxPolicy, + MotionDocumentSource, MotionError, MotionRenderRequest, MotionRenderer, MotionSource, + SandboxPolicy, }; use opentake_render::{DecodedFrame, FrameProvider}; @@ -218,6 +219,53 @@ mod live { ); } + pub(super) fn preview_frame_probe() { + let Some(browser_path) = HeadlessChromiumRenderer::find_browser() else { + eprintln!("skipping live preview probe: no supported Chromium binary"); + return; + }; + let root = tempfile::tempdir().unwrap(); + let renderer = HeadlessChromiumRenderer::new( + MotionCache::new(root.path()), + SandboxPolicy::offline_with_timeout(Duration::from_secs(60)), + ) + .with_browser_path(browser_path); + let document = MotionDocumentSource::new( + r#"

让创意动起来

Real Motion

"#, + r#"html,body,main{margin:0;width:100%;height:100%;background:#111} #tile{position:absolute;left:4px;top:8px;width:16px;height:16px;background:#7c5cff;animation:move 1s linear both}@keyframes move{from{transform:translateX(0)}to{transform:translateX(20px)}} h1,p{color:white}"#, + ) + .inline_document() + .unwrap(); + let request = MotionRenderRequest::new(MotionSource::code(document.clone()), 10, 1, 64, 48) + .with_transparent(false) + .with_start_frame(5); + let first = renderer.render(&request).unwrap(); + let first_pixels = image::open(&first.frames[0]).unwrap().to_rgba8(); + std::fs::remove_dir_all(renderer.cache().dir_for(&request)).unwrap(); + let second = renderer.render(&request).unwrap(); + let second_pixels = image::open(&second.frames[0]).unwrap().to_rgba8(); + assert_eq!( + first_pixels, second_pixels, + "same preview frame must be exact" + ); + + let beginning = MotionRenderRequest::new(MotionSource::code(document), 10, 1, 64, 48) + .with_transparent(false) + .with_start_frame(0); + let beginning = renderer.render(&beginning).unwrap(); + let beginning_pixels = image::open(&beginning.frames[0]).unwrap().to_rgba8(); + let differing_channels = beginning_pixels + .as_raw() + .iter() + .zip(first_pixels.as_raw()) + .filter(|(left, right)| left != right) + .count(); + assert!( + differing_channels > 128, + "two animation frames need a meaningful visible difference, got {differing_channels} channels" + ); + } + pub(super) fn browser_pool_invalidation_probe() { let profiles_before = live_profiles(); let root = tempfile::tempdir().unwrap(); @@ -860,6 +908,13 @@ fn consecutive_cache_misses_reuse_one_chromium_session() { live::browser_pool_reuses_session_probe(); } +#[cfg(feature = "chromium")] +#[test] +fn preview_frame_is_deterministic_and_visibly_advances() { + let _live_test_guard = live_test_guard(); + live::preview_frame_probe(); +} + #[cfg(feature = "chromium")] #[test] fn browser_pool_invalidates_on_blocked_or_cancelled_render() { diff --git a/crates/opentake-project/src/archive.rs b/crates/opentake-project/src/archive.rs index 1086d70f..b830613a 100644 --- a/crates/opentake-project/src/archive.rs +++ b/crates/opentake-project/src/archive.rs @@ -21,8 +21,8 @@ //! bundled; `copied_internal` counts `.project` files copied; `total_bytes` //! is the bytes copied into the new bundle. //! - After writing `project.json` / `media.json` / `generation-log.json`, the -//! source bundle's `thumbnail.jpg` and `chat-sessions/` are carried across -//! when present. +//! source bundle's `thumbnail.jpg`, `chat-sessions/`, and +//! `motion-documents/` are carried across when present. use std::collections::HashMap; use std::fs; @@ -33,6 +33,7 @@ use opentake_domain::{MediaManifest, MediaSource, Timeline}; use crate::error::{ProjectError, Result}; use crate::gen_log::GenerationLog; use crate::layout; +use crate::project_root::ProjectRoot; /// Outcome of an [`archive`] run. 1:1 with upstream `PalmierProjectExporter.Report`. #[derive(Clone, PartialEq, Eq, Debug, Default)] @@ -160,6 +161,9 @@ pub fn archive( &layout::chat_sessions_dir(source_bundle), &layout::chat_sessions_dir(dest_bundle), )?; + let source_root = ProjectRoot::open(source_bundle)?; + let destination_root = ProjectRoot::open(dest_bundle)?; + source_root.copy_motion_documents_to(&destination_root)?; } Ok(report) diff --git a/crates/opentake-project/src/bundle.rs b/crates/opentake-project/src/bundle.rs index ecfb4362..ca588941 100644 --- a/crates/opentake-project/src/bundle.rs +++ b/crates/opentake-project/src/bundle.rs @@ -18,8 +18,9 @@ //! snapshot, then write atomically": each JSON component is written to a //! sibling temp file and renamed into place, so a crash never leaves a //! half-written `project.json`. `save` owns only the JSON components (and the -//! thumbnail when held); it never creates or deletes `media/` or -//! `chat-sessions/`, which the media and agent layers manage out-of-band. +//! thumbnail when held); it never creates or deletes `media/`, +//! `chat-sessions/`, or `motion-documents/`, which their owning layers manage +//! out-of-band. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -97,6 +98,19 @@ impl ProjectCompatibility { } } +/// Requested mutation for the optional project cover in an explicit save. +/// +/// `Preserve` is the compatibility/default behavior for ordinary saves, +/// `Replace` commits newly captured JPEG bytes, and `Remove` represents the +/// authoritative result that the project has no visible cover content. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum ThumbnailUpdate { + #[default] + Preserve, + Replace(Vec), + Remove, +} + /// An opened `.opentake` project: the bundle path plus its decoded components. /// /// Media files referenced by `manifest` live under the bundle's `media/` @@ -116,8 +130,8 @@ pub struct Project { /// The generation log (`generation-log.json`). `None` when the file was /// absent or failed to parse; the latter also makes compatibility read-only. pub generation_log: Option, - /// JPEG thumbnail bytes to write on the next `save`. `None` leaves any - /// existing `thumbnail.jpg` on disk untouched. + /// Optional cover bytes to write on the next save. `None` preserves an + /// existing on-disk cover, matching the original public API. pub thumbnail: Option>, compatibility: ProjectCompatibility, } @@ -338,6 +352,18 @@ impl Project { EncodedProject::prepare(self)?.write_to(root) } + /// Persist this snapshot with an explicit optional-cover mutation. + /// + /// This additive API keeps [`Self::thumbnail`] source-compatible while + /// allowing authoritative callers to distinguish preserve from removal. + pub fn save_to_root_with_thumbnail_update( + &self, + root: &ProjectRoot, + thumbnail: ThumbnailUpdate, + ) -> Result<()> { + EncodedProject::prepare_with_thumbnail_update(self, thumbnail)?.write_to(root) + } + /// Publish a complete fresh sibling bundle and return the exact root that /// became visible. Sessions adopt this retained authority only after the /// directory publication commit succeeds. @@ -346,13 +372,32 @@ impl Project { bundle: impl AsRef, media_source: Option<&ProjectRoot>, ) -> Result { - let encoded = EncodedProject::prepare(self)?; + self.publish_complete_to_with_thumbnail_update( + bundle, + media_source, + self.thumbnail + .clone() + .map_or(ThumbnailUpdate::Preserve, ThumbnailUpdate::Replace), + ) + } + + /// Publish a complete fresh sibling with an explicit optional-cover + /// mutation while retaining all other bundle components. + pub fn publish_complete_to_with_thumbnail_update( + &self, + bundle: impl AsRef, + media_source: Option<&ProjectRoot>, + thumbnail: ThumbnailUpdate, + ) -> Result { + let preserve_thumbnail = matches!(thumbnail, ThumbnailUpdate::Preserve); + let encoded = EncodedProject::prepare_with_thumbnail_update(self, thumbnail)?; let publisher = ProjectRoot::begin_replace(bundle.as_ref())?; encoded.write_to(publisher.stage())?; if let Some(source) = media_source { source.copy_media_to(publisher.stage())?; source.copy_chat_sessions_to(publisher.stage())?; - if self.thumbnail.is_none() { + source.copy_motion_documents_to(publisher.stage())?; + if preserve_thumbnail { source.copy_thumbnail_to(publisher.stage())?; } } @@ -372,12 +417,14 @@ impl Project { bundle: impl AsRef, media_source: ProjectRoot, ) -> Result { + let preserve_thumbnail = self.thumbnail.is_none(); let encoded = EncodedProject::prepare(self)?; let publisher = ProjectRoot::begin_replace(bundle.as_ref())?; encoded.write_to(publisher.stage())?; media_source.copy_media_to(publisher.stage())?; media_source.copy_chat_sessions_to(publisher.stage())?; - if self.thumbnail.is_none() { + media_source.copy_motion_documents_to(publisher.stage())?; + if preserve_thumbnail { media_source.copy_thumbnail_to(publisher.stage())?; } drop(media_source); @@ -399,6 +446,7 @@ impl Project { media_byte_size: u64, media: &mut dyn std::io::Read, ) -> Result { + let preserve_thumbnail = self.thumbnail.is_none(); let encoded = EncodedProject::prepare(self)?; let publisher = ProjectRoot::begin_replace(bundle.as_ref())?; encoded.write_to(publisher.stage())?; @@ -407,7 +455,8 @@ impl Project { .stage() .write_new_media_leaf(media_leaf, media_byte_size, media)?; media_source.copy_chat_sessions_to(publisher.stage())?; - if self.thumbnail.is_none() { + media_source.copy_motion_documents_to(publisher.stage())?; + if preserve_thumbnail { media_source.copy_thumbnail_to(publisher.stage())?; } drop(media_source); @@ -429,12 +478,23 @@ struct EncodedProject { timeline: Vec, manifest: Vec, generation_log: Option>, - thumbnail: Option>, + thumbnail: ThumbnailUpdate, } impl EncodedProject { /// Produce the exact byte snapshot before any destination path is created. fn prepare(project: &Project) -> Result { + let thumbnail = project + .thumbnail + .clone() + .map_or(ThumbnailUpdate::Preserve, ThumbnailUpdate::Replace); + Self::prepare_with_thumbnail_update(project, thumbnail) + } + + fn prepare_with_thumbnail_update( + project: &Project, + thumbnail: ThumbnailUpdate, + ) -> Result { project.compatibility.ensure_writable()?; project .timeline @@ -451,7 +511,7 @@ impl EncodedProject { .as_ref() .map(|log| encode_component(layout::GENERATION_LOG_FILE, log)) .transpose()?, - thumbnail: project.thumbnail.clone(), + thumbnail, }) } @@ -461,8 +521,12 @@ impl EncodedProject { if let Some(log) = &self.generation_log { root.write_atomic(layout::GENERATION_LOG_FILE, log)?; } - if let Some(thumbnail) = &self.thumbnail { - root.write_atomic(layout::THUMBNAIL_FILE, thumbnail)?; + match &self.thumbnail { + ThumbnailUpdate::Preserve => {} + ThumbnailUpdate::Replace(thumbnail) => { + root.write_atomic(layout::THUMBNAIL_FILE, thumbnail)?; + } + ThumbnailUpdate::Remove => root.remove_optional_component(layout::THUMBNAIL_FILE)?, } Ok(()) } @@ -823,6 +887,40 @@ mod tests { ); } + #[test] + fn complete_publish_carries_motion_documents_across_save_as() { + let tmp = TmpDir::new("complete-motion-documents"); + let source = tmp.path().join("Source.opentake"); + let target = tmp.path().join("Target.opentake"); + let project = Project::new(&source); + project.save().unwrap(); + fs::create_dir_all(source.join("motion-documents/rev-document")).unwrap(); + fs::write( + source.join("motion-documents/catalog.json"), + br#"{"schemaVersion":1,"documents":{}}"#, + ) + .unwrap(); + fs::write( + source.join("motion-documents/rev-document/index.html"), + b"
Motion Studio
", + ) + .unwrap(); + let source_root = ProjectRoot::open(&source).unwrap(); + + project + .publish_complete_to(&target, Some(&source_root)) + .expect("Save As must carry project-local motion documents"); + + assert_eq!( + fs::read(target.join("motion-documents/catalog.json")).unwrap(), + br#"{"schemaVersion":1,"documents":{}}"# + ); + assert_eq!( + fs::read(target.join("motion-documents/rev-document/index.html")).unwrap(), + b"
Motion Studio
" + ); + } + #[test] fn complete_publish_replaces_the_owned_source_root() { let tmp = TmpDir::new("complete-same-target"); @@ -848,6 +946,26 @@ mod tests { assert_eq!(fs::read(target.join("thumbnail.jpg")).unwrap(), b"cover"); } + #[test] + fn explicit_thumbnail_removal_deletes_only_the_retained_optional_component() { + let tmp = TmpDir::new("remove-thumbnail"); + let target = tmp.path().join("Project.opentake"); + let mut project = Project::new(&target); + project.thumbnail = Some(b"cover".to_vec()); + project.save().unwrap(); + fs::write(target.join("keep.bin"), b"keep").unwrap(); + + let root = ProjectRoot::open(&target).unwrap(); + project + .save_to_root_with_thumbnail_update(&root, ThumbnailUpdate::Remove) + .expect("thumbnail removal is a valid save"); + + assert!(!target.join("thumbnail.jpg").exists()); + assert_eq!(fs::read(target.join("keep.bin")).unwrap(), b"keep"); + assert!(target.join("project.json").is_file()); + assert!(target.join("media.json").is_file()); + } + #[test] fn complete_publish_streams_generated_media_into_the_new_bundle() { let tmp = TmpDir::new("complete-generated-media"); diff --git a/crates/opentake-project/src/layout.rs b/crates/opentake-project/src/layout.rs index d50b1771..a092e163 100644 --- a/crates/opentake-project/src/layout.rs +++ b/crates/opentake-project/src/layout.rs @@ -39,6 +39,9 @@ pub const LUTS_DIR: &str = "luts"; /// migration of old `.palmier` bundles is ever needed (not done here). pub const CHAT_SESSIONS_DIR: &str = "chat-sessions"; +/// `motion-documents/` — project-local HTML/CSS Motion Studio sources. +pub const MOTION_DOCUMENTS_DIR: &str = "motion-documents"; + /// Absolute path to `project.json` inside `bundle`. pub fn timeline_path(bundle: &Path) -> PathBuf { bundle.join(TIMELINE_FILE) @@ -73,3 +76,8 @@ pub fn luts_dir(bundle: &Path) -> PathBuf { pub fn chat_sessions_dir(bundle: &Path) -> PathBuf { bundle.join(CHAT_SESSIONS_DIR) } + +/// Absolute path to the `motion-documents/` directory inside `bundle`. +pub fn motion_documents_dir(bundle: &Path) -> PathBuf { + bundle.join(MOTION_DOCUMENTS_DIR) +} diff --git a/crates/opentake-project/src/lib.rs b/crates/opentake-project/src/lib.rs index f6bed6c8..9c0e1845 100644 --- a/crates/opentake-project/src/lib.rs +++ b/crates/opentake-project/src/lib.rs @@ -17,7 +17,8 @@ //! ├── generation-log.json # GenerationLog (AI generation audit, optional) //! ├── thumbnail.jpg # cover image (optional) //! ├── media/ # project-internal media (.project relative paths) -//! └── chat-sessions/ # agent chat history, one .json each +//! ├── chat-sessions/ # agent chat history, one .json each +//! └── motion-documents/ # Motion Studio HTML/CSS sources //! ``` //! //! ## What this crate provides @@ -58,7 +59,7 @@ mod safe_fs; pub mod xmlnode; pub use archive::{archive, ArchiveReport, MissingMedia}; -pub use bundle::{copy_media_dir, Project, ProjectCompatibility}; +pub use bundle::{copy_media_dir, Project, ProjectCompatibility, ThumbnailUpdate}; pub use edl::export_edl; pub use error::{ProjectError, Result}; pub use fcpxml::{export_xmeml, export_xmeml_with_timecodes}; diff --git a/crates/opentake-project/src/project_root.rs b/crates/opentake-project/src/project_root.rs index 0910d022..9d899d5f 100644 --- a/crates/opentake-project/src/project_root.rs +++ b/crates/opentake-project/src/project_root.rs @@ -377,6 +377,16 @@ impl ProjectRoot { ) } + /// Copy project-local Motion Studio sources during complete-bundle + /// publication through retained no-follow roots. + pub fn copy_motion_documents_to(&self, destination: &ProjectRoot) -> Result<()> { + self.copy_directory_component_to( + destination, + crate::layout::MOTION_DOCUMENTS_DIR, + "motion-documents-copy", + ) + } + /// Preserve the optional project cover across complete-bundle publication. pub(crate) fn copy_thumbnail_to(&self, destination: &ProjectRoot) -> Result<()> { if let Some(bytes) = self.read_optional(crate::layout::THUMBNAIL_FILE)? { @@ -500,6 +510,24 @@ impl ProjectRoot { Ok(()) } + /// Remove one configured optional project component through the retained + /// root. The final leaf is opened no-follow and verified as the same regular + /// file immediately before unlink, so this never reopens the ambient bundle + /// path or follows a substituted symlink. + pub(crate) fn remove_optional_component(&self, name: &str) -> Result<()> { + validate_leaf(name).map_err(|error| ProjectError::io(self.path.join(name), error))?; + if project_component_max_bytes(name).is_none() { + return Err(ProjectError::io( + self.path.join(name), + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "project component has no configured byte limit", + ), + )); + } + remove_file_artifact(&self.dir, &self.path, OsStr::new(name)) + } + /// Read one no-follow regular file from `chat-sessions/`, bounded before /// allocation and while streaming in case the retained file grows. pub fn read_chat_session(&self, name: &str, max_bytes: usize) -> Result>> { diff --git a/crates/opentake-project/tests/archive.rs b/crates/opentake-project/tests/archive.rs index 835524cc..a128ae23 100644 --- a/crates/opentake-project/tests/archive.rs +++ b/crates/opentake-project/tests/archive.rs @@ -222,7 +222,7 @@ fn missing_source_is_reported_and_kept_dangling() { } #[test] -fn carries_thumbnail_and_chat_sessions() { +fn carries_thumbnail_chat_sessions_and_motion_documents() { let tmp = TempDir::new("archive-extras"); let source_bundle = tmp.child("Src.opentake"); write_file(&source_bundle.join("thumbnail.jpg"), b"JPEGDATA"); @@ -234,6 +234,17 @@ fn carries_thumbnail_and_chat_sessions() { &source_bundle.join("chat-sessions").join("s2.json"), br#"{"id":"s2"}"#, ); + write_file( + &source_bundle.join("motion-documents").join("catalog.json"), + br#"{"schemaVersion":1,"documents":{}}"#, + ); + write_file( + &source_bundle + .join("motion-documents") + .join("rev-document") + .join("styles.css"), + b"body { background: #111; }", + ); let dest = tmp.child("Extras.opentake"); archive( @@ -251,6 +262,14 @@ fn carries_thumbnail_and_chat_sessions() { ); assert!(dest.join("chat-sessions").join("s1.json").is_file()); assert!(dest.join("chat-sessions").join("s2.json").is_file()); + assert_eq!( + std::fs::read(dest.join("motion-documents/catalog.json")).unwrap(), + br#"{"schemaVersion":1,"documents":{}}"# + ); + assert_eq!( + std::fs::read(dest.join("motion-documents/rev-document/styles.css")).unwrap(), + b"body { background: #111; }" + ); } #[test] diff --git a/crates/opentake-project/tests/roundtrip.rs b/crates/opentake-project/tests/roundtrip.rs index 996c5145..8a02cc3c 100644 --- a/crates/opentake-project/tests/roundtrip.rs +++ b/crates/opentake-project/tests/roundtrip.rs @@ -123,11 +123,22 @@ fn save_then_open_is_lossless() { assert_eq!(reopened.manifest, project.manifest); assert_eq!(reopened.generation_log, project.generation_log); // Thumbnail is not loaded back into memory by `open` (left on disk). - assert!(reopened.thumbnail.is_none()); + assert_eq!(reopened.thumbnail, None); let thumb = std::fs::read(bundle.join("thumbnail.jpg")).unwrap(); assert_eq!(thumb, b"\xff\xd8\xff\xe0JPEGDATA"); } +#[test] +fn project_thumbnail_field_remains_option_compatible() { + let tmp = TempDir::new("thumbnail-api"); + let mut project = Project::new(tmp.child("Compatibility.opentake")); + let thumbnail: Option> = Some(b"legacy public field".to_vec()); + + project.thumbnail = thumbnail.clone(); + + assert_eq!(project.thumbnail, thumbnail); +} + #[test] fn transition_survives_project_save_and_reopen() { let tmp = TempDir::new("transition-roundtrip"); diff --git a/crates/opentake-render/src/plan/build.rs b/crates/opentake-render/src/plan/build.rs index e64a7564..3c9031dd 100644 --- a/crates/opentake-render/src/plan/build.rs +++ b/crates/opentake-render/src/plan/build.rs @@ -8,7 +8,9 @@ use std::collections::{HashMap, HashSet}; -use opentake_domain::{Clip, ClipType, NestedSequence, Timeline, TransitionKind}; +use opentake_domain::{ + AnimatableProperty, Clip, ClipType, NestedSequence, Timeline, TransitionKind, +}; use super::affine::{affine_transform, compose, crop_to_uv}; use super::types::{ @@ -656,6 +658,170 @@ fn eval_transition_incoming<'a>( } impl RenderPlan { + fn transition_at(&self, index: usize, f: i32) -> Option<(&ClipPlan, &Clip, f64)> { + let plan = self.clip_plans.get(index)?; + let clip = &plan.clip; + let transition = clip.transition_out.as_ref()?; + let incoming_plan = self.clip_plans.get(index + 1)?; + if transition.kind != TransitionKind::CrossDissolve + || (!transition.from_clip_id.is_empty() && transition.from_clip_id != plan.clip_id) + || incoming_plan.blend_path != plan.blend_path + || incoming_plan.clip_id != transition.to_clip_id + || incoming_plan.start_frame != plan.end_frame + { + return None; + } + let duration = transition + .duration_frames + .max(1) + .min((plan.end_frame - plan.start_frame).max(1)) + .min((incoming_plan.end_frame - incoming_plan.start_frame).max(1)); + let start = plan.end_frame - duration; + if f < start || f >= plan.end_frame { + return None; + } + let progress = (f - start) as f64 / duration as f64; + Some((incoming_plan, &incoming_plan.clip, progress)) + } + + fn transition_interval(&self, index: usize) -> Option<(i32, i32)> { + let plan = self.clip_plans.get(index)?; + let transition = plan.clip.transition_out.as_ref()?; + let incoming_plan = self.clip_plans.get(index + 1)?; + if transition.kind != TransitionKind::CrossDissolve + || (!transition.from_clip_id.is_empty() && transition.from_clip_id != plan.clip_id) + || incoming_plan.blend_path != plan.blend_path + || incoming_plan.clip_id != transition.to_clip_id + || incoming_plan.start_frame != plan.end_frame + { + return None; + } + let duration = transition + .duration_frames + .max(1) + .min((plan.end_frame - plan.start_frame).max(1)) + .min((incoming_plan.end_frame - incoming_plan.start_frame).max(1)); + Some((plan.end_frame - duration, plan.end_frame)) + } + + fn transition_midpoint(&self, index: usize) -> Option { + let (start, end) = self.transition_interval(index)?; + Some((start + (end - start) / 2).min(end - 1)) + } + + fn representative_candidates(&self, index: Option, plan: &ClipPlan) -> Vec { + let midpoint = plan.start_frame + (plan.end_frame - plan.start_frame - 1) / 2; + let mut candidates = Vec::new(); + if let Some(transition_midpoint) = index.and_then(|index| self.transition_midpoint(index)) { + candidates.push(transition_midpoint); + } else { + candidates.push(midpoint); + } + + let mut anchors = vec![plan.start_frame, plan.end_frame - 1]; + for clip in std::iter::once(&plan.clip).chain( + plan.compound_ancestors + .iter() + .map(|ancestor| &ancestor.clip), + ) { + for property in [ + AnimatableProperty::Opacity, + AnimatableProperty::Position, + AnimatableProperty::Scale, + AnimatableProperty::Rotation, + AnimatableProperty::Crop, + ] { + anchors.extend(clip.keyframe_frames(property)); + } + if clip.fade_in_frames > 0 { + anchors.push(clip.start_frame); + anchors.push(clip.start_frame.saturating_add(clip.fade_in_frames)); + } + if clip.fade_out_frames > 0 { + anchors.push(clip.end_frame().saturating_sub(clip.fade_out_frames)); + anchors.push(clip.end_frame().saturating_sub(1)); + } + } + if let Some((start, end)) = index.and_then(|index| self.transition_interval(index)) { + anchors.push(start); + anchors.push(start + (end - start) / 2); + anchors.push(end - 1); + } + anchors.retain(|frame| *frame >= plan.start_frame && *frame < plan.end_frame); + anchors.sort_unstable(); + anchors.dedup(); + + for &anchor in &anchors { + candidates.push(anchor); + if anchor > plan.start_frame { + candidates.push(anchor - 1); + } + if anchor + 1 < plan.end_frame { + candidates.push(anchor + 1); + } + } + for interval in anchors.windows(2) { + candidates.push(interval[0] + (interval[1] - interval[0]) / 2); + } + let mut seen = HashSet::new(); + candidates.retain(|frame| seen.insert(*frame)); + candidates + } + + /// Pick the earliest deterministic frame which the authoritative plan says + /// produces a meaningful draw. Candidate order and transition adjacency are + /// derived from the flattened plan, not from persisted track/clip ordering. + pub fn representative_frame(&self, timeline: &Timeline) -> Option { + let mut groups = self + .clip_plans + .iter() + .enumerate() + .filter(|(_, plan)| plan_has_meaningful_source(plan)) + .map(|(index, plan)| { + ( + plan.start_frame, + index, + self.representative_candidates(Some(index), plan), + ) + }) + .chain( + self.text_plans + .iter() + .enumerate() + .filter(|(_, plan)| plan_has_meaningful_source(plan)) + .map(|(index, plan)| { + ( + plan.start_frame, + self.clip_plans.len() + index, + self.representative_candidates(None, plan), + ) + }), + ) + .collect::>(); + groups.sort_by_key(|(start, order, _)| (*start, *order)); + let mut seen = HashSet::new(); + + groups + .into_iter() + .flat_map(|(_, _, candidates)| candidates) + .filter(|frame| seen.insert(*frame)) + .find(|frame| { + self.frame(timeline, *frame) + .draws + .iter() + .any(|draw| self.draw_is_meaningful(draw)) + }) + } + + fn draw_is_meaningful(&self, draw: &LayerDraw<'_>) -> bool { + let plan = self + .clip_plans + .iter() + .chain(self.text_plans.iter()) + .find(|plan| plan.clip_id == draw.clip_id); + plan.is_some_and(plan_has_meaningful_source) && draw_has_meaningful_surface(draw) + } + /// Evaluate the ordered draw list for frame `f` (SPEC §2.4). /// /// `timeline` must be the same one the plan was built from (they share clip @@ -666,30 +832,7 @@ impl RenderPlan { for (index, plan) in self.clip_plans.iter().enumerate() { let clip = &plan.clip; - let transition = clip.transition_out.as_ref().and_then(|transition| { - let incoming_plan = self.clip_plans.get(index + 1)?; - if transition.kind != TransitionKind::CrossDissolve - || (!transition.from_clip_id.is_empty() - && transition.from_clip_id != plan.clip_id) - || incoming_plan.track_index != plan.track_index - || incoming_plan.clip_id != transition.to_clip_id - || incoming_plan.start_frame != plan.end_frame - { - return None; - } - let incoming = &incoming_plan.clip; - let duration = transition - .duration_frames - .max(1) - .min(clip.duration_frames.max(1)) - .min(incoming.duration_frames.max(1)); - let start = plan.end_frame - duration; - if f < start || f >= plan.end_frame { - return None; - } - let progress = (f - start) as f64 / duration as f64; - Some((incoming_plan, incoming, progress)) - }); + let transition = self.transition_at(index, f); if let Some(d) = eval_layer(plan, clip, f, self.render_size) { if d.opacity > 0.0 { @@ -717,3 +860,36 @@ impl RenderPlan { } } } + +fn plan_has_meaningful_source(plan: &ClipPlan) -> bool { + if plan.end_frame <= plan.start_frame { + return false; + } + match &plan.source { + TextureSource::Text { .. } => { + plan.clip + .text_content + .as_deref() + .is_some_and(|content| !content.trim().is_empty()) + && plan.clip.text_style.is_some() + } + TextureSource::Image { media_ref } + | TextureSource::Lottie { media_ref } + | TextureSource::Decoded { media_ref } => !media_ref.trim().is_empty(), + } +} + +fn draw_has_meaningful_surface(draw: &LayerDraw<'_>) -> bool { + let determinant = draw.affine[0] * draw.affine[3] - draw.affine[1] * draw.affine[2]; + draw.opacity.is_finite() + && draw.opacity > 0.0 + && draw.nat_size.0.is_finite() + && draw.nat_size.1.is_finite() + && draw.nat_size.0 > 0.0 + && draw.nat_size.1 > 0.0 + && draw.affine.iter().all(|value| value.is_finite()) + && determinant.is_finite() + && determinant.abs() > f64::EPSILON + && draw.crop_uv.0 < draw.crop_uv.2 + && draw.crop_uv.1 < draw.crop_uv.3 +} diff --git a/crates/opentake-render/src/plan/tests.rs b/crates/opentake-render/src/plan/tests.rs index d17d079b..a4d94f62 100644 --- a/crates/opentake-render/src/plan/tests.rs +++ b/crates/opentake-render/src/plan/tests.rs @@ -417,6 +417,98 @@ fn clear_color_is_opaque_black() { assert!(fp.draws.is_empty()); } +#[test] +fn representative_frame_uses_render_plan_order_and_skips_transparent_candidates() { + let mut transparent_early = video_clip("transparent-early", 0, 20); + transparent_early.opacity = 0.0; + let visible_late = video_clip("visible-late", 100, 20); + let mut timeline = Timeline::new(); + let mut track = Track::new("video", ClipType::Video); + // Persisted clip order is deliberately not timeline order. + track.clips = vec![visible_late, transparent_early]; + timeline.tracks.push(track); + + let plan = build_render_plan(&timeline, RS, &TestMetrics::default()); + + assert_eq!(plan.representative_frame(&timeline), Some(109)); +} + +#[test] +fn representative_frame_uses_render_plan_transition_adjacency_with_other_layers_present() { + let mut outgoing = video_clip("outgoing", 0, 30); + outgoing.transition_out = Some(Transition { + from_clip_id: "outgoing".into(), + to_clip_id: "incoming".into(), + kind: TransitionKind::CrossDissolve, + duration_frames: 10, + }); + let incoming = video_clip("incoming", 30, 30); + let mut background = Track::new("background", ClipType::Video); + // Deliberately unsorted persisted order: the plan owns adjacency. + background.clips = vec![incoming, outgoing]; + let mut overlay = Track::new("overlay", ClipType::Video); + overlay.clips.push(video_clip("overlay", 20, 12)); + let mut timeline = Timeline::new(); + timeline.tracks = vec![overlay, background]; + + let plan = build_render_plan(&timeline, RS, &TestMetrics::default()); + + assert_eq!(plan.representative_frame(&timeline), Some(25)); + assert_eq!(plan.frame(&timeline, 25).draws.len(), 3); +} + +#[test] +fn representative_frame_ignores_empty_text_and_degenerate_visual_boxes() { + let mut empty_text = Clip::new("empty-text", "", 0, 30); + empty_text.media_type = ClipType::Text; + empty_text.text_content = Some(" ".into()); + empty_text.text_style = Some(opentake_domain::TextStyle::default()); + let mut zero_box = video_clip("zero-box", 0, 30); + zero_box.transform.width = 0.0; + let mut text_track = Track::new("text", ClipType::Text); + text_track.clips.push(empty_text); + let mut video_track = Track::new("video", ClipType::Video); + video_track.clips.push(zero_box); + let mut timeline = Timeline::new(); + timeline.tracks = vec![text_track, video_track]; + + let plan = build_render_plan(&timeline, RS, &TestMetrics::default()); + + assert_eq!(plan.representative_frame(&timeline), None); +} + +#[test] +fn representative_frame_finds_opacity_visible_only_away_from_midpoint() { + let mut clip = video_clip("late-opacity", 0, 9); + clip.opacity_track = Some(KeyframeTrack::from_keyframes(vec![ + Keyframe::with_interpolation(0, 0.0, Interpolation::Hold), + Keyframe::new(8, 1.0), + ])); + let timeline = single_video_timeline(clip); + + let plan = build_render_plan(&timeline, RS, &TestMetrics::default()); + + assert_eq!(plan.representative_frame(&timeline), Some(8)); +} + +#[test] +fn representative_frame_finds_scale_visible_only_away_from_midpoint() { + let mut clip = video_clip("late-scale", 0, 9); + clip.scale_track = Some(KeyframeTrack::from_keyframes(vec![ + Keyframe::with_interpolation( + 0, + opentake_domain::AnimPair::new(0.0, 0.0), + Interpolation::Hold, + ), + Keyframe::new(8, opentake_domain::AnimPair::new(1.0, 1.0)), + ])); + let timeline = single_video_timeline(clip); + + let plan = build_render_plan(&timeline, RS, &TestMetrics::default()); + + assert_eq!(plan.representative_frame(&timeline), Some(8)); +} + // --- Source frame index (SPEC §2.5, upstream insertClip L301-343) --- #[test] diff --git a/docs/INDEX.md b/docs/INDEX.md index 678e1ab5..5e60acd4 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -63,5 +63,5 @@ docs/ | [CHANGELOG.md](../CHANGELOG.md) | 变更历史 | | [CONTRIBUTING.md](../CONTRIBUTING.md) | 贡献指南 | | [Specs Index](specs/INDEX.md) | 已批准/历史规格目录 | -| [Beta 发布与验证](releases/1.0.0-beta.4.md) · [最终模块验收](audit/2026-08-10/final-module-validation.md) | ★ 当前 Beta 4 范围、发布门槛与逐项执行证据 | +| [Beta 发布与验证](releases/1.0.0-beta.5.md) · [Motion Studio 验证](audit/2026-08-13/beta5-motion-studio.md) | ★ 当前 Beta 5 范围、发布门槛与逐项执行证据 | | [Superpowers Recovery](superpowers/specs/2026-07-08-opentake-recovery-integration-design.md) | 历史恢复集成设计与计划入口(Beta 2 已收口) | diff --git a/docs/audit/2026-08-13/beta5-external-mcp.md b/docs/audit/2026-08-13/beta5-external-mcp.md new file mode 100644 index 00000000..83849810 --- /dev/null +++ b/docs/audit/2026-08-13/beta5-external-mcp.md @@ -0,0 +1,95 @@ +# Beta 5 External MCP 持久连接边界审计 + +日期:2026-08-14 + +基线:`0506e9c198d8bc193848ad2456559961c57370c2` + +环境:macOS 26.6.1(25G76)、Rust/Cargo 1.96.0 + +## 结论 + +External MCP 的真实 loopback Streamable HTTP 边界通过了跨进程状态重建与安全矩阵。测试使用正式 `rmcp` 客户端、临时 catalog、每次运行唯一的 macOS Keychain service,以及仅由本次 pairing receipt 推导出的精确 account 清单。测试不会输出 bearer;结束和 panic 清理都只删除这些精确 accounts,正常结束还会逐项回读并确认不存在。 + +测试目标只在显式启用 `external-mcp-integration` feature 时编译;普通、`--no-default-features` 和发布构建中 `external_mcp` 模块保持私有,且不包含 public harness。进入该测试后仍必须显式设置 `OPENTAKE_RUN_REAL_KEYCHAIN_MCP=1` 才会访问真实 Keychain;未设置时安全跳过真实矩阵。本机已显式 opt in 并实际执行通过。 + +## 真实矩阵 + +| 边界 | 实际路径 | 结果 | +|---|---|---| +| 跨重启认证 | 第一个 lifecycle 写入临时 catalog/Keychain 后关闭;新建 lifecycle 使用同一目录和 service;正式 `rmcp` 客户端以原 bearer 重新初始化并列出工具 | 通过 | +| 撤销 | 保留第二个有效客户端和正在监听的 endpoint;撤销第一个客户端;旧 bearer 无法重新建立 `rmcp` 会话,第二个 bearer 仍可列出工具 | 通过 | +| Host / Origin | 对真实 endpoint 发送携带有效 bearer 的边界探针;非 loopback `Host` 与远端 `Origin` 分别返回 HTTP 403 | 通过 | +| 项目切换取消 | 正式 `rmcp` 会话发起阻塞的 `import_media`;并发打开另一个已保存项目;请求被取消且新项目 media 保持为空 | 通过 | +| undo 会话隔离 | 会话 A 创建 folder 后直接确认 folder 存在;会话 B 的 `undo` 被拒绝后 folder 仍存在;会话 A `undo` 后直接确认 folder 消失 | 通过 | +| 固定端口冲突 | 先占用 `127.0.0.1:19789` 再启用 endpoint;状态为 `portConflict`,未选择替代端口;释放后恢复监听 | 通过 | +| disable 关闭 socket | disable 返回后立即在 `127.0.0.1:19789` 重绑 | 通过 | +| 进程退出关闭 socket | 子测试进程在 listener 存活时直接 `process::exit(0)`;父进程等待退出后立即重绑固定端口 | 通过 | + +Host/Origin 是刻意使用 `reqwest` 的补充 HTTP 边界探针;所有正常会话、工具发现和工具调用均使用 `rmcp 2.2.0` 的正式 Streamable HTTP client,没有用 raw HTTP 代替协议客户端。 + +## 凭据泄漏与清理检查 + +测试在内存中保留每个生成的完整 bearer,仅用于认证和最终比较,不包含在断言消息、测试摘要或审计文档中。完成矩阵后,对以下原始字节逐个扫描每个完整 bearer,要求零匹配: + +- 测试矩阵日志; +- 全局 `tracing` subscriber 捕获到的事件及字段; +- 进程退出子测试的 stdout/stderr; +- 临时 `external-mcp/` catalog 目录内全部普通文件。 + +结果为零匹配。随后只删除 `external-mcp:` 形式且由本轮 receipt 记录的 accounts,并通过同一唯一 Keychain service 逐项回读确认 `None`。未使用 service 级或通配符清理。 + +## TDD 与依赖记录 + +RED 阶段先创建 integration test;`cargo test -p opentake-tauri --test external_mcp_integration --no-run` 因 `external_mcp` 模块私有、integration harness 缺失且 `rmcp` 未声明而失败。补齐最小 seam 后,`rmcp 2.2.0` 的 reqwest transport 暴露了锁定版 `sse-stream 0.2.3` 的兼容性问题:上游调用 `from_bytes_stream`,而 0.2.3 只有早期拼写 `from_byte_stream`。最终精确锁定 `sse-stream = 0.2.4`;该版本提供兼容 alias,测试恢复编译。 + +第一次真实运行还暴露了 reqwest 0.13/rustls 未安装进程级 crypto provider 的 panic。integration test 显式安装 `ring` provider 后转绿;没有修改产品 TLS 或 listener 行为。 + +## API 暴露审查 + +为了让 crate 外 integration test 组装真实生产 lifecycle,新增了专用 `external-mcp-integration` feature 和带 `required-features` 的测试目标。只有显式启用该 feature 时,`external_mcp` 模块、`ExternalMcpListenerState`、`ExternalMcpIntegrationHarness` 与 receipt 才对 crate 外可见;普通产品构建不编译 harness 或取消 probe。harness 不是 Tauri command,不绕过 bearer、Host/Origin 或 live-project gate,也不直接暴露 catalog/store。 + +取消 probe 的事件边沿使用 `Notify::notify_one()` 保留 permit,避免 waiter 在读取 atomic flag 与注册 `notified()` 之间丢失通知。一个 2,048 轮并发 signal/wait 回归专门锁定该边界。 + +## 验证记录 + +```text +OPENTAKE_RUN_REAL_KEYCHAIN_MCP=1 cargo test -p opentake-tauri --features external-mcp-integration --test external_mcp_integration -- --nocapture +PASS — 2 passed;真实矩阵全部通过;完整 bearer 扫描零匹配 + +cargo test -p opentake-tauri --features external-mcp-integration --test external_mcp_integration -- --nocapture +PASS — 2 passed;未 opt in 时真实 Keychain 矩阵安全跳过 + +cargo test -p opentake-tauri --features external-mcp-integration external_mcp::tests::integration_cancel_probe_never_loses_a_concurrent_entry_signal --lib +PASS — 1 passed;2,048 轮并发 signal/wait + +cargo test -p opentake-tauri --test external_mcp_integration --no-run +EXPECTED REFUSAL — 目标要求 `external-mcp-integration` + +cargo test -p opentake-tauri --no-default-features --test external_mcp_integration --no-run +EXPECTED REFUSAL — 目标要求 `external-mcp-integration` + +cargo test -p opentake-tauri external_mcp::tests --lib +PASS — 40 passed + +cargo test -p opentake-agent mcp::server::tests -- --nocapture +PASS — 30 passed +``` + +此外,已分别构建默认与 `--no-default-features` library,并用 `nm` +检查产物;两者均不含 `ExternalMcpIntegrationHarness` 或 +`IntegrationCancelProbe` 符号。 + +本轮 review fix 对 3 个 Task 6 所有的 Rust 文件执行了定向 +`rustfmt --check`,结果通过。严格定向 Clippy 仅被同时编辑中的 +`src-tauri/src/render.rs` 阻断:默认构建为 `dead_code`,no-default +构建另有 `too_many_arguments`。仅放行这两个与 Task 6 无关的 lint 后, +两个 Task 6 Clippy 目标均通过;父任务会在 composite 收敛后重跑无放行的统一门禁。 + +严格 Clippy、fmt 与 diff 检查结果见同任务报告。 + +## 限制 + +- 测试独占固定端口 `19789`,不能与运行中的 OpenTake External MCP listener 或同一测试的并行实例同时执行。 +- 项目切换取消使用 doc-hidden harness 注入的阻塞 media bridge,以确定性观察 request-local cancellation;传输、dispatcher、live-project gate 和取消路径均为真实实现,但不会启动真实解码器或媒体网络读取。 +- 进程退出场景证明 listener 存活时 OS 进程终止会释放 socket;它不启动完整 Tauri GUI event loop。`RunEvent::Exit` 主动 shutdown 路径由 lifecycle 单元测试覆盖。 +- 独立 security review 已完成;其发现的 public harness 面、取消通知丢失窗口和 live undo 状态证据缺口均已在本轮修复。 diff --git a/docs/audit/2026-08-13/beta5-motion-studio.md b/docs/audit/2026-08-13/beta5-motion-studio.md new file mode 100644 index 00000000..3c96f922 --- /dev/null +++ b/docs/audit/2026-08-13/beta5-motion-studio.md @@ -0,0 +1,144 @@ +# OpenTake Beta 5 Motion Studio 验证记录 + +日期:2026-08-14(Asia/Shanghai) + +分支:`release/v1.0.0-beta.5` + +验证基线:`354cd71 feat(agent): edit Motion Studio documents with hash-safe tools` + +## 结论 + +Motion Studio 的真实字符渲染、CSS 动画逐帧预览、项目内文档持久化、发布/重开、取消无副作用、Agent 哈希安全协作与许可证门禁均已由自动化测试和真实 Chromium/FFmpeg 路径验证通过。 + +本次环境没有可用的 macOS 桌面控制能力,因此没有声称完成“对打包后的 Tauri 应用做自动点击”这一项。界面结构、编辑与逐帧操作在浏览器壳层中做了可视检查;原生渲染、媒体发布、工程重开及取消语义由实际 Rust Chromium/FFmpeg 集成测试覆盖。浏览器壳层的绿色预览占位图没有作为渲染证据,预览截图由生产 `HeadlessChromiumRenderer` 直接生成。 + +## 可视工作流 + +在 1600×1000 视口进入 Motion Studio 后完成以下操作: + +1. 新建工程并从一级导航进入“动效工作台”。 +2. 在 `index.html` 写入中英文真实字符: + + ```html +
+

OpenTake Beta 5

+

真实字符 · Real characters

+

CSS animation · 逐帧预览

+
+ ``` + +3. 在 `styles.css` 写入径向渐变场景和 `beta-five-in`、`subtitle-in` 两段关键帧动画。 +4. 在时间线上看到两个关键帧条目,并从第 0 帧切到第 89 帧;界面读数为 `90 / 90`。 +5. 无障碍树确认存在主区域“动效工作台”、区域“HTML 与 CSS 编辑器”、区域“动效预览”、播放控制组、帧滑块、检查器和关键帧时间线。 + +界面全景: + +![Motion Studio editor](screenshots/motion-studio-editor.png) + +生产 Chromium 在第 60 帧渲染的 1280×720 真实预览: + +![Motion Studio real Chromium preview](screenshots/motion-studio-preview.png) + +文件校验: + +```text +motion-studio-editor.png 1600×1000 RGB PNG +SHA-256 c808bd9b290df3a49c0b021827ccbffe3121efbd05bd7d88b42c044cefb90501 + +motion-studio-preview.png 1280×720 RGBA PNG +SHA-256 06ba67b1f537d80112d5e0edd88f412b84e5c34c9b7455157cad87953d31e60b +``` + +## 原生渲染、发布与取消 + +`src-tauri/tests/motion_integration.rs` 通过真实 Chromium 与 FFmpeg 验证完整路径: + +- 生成包含“真实字符 Real text”和 CSS `@keyframes` 的逐帧画面;开始、中间和结束帧像素不同。 +- 发布结果含真实字形/场景像素,而不是空画面或占位图。 +- 添加后编辑同一 Motion 文档,保持 clip 身份并原子替换媒体。 +- 保存并重新打开 `.opentake` 工程后,timeline、media 和代表帧像素与保存前一致。 +- 第二次发布在已取消 token 下返回 `Cancelled`,前后 timeline 与 media 快照完全相同,没有新条目。 +- 非法尺寸被拒绝且同样不改变工程。 + +执行证据: + +```text +OPENTAKE_RUN_FFMPEG_TESTS=1 cargo test -p opentake-tauri --test motion_integration -- --nocapture +1 passed + +cargo test -p opentake-tauri --test motion_command -- --nocapture +1 passed +``` + +## Agent 协作与冲突 + +Agent/MCP 与 Web store 回归覆盖: + +- Agent 创建文档、读取权威 revision hash,并以 UTF-8 字节编辑写入中文内容。 +- patch 必须携带精确 baseline hash;过期 hash 返回结构化、非变更的 revision conflict。 +- 编辑器干净时安装 Agent 权威 revision;本地 dirty/saving/conflict/publishing 时保留本地内容并进入显式冲突处理,不静默覆盖。 +- 发布期间到达的新 Agent revision 会排队,并在发布终态后按项目身份重新读取。 +- create/patch/preview/publish 都绑定 IPC 接收时的项目 authority;Save As/Open 造成身份变化时取消旧项目操作。 +- 文档 ID、尺寸、帧数、源文件大小和结果大小均有严格边界;Agent 结果不包含文件系统路径。 + +对应最终门禁: + +```text +cargo test -p opentake-agent mcp:: +191 passed + +cargo test -p opentake-tauri motion_documents::tests --lib +17 passed + +cargo test -p opentake-tauri mcp::tests --lib +83 passed + +pnpm -C web exec vitest run src/components/motion/MotionCodeEditor.test.tsx \ + src/components/motion/MotionStudio.interaction.test.tsx \ + src/components/motion/MotionStudio.test.tsx \ + src/components/motion/MotionTimeline.test.ts \ + src/store/motionStudioStore.test.ts +5 files / 39 tests passed +``` + +## Motion 与许可证门禁 + +```text +cargo test -p opentake-motion --lib -- --nocapture +62 passed + +cargo test -p opentake-motion --all-features +97 unit tests passed + +cargo test -p opentake-motion --all-features --test chromium \ + -- --nocapture --test-threads=1 +7 passed; 4K opaque 5.801 s, transparent 9.077 s + +python3 -B -m unittest scripts/test_check_license_inventory.py +7 passed + +python3 -B scripts/check_license_inventory.py +passed + +pnpm -C web licenses list --prod +passed; CodeMirror production packages resolve to their recorded MIT licenses + +pnpm -C web test +149 files / 1365 tests passed + +pnpm -C web build +passed; only the existing dynamic-import and large-chunk warnings remain + +git diff --check +passed +``` + +第一次并行运行 live Chromium 集成时,4K opaque 帧完成后 transparent 用例在 180 秒超时,并使同一共享 gate 后续用例被 poison。4K 用例独立复跑通过(opaque 5.941 秒、transparent 9.469 秒),随后完整 Chromium 集成改为单线程复跑,7/7 在 39.74 秒内通过。该现象记录为测试并发资源争用,不被隐藏为一次全绿运行。 + +CodeMirror 依赖、解析出的精确版本、仓库来源与安装包内 MIT 许可证由 `scripts/check_license_inventory.py` 交叉校验;修改或删除任一清单项的 mutation tests 均会 fail closed。 + +## 覆盖边界 + +- 已验证:真实 Chromium 字符/CSS 动画、逐帧寻址、FFmpeg 发布、代表帧像素、保存重开、取消不提交、Agent stale-hash 冲突、项目切换隔离、Web 可视结构及许可证。 +- 未声称:本机打包 App 的自动鼠标/键盘点击录制。当前会话没有可用的 desktop-control skill/tool;浏览器壳层也不提供原生 Chromium PNG,因此其绿色占位预览已被真实生产渲染截图替换。 +- 发布包安装、签名、公证、DMG 哈希和 GitHub 资产上传属于最终 Beta 5 候选包流程,不由本审计记录代替。 diff --git a/docs/audit/2026-08-13/beta5-release-candidate.md b/docs/audit/2026-08-13/beta5-release-candidate.md new file mode 100644 index 00000000..a93a8394 --- /dev/null +++ b/docs/audit/2026-08-13/beta5-release-candidate.md @@ -0,0 +1,134 @@ +# OpenTake 1.0.0-beta.5 本地候选审计 + +日期:2026-08-15(Asia/Shanghai) + +候选分支:`release/v1.0.0-beta.5` + +候选源码:`a6e6ebf1de7a228cc61a630eb23144e5ed834245` + +环境:macOS 26.6.1 (25G76) arm64;Rust/Cargo 1.96.0;Node 22.17.1;pnpm 10.30.1;npm 10.9.2。 + +## 当前结论 + +Beta 5 的源码级、真实 Keychain/MCP、真实 Chromium/FFmpeg、Web、许可证、发布工作流、macOS ARM64 本地打包及最终 `.app` GUI 点击/截图门禁均已完成。ad-hoc `.app` 与 DMG 已生成并完成签名结构、磁盘映像、sidecar、版本/架构及真实启动检查;同一最终包完成 Home、素材库、设置、Motion Studio、真实 Codex 清空时间线 PNG 与标题栏像素验收。GitHub Actions 的两个 updater signing secret 名称已做只读存在性预检。当前候选仍不是可公开发布状态:尚未经过远端 PR/main CI、不可变 tag、真实签名产物与十七项资产验证。 + +## 版本与发布身份 + +- commit `35de353`:`chore(release): prepare v1.0.0-beta.5 metadata` +- commit `3226456`:`fix(release): enforce CodeMirror license inventory` +- commit `152c24c`:`fix(settings): allow packaged window resizing` +- commit `b79d81d`:`test(shell): measure packaged titlebar alignment` +- commit `d9a12ce`:`fix(agent): preserve Codex MCP image results` +- commit `a6e6ebf`:`fix(shell): calibrate packaged traffic lights` +- 11 个 `opentake-*` workspace package、Tauri 与 Web:`1.0.0-beta.5` +- Windows WiX:`1.0.0.5` +- 正常 tag push 只绑定 Beta 5;唯一获批 `workflow_dispatch` 恢复链只绑定 Beta 4 / WiX `1.0.0.4`。Beta 4 与 Beta 5 交叉身份均 fail closed。 +- 发布身份与许可修复的独立复核最终结论均为 Spec PASS / Quality APPROVE;五个 job digest 与受影响的 validate/quality run digest 均独立重算一致。CodeMirror fail-closed checker/test 已进入 CI 与 release workflow。两个 signing secret 名称已存在;完整发布只因远端 PR/main CI、真实签名产物与 tag 后资产验证尚未完成而保持 BLOCK。 + +## 聚焦产品门禁 + +| 范围 | 命令摘要 | 结果 | +|---|---|---| +| 外部 MCP lifecycle/catalog | `cargo test -p opentake-tauri external_mcp::tests --lib -- --test-threads=1` | 40/40 通过 | +| 真实 Keychain 与 loopback transport | `OPENTAKE_RUN_REAL_KEYCHAIN_MCP=1 cargo test -p opentake-tauri --features external-mcp-integration --test external_mcp_integration -- --nocapture --test-threads=1` | 2/2 通过;重启认证、Host/Origin、撤销、端口冲突、disable/exit socket、跨 session undo、项目切换取消与 token 扫描均通过 | +| Agent MCP / clear-timeline PNG | `cargo test -p opentake-agent mcp:: -- --test-threads=1` | 191/191 通过 | +| Agent ordered protocol | Agent `chat::` + Tauri `chat::tests` | 61/61 + 24/24 通过 | +| Motion document/publish/render bridge | Tauri `motion_documents::tests`、`motion::tests`、`render::tests` | 17/17 + 12/12 + 17/17 通过 | +| 设置、Storage、素材库、Home、标题栏、Agent、Motion UI | Beta 5 UI 聚焦矩阵;最终 Codex/标题栏增量矩阵 | 299/299 + 43/43 通过 | +| 真实 Chromium | `cargo test -p opentake-motion --all-features --test chromium -- --nocapture --test-threads=1` | 最终 7/7 通过;4K opaque 5.949 s、transparent 9.572 s,整组 40.38 s | +| 真实 Motion FFmpeg | `OPENTAKE_RUN_FFMPEG_TESTS=1 cargo test -p opentake-tauri --test motion_integration -- --nocapture --test-threads=1` | 1/1 通过 | +| Motion command | `cargo test -p opentake-tauri --test motion_command -- --nocapture --test-threads=1` | 1/1 通过 | + +Chromium 首次运行在 opaque 5.311 s 后,transparent 触发测试内 180 s timeout,随后三项因共享 gate poison 继发失败。进程结束后没有残留 Chrome;独立 4K 复跑以 6.336 s / 9.752 s 通过,随后同一完整单线程命令 7/7 通过。该首次失败保留为环境/共享浏览器资源波动证据,不被覆盖成一次全绿历史。 + +## 完整 Rust 与 Web 门禁 + +| 命令 | 结果 | +|---|---| +| `cargo test --workspace --no-fail-fast -- --test-threads=1` | exit 0;`--list` 发现 2,844 项;所有运行项通过。显式 ignored:Main10 真实素材 1、media Main10 fixture 1、真实设备 export probe 3、真实设备 playback probe 4 | +| `cargo clippy --workspace --all-targets --all-features -- -D warnings` | 通过;仅 Cargo 既有 `block 0.1.6` future-incompatibility 提示 | +| `cargo clippy -p opentake-tauri --no-default-features --all-targets -- -D warnings` | 通过 | +| `cargo fmt --all -- --check` | 通过 | +| playback-engine transport integration | 6/6 通过 | +| `pnpm -C web test` | 149 files / 1,371 tests 通过 | +| `pnpm -C web build` | 通过;仅既有 ineffective dynamic import 与大 chunk 警告 | +| `pnpm -C web install --frozen-lockfile --offline` | 通过,lockfile 无变化 | + +## 许可证、依赖与供应链 + +- `scripts/test_check_license_inventory.py`:10/10;`scripts/check_license_inventory.py`:通过。合同精确覆盖全部五个 CodeMirror 直接生产依赖,并拒绝任何未登记的第六个 `codemirror` / `@codemirror/*` 依赖。 +- Web production license inventory:CodeMirror/Lezer 与记录版本均为 MIT;Tauri/React 等许可证清单生成成功。 +- Motion Canvas `npm ci --ignore-scripts`:第一次下载收到 `ECONNRESET`;验证 1,639 个 npm cache object 后原命令重试成功。 +- Motion Canvas `npm audit --audit-level=moderate`:0 vulnerabilities;license lock:123 records;job tests:2/2;runner build 与已提交 `bundle/runner.html` byte-exact,无 diff。 +- `cargo audit` 在线更新 RustSec 时因 GitHub I/O 失败未完成 fetch。使用 2026-08-12 的本机 advisory-db(commit `69f93e1d…`)执行 `cargo audit --no-fetch --stale`:0 vulnerability,22 条允许的 informational warning;其中包含 Linux GTK3/glib 0.18 的 unmaintained/unsound 通告以及既有传递依赖通告。不能把该缓存结果表述为 2026-08-14 在线最新审计。 + +## 发布与完整性门禁 + +- Windows product contract:88/88。 +- updater attestation:5/5;updater manifest:12/12。 +- release workflow:默认 Beta 5 环境 83/83;Beta 4 recovery 环境完整 83/83;checker 通过。CI 与 Beta 5 release quality 均在 Web 依赖安装后运行许可 checker/test;Beta 4 recovery 对 CodeMirror 直接依赖执行通用空集合断言。 +- provisioner tests:7/7;Windows sidecar provision fixture 验证通过。 +- `actionlint`、严格 YAML parse、`git diff --check`:通过。 +- Gitleaks 8.30.1 以 `gitleaks git . --log-opts="50aebdfb2e0dad4cdeacfdcde1180444cfa96589..a6e6ebf1de7a228cc61a630eb23144e5ed834245" --no-banner --no-color --redact --report-format json --report-path -` 重扫 61 commits / 1,654,531 bytes:3 个命中仍全部来自 `ExternalMcpPane.test.tsx` / `SettingsView.interaction.test.tsx` 中固定假 `tokenDigest`(`abc123def456` / `def456abc123`)及历史版本;没有真实 token/private key。真实 Keychain MCP 矩阵对 captured log/catalog 的完整 token 扫描同样为 0 matches。 + +## macOS ARM64 本地打包证据 + +使用已校验的 `aarch64-apple-darwin` FFmpeg/FFprobe sidecar 执行: + +```text +./web/node_modules/.bin/tauri build --ci --target aarch64-apple-darwin \ + --bundles app,dmg \ + --config '{"bundle":{"createUpdaterArtifacts":false,"macOS":{"signingIdentity":"-"}}}' +``` + +- `.app`:`target/aarch64-apple-darwin/release/bundle/macos/OpenTake.app`,`du -sk` 为 170,040 KiB。 +- DMG:`target/aarch64-apple-darwin/release/bundle/dmg/OpenTake_1.0.0-beta.5_aarch64.dmg`,70,337,681 bytes,SHA-256 `be1b5334f0272a6378085ad913eff318ce85b2730be77c8a01febb4e148804b3`。 +- 主程序:75,284,176 bytes,SHA-256 `20babb3ad5f42313f29fda73cce903fb99032eda8b04dc2ac43ea51a1a3599d8`。 +- 包内 `Contents/MacOS/ffmpeg`:48,789,568 bytes,SHA-256 `a83e9395c338b9e759cfba5b797e4206e60d46572c05b7467d2a33e30f53fcc3`。 +- 包内 `Contents/MacOS/ffprobe`:48,731,824 bytes,SHA-256 `8a5ff4c6b60ce86cc6e4c7b0bd026aa7a6e91ebfb7748dbbcae68b6ef1d04739`。 +- `.app`、主程序及两个 sidecar 的 `codesign --verify --deep --strict` 均通过,签名类型均为 ad-hoc。 +- `hdiutil verify` 通过;挂载后的 `.app` 再次通过 `codesign`,挂载包内两个 sidecar 的执行检查通过。 +- `Info.plist` 为 `CFBundleShortVersionString=1.0.0-beta.5`、`CFBundleIdentifier=com.opentake.desktop`;主程序与 sidecar 均为 Mach-O arm64。 +- 使用最终 `.app/Contents/MacOS/opentake` 绝对路径启动,并为应用进程提供正式 `codex-cli 0.146.0` 所在 PATH;GUI 验收期间确认运行的 executable 精确来自该最终 bundle。第一次把相对路径传给 `open -na` 的失败属于命令用法错误,不计为产品启动失败。 + +本地构建有意关闭 updater artifact,并没有使用或伪造发布签名 secret;因此未生成 `.app.tar.gz.sig`,也不能替代远端签名/发布工作流。 + +## 可视证据 + +- `screenshots/motion-studio-editor.png`:1600×1000,110,680 bytes,SHA-256 `c808bd9b290df3a49c0b021827ccbffe3121efbd05bd7d88b42c044cefb90501`。 +- `screenshots/motion-studio-preview.png`:生产 `HeadlessChromiumRenderer` 输出 1280×720,639,940 bytes,SHA-256 `06ba67b1f537d80112d5e0edd88f412b84e5c34c9b7455157cad87953d31e60b`。 +- `screenshots/beta5-packaged-home.png`:最终 `.app` Home 与 16:9 项目卡,2132×1332,337,532 bytes,SHA-256 `7bc7f19ae79e919b459b9e6949842d07dfb78b4b2472958b53ef0f7b933c0f3b`。 +- `screenshots/beta5-packaged-library.png`:最终 `.app` 素材库,Home 为左栏首控件且页面头不重复,2132×1332,171,370 bytes,SHA-256 `a2a2ffe570f2d1a2ccddf6cdc915dceabe88458b31819bfc8d5f59c816189e83`。 +- `screenshots/beta5-packaged-agent.png`:正式 Codex 删除真实 Motion clip 后,`remove_clips` 披露内联显示生产 PNG,2132×1332,352,175 bytes,SHA-256 `8d479a608abb9763e1f15a3f59edb95941f32ae290cb9cfcbfb1cd003f4ef527`。 +- `screenshots/beta5-packaged-motion.png`:工程重开后独立 Motion Studio 的真实 HTML、CSS 动画预览、90 帧时间线与发布入口,2132×1332,277,986 bytes,SHA-256 `6cfadca6e44175ac3defbb29831dd361c94dfe6672bb5e4cac9c895df0194f51`。 +- `screenshots/beta5-packaged-settings.png`:最终 `.app` 外观设置只显示“深色 · 标准 / 深色 · 紧凑”,无浅色开关和勾号,2132×1332,156,492 bytes,SHA-256 `8a36c679fc90a22a8ddfd5fb6fb909bde2ca861c60268c53ed3be978eb839927`。 +- 最终 `.app` 的 Storage 页还实际打开并取消了 141 MB 模型的内联移除确认:说明文字在同一固定行下方呈现,没有跳到独立浮层或删除模型;正常/`prefers-reduced-motion` 进入退出、迟到异步和焦点恢复由 Storage/Reveal 自动化回归覆盖。 +- 最终 `.app` 的 External MCP 页实际执行启用、创建 `Beta5 packaged GUI` 配对、清除一次性回执、撤销与 Keychain readback。启用后 `127.0.0.1:19789` 由最终 bundle 进程监听;撤销后 catalog 记录 `revokedAt`,`security find-generic-password -s io.opentake.app -a external-mcp:` 返回 44 且端口无 listener。`enabled=true` 按设计持久化,但没有活跃客户端时 lifecycle 为 paused,未留下可连接端点。 +- 最终 `.app` 中已实际执行:重开有持久 Motion 文档的工程、发布 90 帧/3 秒 Motion Graphic、创建新 Agent 会话、输入 `Remove all clips from the timeline and report the result.`、等待正式 Codex 调用 `get_timeline` / `remove_clips` 并展开工具结果。时间线由 1 clip 变为空;新 session JSON 中 `remove_clips.result.content` 为 `text + image`,PNG base64 长度 10,268,且不存在 `structuredContent`。 +- 标题栏以最终 Agent PNG 和 macOS AX 几何共同测量。原始 AX receipt:window `position=202,162 size=1066,666`;close `219,173 16×16`、minimize `242,173 16×16`、fullscreen `265,173 16×16`;Home `284,167 26×27`、Chat `316,167 26×27`、Motion `348,167 26×27`。将屏幕坐标减窗口原点再乘 Retina scale 2,得到 PNG traffic group `34,22,124,32`,Home/Chat/Motion 分别为 `164,10,52,54` / `228,10,52,54` / `292,10,52,54`。完整重放命令与输出: + +```text +python3 -B scripts/measure_titlebar_alignment.py \ + docs/audit/2026-08-13/screenshots/beta5-packaged-agent.png \ + --scale 2 \ + --traffic-rect 'traffic:34,22,124,32' \ + --icon-rect 'home:164,10,52,54' \ + --icon-rect 'chat:228,10,52,54' \ + --icon-rect 'motion:292,10,52,54' + +traffic: center 19.000 CSS px +home: center 18.500 CSS px; deviation 0.500 CSS px +chat: center 18.500 CSS px; deviation 0.500 CSS px +motion: center 18.500 CSS px; deviation 0.500 CSS px +maximum deviation: 0.500 CSS px +PASS +``` +- Web/Tauri 几何合同继续覆盖 16:9 preview、38px titlebar、26×26 控件;macOS 原生 traffic light 配置使用经最终包实测校准的 `y=21`。 + +## 未完成 / 发布阻断 + +1. 本地 `.app`/DMG 已生成并完成 GUI/像素/内容验收,两个 updater signing secret 名称已在 Actions 中存在;但真实 `.app.tar.gz.sig` 只能由 tag workflow 生成,不能在本机伪造,十七项远端资产/attestation 验证尚未运行。 +2. 尚未 push release branch、创建/合并 PR、等待远端 main CI、创建 `v1.0.0-beta.5` annotated tag 或公开 GitHub prerelease。 +3. 本地 macOS 包仍为 ad-hoc 且未 notarize;Windows 仍无 Authenticode。必须继续在 release note 与最终 receipt 中明确。 + +在上述阻断关闭前,不创建、不移动也不发布 Beta 5 tag;Beta 4 tag 与资产保持不可变回滚候选。 diff --git a/docs/audit/2026-08-13/screenshots/beta5-packaged-agent.png b/docs/audit/2026-08-13/screenshots/beta5-packaged-agent.png new file mode 100644 index 00000000..b658fb73 Binary files /dev/null and b/docs/audit/2026-08-13/screenshots/beta5-packaged-agent.png differ diff --git a/docs/audit/2026-08-13/screenshots/beta5-packaged-home.png b/docs/audit/2026-08-13/screenshots/beta5-packaged-home.png new file mode 100644 index 00000000..bb70ae9d Binary files /dev/null and b/docs/audit/2026-08-13/screenshots/beta5-packaged-home.png differ diff --git a/docs/audit/2026-08-13/screenshots/beta5-packaged-library.png b/docs/audit/2026-08-13/screenshots/beta5-packaged-library.png new file mode 100644 index 00000000..8e99673f Binary files /dev/null and b/docs/audit/2026-08-13/screenshots/beta5-packaged-library.png differ diff --git a/docs/audit/2026-08-13/screenshots/beta5-packaged-motion.png b/docs/audit/2026-08-13/screenshots/beta5-packaged-motion.png new file mode 100644 index 00000000..86a2ad62 Binary files /dev/null and b/docs/audit/2026-08-13/screenshots/beta5-packaged-motion.png differ diff --git a/docs/audit/2026-08-13/screenshots/beta5-packaged-settings.png b/docs/audit/2026-08-13/screenshots/beta5-packaged-settings.png new file mode 100644 index 00000000..ab5c64d8 Binary files /dev/null and b/docs/audit/2026-08-13/screenshots/beta5-packaged-settings.png differ diff --git a/docs/audit/2026-08-13/screenshots/motion-studio-editor.png b/docs/audit/2026-08-13/screenshots/motion-studio-editor.png new file mode 100644 index 00000000..bf446317 Binary files /dev/null and b/docs/audit/2026-08-13/screenshots/motion-studio-editor.png differ diff --git a/docs/audit/2026-08-13/screenshots/motion-studio-preview.png b/docs/audit/2026-08-13/screenshots/motion-studio-preview.png new file mode 100644 index 00000000..987408d1 Binary files /dev/null and b/docs/audit/2026-08-13/screenshots/motion-studio-preview.png differ diff --git a/docs/releases/1.0.0-beta.5.md b/docs/releases/1.0.0-beta.5.md new file mode 100644 index 00000000..194651ee --- /dev/null +++ b/docs/releases/1.0.0-beta.5.md @@ -0,0 +1,70 @@ +# OpenTake 1.0.0-beta.5 + +候选日期:2026-08-14(Asia/Shanghai) + +## Beta 目标 + +Beta 5 将 Agent、外部 MCP 与动态内容从演示形态收敛为可持久、可验证的产品路径:外部客户端可经显式配对安全连接;Agent 文本和工具调用按真实顺序连续呈现;Motion Studio 提供独立的 HTML/CSS 编辑、逐帧 Chromium 预览和 FFmpeg 发布。与此同时,设置、素材库、Home 项目卡与 macOS 标题栏完成本轮交互整理。 + +## 主要变化 + +### 外部 MCP + +- 设置中可显式开启固定 `127.0.0.1:19789/mcp` 端点、创建客户端配对、复制可移植的 HTTP 配置、重新生成凭据和撤销客户端。 +- 凭据只存系统钥匙串;catalog 原子持久化且不含明文 token。Bearer、Host、Origin、协议、请求体和并发边界均 fail closed。 +- 端点启用状态跨重启保留;重新生成/撤销只取消目标客户端旧 generation 的 session/request,其他客户端继续工作。关闭、退出和端口冲突有确定的清理语义。 + +### Agent 对话与时间线结果 + +- assistant 文本、tool use 和 tool result 以带 message id、block index 与单调 sequence 的权威有序块流传输和持久化;工具调用穿插在文本中,无独立卡片边框。 +- gap、冲突重放、畸形终态和跨会话事件只阻断目标消息,并通过 exact-turn 权威历史重同步;切会话、切工程、卸载与重试不会串流或静默覆盖。 +- Agent 清空时间线后使用生产合成器返回真实 PNG;空时间线输出带实际时间码和画布信息,不再是抽象空白。 + +### Motion Studio + +- 一级导航新增 Motion Studio,提供 CodeMirror HTML/CSS 双文件编辑器、检查器、关键帧时间线、整数帧 scrub/playback、诊断和发布进度。 +- 受限 HTML/CSS 在离线 Chromium 沙箱中按确定性时钟渲染真实字符和 CSS 动画;禁止网络、脚本和危险活动内容。文档保存在当前工程内,使用 revision/result hash、原子写入和项目 identity 防止跨工程迟到写。 +- Agent/MCP 可列出、读取、创建、哈希安全 patch、预览和发布同一文档;过期 baseline 返回显式冲突,不覆盖本地 dirty 内容。 +- 发布复用生产 Chromium/FFmpeg 路径,支持添加/编辑、逐帧进度、取消、工程重开和媒体清理;发布事务与 Save As/完整 bundle replacement 串行。 +- CodeMirror 生产依赖及其精确版本、仓库和 MIT 许可证进入第三方清单,并由 fail-closed inventory test 校验。 + +### 设置、素材库与 Home + +- 移除未生效的深色/浅色外观切换及勾号布局;Beta 5 的标准与紧凑密度都使用深色主题,并以 radio 语义、串行原生 resize 和失败回滚切换。 +- 模型清理说明使用统一 Reveal 进入/退出动画,支持 reduced motion、异步 epoch 防迟到状态以及删除后的确定焦点恢复。 +- 素材库 Home 返回入口只保留在左侧分类栏顶部,并共享标题栏 safe area;具体素材页面不再重复放置返回按钮。 +- Home 移除 AI 生成记录展示;项目卡使用响应式 16:9 预览。保存/关闭从权威合成器生成封面,无封面时显示真实项目名、画布比例和轨道结构。 + +### macOS 标题栏 + +- 原生交通灯在 38px Overlay TitleBar 内使用最终打包 `.app` 实测校准的 `y=21`;其中心与 Home/Chat/Motion 控件中心偏差为 0.5 CSS px。左侧入口、View 菜单与右侧操作按钮保持 26×26 控件面且不再使用独立 `top` 偏移。 +- Home、素材库和 Motion Studio 使用同一组 titlebar safe-area token。 + +## 验证与审计 + +- 外部 MCP:[`beta5-external-mcp.md`](../audit/2026-08-13/beta5-external-mcp.md) +- Motion Studio:[`beta5-motion-studio.md`](../audit/2026-08-13/beta5-motion-studio.md) +- 最终本地候选矩阵将在 tag 前记录到 `docs/audit/2026-08-13/beta5-release-candidate.md`。 + +最终发布门禁包含 Rust workspace test/clippy/fmt、Web 全测与构建、真实 Keychain MCP、Chromium/FFmpeg、许可证/依赖审计、严格 release workflow 合同以及候选包 GUI 验收。没有匹配范围的真实证据时,不把源码存在或浏览器 fallback 表述为已完成原生验收。 + +## GitHub 自动发布流程 + +正常 tag push 只接受 `v1.0.0-beta.5` 形式的 `v` tag;product source SHA 与 release tooling SHA 必须相同,并等于当前远端 `main` HEAD。Cargo、Tauri 与 Web 版本均为 `1.0.0-beta.5`,Windows WiX 版本为 `1.0.0.5`。发布流程不创建 tag,只消费已存在且绑定远端已验证 main 的 annotated tag。 + +validate、quality、macOS ARM64 与 Windows x64 全部通过后,publish 才可生成并验证精确十七项资产。Tauri updater package、MSI/NSIS、各自签名与 attestation、tag-specific manifest 和 `SHA256SUMS` 必须在上传、下载回读、大小、SHA-256 与 Minisign 校验中完全一致。Updater 只接受固定 `appergb/OpenTake` HTTPS URL、严格递增 SemVer、精确 tag manifest 和与内置公钥匹配的签名。 + +现有 `workflow_dispatch` 恢复路径只保留已批准的 Beta 4 事故链,不自动扩展到 Beta 5。它同时要求 `failed_run_id`、`failed_run_id=31412976593` 与 `failed_recovery_run_id=31441693191`,原不可变 tag SHA 为 `2c4efdff9d2587c90cbcac0919f9d1d333d67d6a`,前驱 tooling 为 `924bc1102a9343e14c3beea2a3622b5d92ebff13`。校验必须证明 source → `924bc110…` 与 `924bc110…` → 当前远端 `main`,并把当前 release tooling 绑定到 `github.workflow_sha`。正常 tag push 与该历史恢复链都不创建、移动或删除 tag。 + +恢复时公开 release notes 从 exact tooling commit 读取并记录 notes commit;REST `target_commitish` 只做非空 schema 校验,远端 source oracle 始终是 tag peel 与 GraphQL `tagCommit.oid`。因此历史恢复能力不会把任意失败 run、任意新 tag 或工作区文件提升为可信发布输入。 + +## 平台与签名边界 + +- macOS 应用仍使用 ad-hoc 签名,不是 Developer ID 签名且未公证。 +- Windows 安装器仍未使用 Authenticode;Windows WebView2、MSI/NSIS 原地升级和平台文件系统边界必须由 exact-SHA Windows CI/实机证据确认。 +- `TAURI_SIGNING_PRIVATE_KEY` 与密码只从 GitHub Actions secrets 注入;缺失时失败,不生成未签名 updater。私钥不得进入 checkout、日志、receipt、manifest 或发布资产。 +- 最终打包 `.app` GUI 验收和两个 signing secret 名称预检已经完成;在远端 main CI 通过前不创建 `v1.0.0-beta.5` tag 或公开 prerelease。真实 updater 签名与十七项资产仍必须由 tag workflow fail closed 地生成和验证。 + +## 回滚 + +`v1.0.0-beta.4` tag、发布资产和审计保持不可变并作为回滚候选,绝不移动、删除或复用。若 Beta 5 公开后发现回归,保留 Beta 5 tag 与审计,回退到 Beta 4;任何修复只使用更高版本并重新通过完整门禁。 diff --git a/docs/superpowers/plans/2026-08-13-beta5-agent-conversation.md b/docs/superpowers/plans/2026-08-13-beta5-agent-conversation.md new file mode 100644 index 00000000..6e15d205 --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-beta5-agent-conversation.md @@ -0,0 +1,199 @@ +# OpenTake Beta 5 Agent Conversation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the timeline Agent into a stable continuous conversation whose text and tools render in authoritative order, whose streams never cross sessions, and whose destructive timeline results include a real composited PNG. + +**Architecture:** Rust chat messages remain the persisted truth, but streaming events address an explicit message and block. The Web store applies those events only to the active matching session and the panel renders blocks as one borderless assistant turn. Timeline mutations are observed at the dispatcher boundary; the transition from visible content to empty requests a bounded PNG from the Rust compositor and adds it to the matching tool-result block. + +**Tech Stack:** Rust/Serde, OpenTake dispatcher and compositor, Tauri events, React/TypeScript, Zustand, Vitest. + +## Global Constraints + +- Remove the Agent-local Chat/Motion mode completely; Motion Studio is a separate app view. +- Treat `ChatMessage.blocks` as the only render order when present; legacy `content` and `toolCalls` exist only for wire compatibility and migration. +- Assistant turns have no enclosing bubble or card border. Tool detail disclosure stays inline with the same response. +- Every stream mutation identifies both `sessionId` and `messageId`; stale or inactive sessions may persist independently but cannot alter the visible draft. +- Generate timeline images through Rust timeline/compositor code, never by screenshotting the WebView. +- Bound image dimensions and encoded bytes and keep image data out of text logs. + +--- + +### Task 1: Make block order explicit in the Rust chat event protocol + +**Files:** +- Modify: `crates/opentake-agent/src/chat/session.rs` +- Modify: `crates/opentake-agent/src/chat/loop.rs` +- Modify: `crates/opentake-agent/src/chat/mod.rs` +- Modify: `src-tauri/src/chat.rs` + +**Interfaces:** +- stable `AgentContentBlock` serialization for text, tool use, and tool result +- `LoopEvent::BlockDelta { session_id, message_id, block_index, delta }` +- `LoopEvent::BlockUpsert { session_id, message_id, block_index, block }` +- `LoopEvent::Done { session_id, message_id, message }` + +- [ ] **Step 1: Write failing order and migration tests** + + Test an assistant message ordered as text A, tool use 1, text B, tool use 2; a tool result containing text and image content; round-trip serialization; and deserialization of legacy content/toolCalls into the equivalent stable order. Test that `refresh_legacy_fields` derives compatibility fields without reordering blocks. + +- [ ] **Step 2: Verify RED** + + Run `cargo test -p opentake-agent chat::session::tests::blocks_ chat::loop::tests::events_ -- --nocapture`. Expected: block-addressed events and ordered construction helpers are missing. + +- [ ] **Step 3: Implement ordered block mutation** + + Add constructors and mutation methods that append text/tool blocks in event order and consolidate only adjacent text deltas. Keep legacy field derivation one-way from blocks. Extend loop events with stable message ids generated before the first delta and preserve those ids in the final message. + +- [ ] **Step 4: Adapt Tauri emission and Codex compatibility paths** + + Emit the new event payloads from both normal provider streaming and Codex execution. Ensure every error/cancellation finalizes the same message id. Retain a temporary decoder for prior event fields only where an already-open Beta 4 window can receive them during development. + +- [ ] **Step 5: Verify GREEN** + + Run `cargo test -p opentake-agent chat:: -- --nocapture` and `cargo test -p opentake-tauri chat::tests --lib`. + +- [ ] **Step 6: Commit the protocol** + + Commit as `refactor(agent): stream authoritative ordered content blocks`. + +### Task 2: Make the front-end chat store session- and block-safe + +**Files:** +- Modify: `web/src/lib/types.ts` +- Modify: `web/src/lib/api.ts` +- Modify: `web/src/store/chatStore.ts` +- Modify: `web/src/store/chatStore.test.ts` + +**Interfaces:** +- `beginMessage(sessionId, messageId)` +- `appendBlockDelta(sessionId, messageId, blockIndex, delta)` +- `upsertBlock(sessionId, messageId, blockIndex, block)` +- `finalize(sessionId, messageId, message)` + +- [ ] **Step 1: Write failing reducer tests** + + Cover text/tool/text order, multiple tool rounds, duplicate retry events, out-of-order block indices, final replacement, switching sessions mid-stream, deleting a session mid-stream, and late events from a previous session. Assert no tool call can merge into the nearest unrelated assistant message. + +- [ ] **Step 2: Verify RED** + + Run `pnpm -C web test -- src/store/chatStore.test.ts`. Expected: current nearest-assistant merging fails the session isolation and block-order cases. + +- [ ] **Step 3: Replace proximity matching with exact identity matching** + + Track drafts by session/message identity, apply immutable block updates, ignore malformed negative/huge indices, and preserve inactive session drafts separately until persisted history is reloaded. Derive visible `messages` only for the selected session. + +- [ ] **Step 4: Wire the typed event decoder** + + Validate event discriminants and required ids before dispatch. On a sequence gap or malformed payload, stop applying that message and request authoritative history instead of guessing an order. + +- [ ] **Step 5: Verify GREEN** + + Run `pnpm -C web test -- src/store/chatStore.test.ts src/components/agent/AgentPanel.persistence.test.tsx` and `pnpm -C web build`. + +- [ ] **Step 6: Commit the store** + + Commit as `fix(agent): isolate ordered streams by session and message`. + +### Task 3: Render one continuous borderless assistant turn + +**Files:** +- Modify: `web/src/components/agent/AgentPanel.tsx` +- Modify: `web/src/components/agent/AgentPanel.persistence.test.tsx` +- Create: `web/src/components/agent/AgentConversation.test.tsx` +- Modify: `web/src/styles/components.css` +- Modify: `web/src/i18n/dict.ts` + +**Interfaces:** +- `AssistantTurn` renders blocks sequentially. +- `InlineToolActivity` exposes collapsed status and accessible expanded arguments/results/images. +- User messages keep a quiet surface; assistant messages do not. + +- [ ] **Step 1: Write failing DOM/order tests** + + Render text A, tool use, tool result image, text B and assert exact DOM order, no assistant bubble class, no tool card border class, accessible expand/collapse, error state, and reduced-motion behavior. Assert the Chat/Motion tablist and its stored mode are absent. + +- [ ] **Step 2: Verify RED** + + Run `pnpm -C web test -- src/components/agent/AgentConversation.test.tsx src/components/agent/AgentPanel.persistence.test.tsx`. Expected: current bubble/card and panel-mode tests fail. + +- [ ] **Step 3: Build the continuous renderer** + + Replace the split content/tool rendering with a block switch. Keep tool rows on the text baseline, animate only detail height/opacity, render image blocks with constrained dimensions and alt text, and use live status labels without adding card chrome. + +- [ ] **Step 4: Remove Motion mode from AgentPanel** + + Delete local mode state, tab controls, motion-specific conditional content, and persistence keys. Keep current session selection/input operational across top-level app navigation. + +- [ ] **Step 5: Verify GREEN** + + Run the two focused test files, `pnpm -C web test -- src/components/agent`, and `pnpm -C web build`. + +- [ ] **Step 6: Commit the conversation UI** + + Commit as `feat(agent): render tools inline in continuous replies`. + +### Task 4: Attach a real PNG when the last visible timeline content is deleted + +**Files:** +- Modify: `crates/opentake-agent/src/mcp/dispatch.rs` +- Modify: `crates/opentake-agent/src/mcp/media_bridge.rs` +- Modify: `src-tauri/src/mcp.rs` +- Modify: `src-tauri/src/render.rs` +- Modify: `src-tauri/src/chat.rs` + +**Interfaces:** +- mutation receipt records visible clip count before/after execution +- `MediaBridge::capture_timeline_result(request) -> AgentToolResultContentBlock::Image` +- explicit empty-canvas compositor input with project width, height, fps, and playhead timecode + +- [ ] **Step 1: Write failing mutation receipt tests** + + Test deletion from one visible clip to zero, deletion that leaves another visible clip, non-visual mutations, failure/rollback, undo, and batched delete. Only the successful visible-to-empty transition must require an image. + +- [ ] **Step 2: Write failing compositor tests** + + Create a small deterministic empty project and assert returned bytes decode as PNG, dimensions are bounded, pixels include the canvas background and semantic empty-state overlay, and the tool result carries the image after its text summary. Add a non-empty fixture proving the authoritative compositor path is used. + +- [ ] **Step 3: Verify RED** + + Run `cargo test -p opentake-agent mcp::dispatch::tests::timeline_image_ -- --nocapture` and `cargo test -p opentake-tauri render::tests::empty_timeline_ --lib`. Expected: mutation receipts and empty-canvas PNG capture are absent. + +- [ ] **Step 4: Implement post-commit capture** + + Compare Rust timeline visibility before and after an admitted successful mutation. After commit, call the compositor at the current clamped playhead; for zero visible clips, render the explicit project canvas with timecode and localized-neutral empty marker. PNG-encode with the existing image crate, cap dimensions/bytes, and add the image to the same tool result. + +- [ ] **Step 5: Keep failure semantics atomic** + + A capture failure must not roll back a successful edit, but it must append a sanitized warning block. A failed edit, cancelled edit, or stale-project edit must never return a success image. + +- [ ] **Step 6: Verify GREEN** + + Run the focused tests, `cargo test -p opentake-agent mcp:: -- --nocapture`, and `cargo test -p opentake-tauri chat::tests render::tests --lib`. + +- [ ] **Step 7: Commit timeline result images** + + Commit as `feat(agent): show composited result after clearing timeline`. + +### Task 5: Verify conversation behavior in the packaged application + +**Files:** +- Create: `docs/audit/2026-08-13/beta5-agent-conversation.md` +- Create: `docs/audit/2026-08-13/screenshots/agent-continuous-conversation.png` +- Create: `docs/audit/2026-08-13/screenshots/agent-empty-timeline-result.png` + +- [ ] **Step 1: Run all automated Agent gates** + + Run `cargo test -p opentake-agent`, `cargo test -p opentake-tauri chat::tests --lib`, `pnpm -C web test -- src/store/chatStore.test.ts src/components/agent`, and `pnpm -C web build`. + +- [ ] **Step 2: Exercise a real multi-tool conversation** + + In the packaged Tauri app, execute a request that produces text, at least two tools, and final text. Switch sessions during a stream and return. Confirm order, no cross-session mutation, inline disclosure, and no standalone tool cards. + +- [ ] **Step 3: Exercise clear-to-empty** + + Place visible content, ask Agent to delete it, and confirm the ordered tool result includes a decodable PNG showing the empty project canvas rather than an empty JSON result or WebView screenshot. + +- [ ] **Step 4: Record evidence and commit** + + Record exact commands and observed packaged-app behavior, include the two screenshots, and commit as `test(agent): verify Beta 5 continuous conversation`. diff --git a/docs/superpowers/plans/2026-08-13-beta5-external-mcp.md b/docs/superpowers/plans/2026-08-13-beta5-external-mcp.md new file mode 100644 index 00000000..1cfef77e --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-beta5-external-mcp.md @@ -0,0 +1,221 @@ +# OpenTake Beta 5 Long-Lived External MCP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore a persistent, authenticated loopback MCP endpoint that shares the in-app Agent dispatcher and survives application restarts without persisting plaintext bearer tokens. + +**Architecture:** `ExternalMcpState` owns a listener task and an atomic pairing catalog. The catalog keeps non-secret client metadata in application data while `KeyringStore` keeps one bearer token per client. `opentake-agent` exposes a dynamically authenticated Streamable HTTP endpoint; every accepted session is gated by the production `LiveProjectMcpGate` and uses the same dispatcher, capability bridges, and plugin registry as in-app chat. + +**Tech Stack:** Rust, Tokio, Axum/rmcp Streamable HTTP, Tauri 2 managed state and events, system keyring, React/TypeScript, Zustand, Vitest. + +## Global Constraints + +- Bind only `127.0.0.1:19789`; never fall back to a random port or broader interface. +- Start only when external MCP is enabled and at least one non-revoked client exists. +- Return a plaintext 256-bit bearer token only from pair/regenerate commands and never serialize it into metadata, logs, errors, telemetry, events, or tests. +- Authenticate `/mcp` and well-known routes with constant-time token comparison plus loopback Host/Origin checks before rmcp session creation. +- Reuse `ChatState`'s dispatcher and plugin registry; do not construct a second tool universe. +- Keep a distinct undo scope per rmcp session and cancel affected requests immediately on revoke or project transition. +- Preserve all user-owned `docs/audit/2026-08-07/*` changes and assets. + +--- + +### Task 1: Add an atomic, keychain-backed pairing catalog + +**Files:** +- Create: `src-tauri/src/external_mcp.rs` +- Modify: `src-tauri/src/secret.rs` +- Modify: `src-tauri/src/lib.rs` + +**Interfaces:** +- `ExternalMcpClientSummary { id, name, token_digest, created_at, last_used_at, revoked_at }` +- `ExternalMcpPairingReceipt { client, endpoint, bearer_token }` +- `ExternalMcpCatalog::{load,pair,regenerate,revoke,active_credentials}` +- keychain account name `external-mcp:` under the existing OpenTake service. + +- [ ] **Step 1: Write failing catalog tests** + + Add unit tests proving pair creates a unique client id and 32-byte random token, persisted JSON omits the token, a restart reloads metadata and retrieves the token from a fake secret store, regeneration invalidates the previous token, revoke removes the secret, duplicate display names remain distinguishable, and a failed atomic rename leaves the previous catalog readable. + +- [ ] **Step 2: Verify RED** + + Run `cargo test -p opentake-tauri external_mcp::tests::catalog --lib`. Expected: compilation fails because the catalog and secret-store seam do not exist. + +- [ ] **Step 3: Implement the secret-store seam and catalog** + + Introduce a narrow `McpSecretStore` trait implemented by the existing keyring wrapper and by an in-memory test double. Persist only versioned metadata beneath `app_data_dir/external-mcp/clients.json` using create-new temp file, sync, rename, and parent sync. Generate credentials with the operating-system RNG, expose only a short SHA-256 digest, validate names and lengths, and serialize timestamps in a stable integer representation. + +- [ ] **Step 4: Verify GREEN** + + Run `cargo test -p opentake-tauri external_mcp::tests::catalog --lib`. All catalog and restart tests must pass without touching the developer's real keychain. + +- [ ] **Step 5: Commit the catalog** + + Commit `src-tauri/src/external_mcp.rs`, `src-tauri/src/secret.rs`, and the module declaration as `feat(mcp): add keychain-backed pairing catalog`. + +### Task 2: Generalize Streamable HTTP authentication for long-lived credentials + +**Files:** +- Modify: `crates/opentake-agent/src/mcp/server.rs` +- Modify: `crates/opentake-agent/src/mcp/mod.rs` + +**Interfaces:** +- `trait BearerAuthorizer { fn authorize(&self, token: &str) -> Option; }` +- `AuthenticatedMcpClient { client_id: Arc, credential_generation: u64 }` +- `ManagedMcpEndpoint { addr, shutdown(), wait() }` +- `bind_managed_gated_on(listener, dispatcher, registry, gate, authorizer)` for production and deterministic tests. + +- [ ] **Step 1: Write failing transport/authentication tests** + + Cover missing authorization, wrong token, revoked token, token regeneration, malformed bearer syntax, remote Host, remote Origin, loopback Origin, valid initialize, and shutdown. Assert all authentication failures have the same public status/body shape and captured tracing output does not contain supplied tokens. + +- [ ] **Step 2: Verify RED** + + Run `cargo test -p opentake-agent mcp::server::tests::managed_ -- --nocapture`. Expected: tests fail because the dynamic authorizer and managed endpoint are absent. + +- [ ] **Step 3: Implement dynamic authorization and managed shutdown** + + Refactor the existing single-token guard into a shared boundary that parses once and delegates matching to `BearerAuthorizer`. Compare all active token byte strings in constant time, attach the authenticated client identity to request extensions, retain Beta 4 body/content-type/protocol/concurrency limits, and add an explicit cancellation token plus join handle for listener shutdown. + +- [ ] **Step 4: Preserve ephemeral behavior** + + Adapt `bind_ephemeral_gated` to the new shared boundary with a one-entry authorizer, keeping its random port, one-time token receipt, Host/Origin behavior, and tests unchanged. + +- [ ] **Step 5: Verify GREEN** + + Run `cargo test -p opentake-agent mcp::server::tests -- --nocapture` and `cargo test -p opentake-agent chat:: -- --nocapture`. Both suites must pass. + +- [ ] **Step 6: Commit the transport** + + Commit the agent crate changes as `feat(mcp): authenticate managed loopback sessions`. + +### Task 3: Promote project gating to production and share the Agent tool universe + +**Files:** +- Modify: `src-tauri/src/mcp.rs` +- Modify: `src-tauri/src/chat.rs` +- Modify: `src-tauri/src/external_mcp.rs` + +**Interfaces:** +- production `LiveProjectMcpGate` implementing `ChatTurnGate` +- `ChatState::external_mcp_components() -> ExternalMcpComponents` +- `ExternalMcpState::new(core, components, catalog)` + +- [ ] **Step 1: Write failing shared-state and transition tests** + + Add tests proving external state receives pointer-identical dispatcher/registry values from `ChatState`, refuses mutating calls with no saved project, cancels an active old-project request before activating the new identity, rejects a stale identity, and keeps undo isolated across two rmcp sessions and in-app chat. + +- [ ] **Step 2: Verify RED** + + Run `cargo test -p opentake-tauri mcp::tests::live_project_ external_mcp::tests::shared_ --lib`. Expected: the production external components and public gate construction are unavailable. + +- [ ] **Step 3: Move the proven gate out of test-only compilation** + + Remove the `cfg(test)` boundary from the gate and its required imports, keep test-only helpers gated, and make transition admission/cancellation usable by the listener. Do not weaken project identity or side-effect termination checks. + +- [ ] **Step 4: Expose shared ChatState components** + + Add a crate-private immutable component bundle that clones Arcs for the existing dispatcher and registry. Construct `ExternalMcpState` from that bundle during Tauri setup after the core and bridges are ready. + +- [ ] **Step 5: Verify GREEN** + + Run `cargo test -p opentake-tauri mcp::tests --lib` and `cargo test -p opentake-tauri external_mcp::tests::shared_ --lib`. + +- [ ] **Step 6: Commit the shared gate** + + Commit as `refactor(mcp): share live project dispatcher with external sessions`. + +### Task 4: Implement the listener lifecycle and typed Tauri command surface + +**Files:** +- Modify: `src-tauri/src/external_mcp.rs` +- Modify: `src-tauri/src/lib.rs` +- Modify: `web/src/lib/types.ts` +- Modify: `web/src/lib/api.ts` + +**Interfaces:** +- `external_mcp_status() -> ExternalMcpStatus` +- `external_mcp_set_enabled(enabled) -> ExternalMcpStatus` +- `external_mcp_pair(name) -> ExternalMcpPairingReceipt` +- `external_mcp_regenerate(client_id) -> ExternalMcpPairingReceipt` +- `external_mcp_revoke(client_id) -> ExternalMcpStatus` +- event `external_mcp_status_changed` + +- [ ] **Step 1: Write failing lifecycle tests** + + Cover disabled startup, restart recovery, zero-client shutdown, port conflict, status transition ordering, pair while enabled, revoke of the final client, regenerate cancellation, application shutdown, and last-used timestamp updates without high-frequency disk writes. + +- [ ] **Step 2: Verify RED** + + Run `cargo test -p opentake-tauri external_mcp::tests::lifecycle_ --lib`. Expected: command/state lifecycle types do not yet exist. + +- [ ] **Step 3: Implement a serialized state machine** + + Guard lifecycle changes with one async mutex; expose `disabled`, `starting`, `listening`, `portConflict`, `authFailure`, and `paused` states; bind the fixed IPv4 socket before reporting listening; cancel old client sessions on regenerate/revoke; and stop the endpoint in Tauri exit handling. Emit sanitized summaries only. + +- [ ] **Step 4: Register commands and front-end types** + + Register all five commands in `generate_handler!`, add exact camelCase TypeScript DTOs and API wrappers, and provide a typed listener that can re-sync after missed events. + +- [ ] **Step 5: Verify GREEN** + + Run `cargo test -p opentake-tauri external_mcp::tests --lib`, `pnpm -C web test -- src/lib/api.test.ts`, and `pnpm -C web build`. + +- [ ] **Step 6: Commit the lifecycle** + + Commit as `feat(mcp): manage persistent external endpoint lifecycle`. + +### Task 5: Replace the external MCP settings placeholder with pairing management + +**Files:** +- Modify: `web/src/components/settings/SettingsView.tsx` +- Create: `web/src/components/settings/ExternalMcpPane.tsx` +- Create: `web/src/components/settings/ExternalMcpPane.test.tsx` +- Modify: `web/src/i18n/dict.ts` +- Modify: `web/src/styles/components.css` + +**Interfaces:** +- One enable switch, authoritative status row, fixed endpoint display, client list, and pair/regenerate/revoke/config-copy actions. +- Copy payload uses the documented Streamable HTTP endpoint and bearer header without storing the token in browser persistence. + +- [ ] **Step 1: Write failing interaction tests** + + Assert disabled/listening/port-conflict/auth-failure views, enable rollback on command failure, one-time token reveal, config copy, confirmation before regenerate/revoke, token removal after dismiss, and no credential text in rendered status after navigation/reload. + +- [ ] **Step 2: Verify RED** + + Run `pnpm -C web test -- src/components/settings/ExternalMcpPane.test.tsx`. Expected: the pane and API interactions are absent. + +- [ ] **Step 3: Implement the pane** + + Build the settings UI with existing tokens and shared disclosure motion, accessible labels, clear destructive confirmations, clipboard failure feedback, and status re-sync on mount/event. Do not render an enabled state until the backend reports listening. + +- [ ] **Step 4: Verify GREEN** + + Run `pnpm -C web test -- src/components/settings/ExternalMcpPane.test.tsx src/components/settings/SettingsView.interaction.test.tsx` and `pnpm -C web build`. + +- [ ] **Step 5: Commit settings integration** + + Commit as `feat(settings): manage external MCP pairings`. + +### Task 6: Exercise a real restart and security matrix + +**Files:** +- Create: `src-tauri/tests/external_mcp_integration.rs` +- Create: `docs/audit/2026-08-13/beta5-external-mcp.md` + +- [ ] **Step 1: Add an opt-in real-keychain integration harness** + + Use unique test service/account identifiers, bind only loopback, start a client through rmcp, restart the state against the same temporary catalog/keychain namespace, and clean up only those exact test credentials. + +- [ ] **Step 2: Run the live transport matrix** + + Run `cargo test -p opentake-tauri --test external_mcp_integration -- --nocapture` plus the agent server test suite. Record authenticated restart, revoke rejection, Host/Origin rejection, project-switch cancellation, cross-session undo isolation, and port-conflict results. + +- [ ] **Step 3: Audit logs and persisted bytes** + + Search captured logs and catalog files for each generated full token and require zero matches. Confirm the listener socket is closed after disable and process exit. + +- [ ] **Step 4: Commit verified evidence** + + Commit the integration test and Markdown receipt as `test(mcp): verify persistent external connection boundary`. diff --git a/docs/superpowers/plans/2026-08-13-beta5-interface-polish.md b/docs/superpowers/plans/2026-08-13-beta5-interface-polish.md new file mode 100644 index 00000000..1f61a1bb --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-beta5-interface-polish.md @@ -0,0 +1,294 @@ +# OpenTake Beta 5 Interface Polish Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove misleading appearance controls, animate conditional copy without layout flashes, relocate library navigation, replace Home placeholders with useful 16:9 previews, and align macOS traffic lights with every title-bar icon. + +**Architecture:** A shared disclosure primitive owns conditional text motion and reduced-motion behavior. Settings retain only the real dark standard/compact window choice with optimistic rollback. Library and Home are simplified at their source components. Project saves request an authoritative representative composite for thumbnails. All window chrome uses one CSS geometry contract mirrored by Tauri's macOS traffic-light configuration. + +**Tech Stack:** React/TypeScript, Zustand, CSS tokens, Tauri 2, Rust compositor/image encoding, Vitest, packaged macOS GUI measurement. + +## Global Constraints + +- Always use the dark token set in Beta 5; do not display dark/light choices or persist a theme setting. +- Standard/compact options have stable equal geometry and no checkmark; failed native resize restores the prior selection. +- Conditional copy enters and exits through the shared primitive in 150–200ms, or immediately under `prefers-reduced-motion`. +- Put Library Home navigation at the top of the left category rail only. +- Remove Home generation activity UI and its Home-specific request/effect, but retain backend audit data used elsewhere. +- Use `aspect-ratio: 16 / 9` for project preview surfaces and actual project content where available. +- Final alignment acceptance comes from a packaged `.app`, not browser-only CSS inspection. + +--- + +### Task 1: Create a shared disclosure motion primitive + +**Files:** +- Create: `web/src/components/ui/Reveal.tsx` +- Create: `web/src/components/ui/Reveal.test.tsx` +- Modify: `web/src/styles/tokens.css` +- Modify: `web/src/styles/components.css` + +**Interfaces:** +- `Reveal { open, children, id?, role?, onExited? }` +- CSS tokens `--motion-disclosure-duration` and `--motion-disclosure-ease`. + +- [ ] **Step 1: Write failing lifecycle tests** + + Assert open content mounts, close retains content through exit then unmounts, rapid reopen cancels unmount, measured block size does not flash from auto/zero, focus leaves hidden content, and reduced-motion closes synchronously. + +- [ ] **Step 2: Verify RED** + + Run `pnpm -C web test -- src/components/ui/Reveal.test.tsx`. Expected: component and tokens are absent. + +- [ ] **Step 3: Implement measured disclosure** + + Use a wrapper and inner content element with ResizeObserver, CSS custom block size, opacity, and translate. Keep layout reserved throughout exit, clean timers/listeners, handle dynamic content height, and set the duration token to zero in the existing reduced-motion media query. + +- [ ] **Step 4: Verify GREEN** + + Run the focused test and `pnpm -C web build`. + +- [ ] **Step 5: Commit the primitive** + + Commit as `feat(ui): add shared disclosure motion`. + +### Task 2: Remove theme switching and make standard/compact reliable + +**Files:** +- Modify: `web/src/store/settingsStore.ts` +- Create: `web/src/store/settingsStore.test.ts` +- Modify: `web/src/components/settings/SettingsView.tsx` +- Modify: `web/src/components/settings/SettingsView.interaction.test.tsx` +- Modify: `web/src/components/settings/SettingsView.visual.test.ts` +- Modify: `web/src/components/ui/Dropdown.tsx` +- Modify: `web/src/App.tsx` +- Modify: `web/src/App.lifecycle.test.tsx` +- Modify: `web/src/i18n/dict.ts` + +**Interfaces:** +- delete `Theme`, `theme`, `setTheme`, `applyTheme`, and `initTheme`. +- startup clears legacy `theme` and versioned theme keys and sets `document.documentElement.dataset.theme = "dark"` only if compatibility CSS still requires it. +- `setWindowSize(mode)` returns/awaits native success and rolls state back on rejection. + +- [ ] **Step 1: Write failing migration and rollback tests** + + Seed dark/light legacy keys and assert startup removes them and stays dark. Assert Appearance contains exactly two equal-width choices, “深色 · 标准” and “深色 · 紧凑”, no check icon, stable text offsets, native resize success, and rollback/error on rejection. + +- [ ] **Step 2: Verify RED** + + Run `pnpm -C web test -- src/store/settingsStore.test.ts src/components/settings/SettingsView.interaction.test.tsx src/components/settings/SettingsView.visual.test.ts`. Expected: current theme control/checkmark and optimistic failure behavior violate assertions. + +- [ ] **Step 3: Remove the unused theme state and UI** + + Delete theme loading/storage/actions and App initialization. Replace the generic checked segmented appearance control with two stable layout cells whose selected state changes color/background only. Keep keyboard radiogroup semantics. + +- [ ] **Step 4: Make native window selection transactional** + + Store the previous mode, call the existing Tauri resize command, commit/persist only on success, and restore plus toast on failure. Ignore a stale failure from an earlier click after a later choice has succeeded. + +- [ ] **Step 5: Verify GREEN** + + Run focused tests, full settings tests, App lifecycle tests, and `pnpm -C web build`. + +- [ ] **Step 6: Commit appearance changes** + + Commit as `fix(settings): keep only stable dark window layouts`. + +### Task 3: Animate model-clear confirmation without a text jump + +**Files:** +- Modify: `web/src/components/settings/StoragePane.tsx` +- Modify: `web/src/components/settings/StoragePane.test.tsx` +- Modify: `web/src/styles/components.css` + +**Interfaces:** +- clear confirmation uses `Reveal` within the selected model row. +- destructive action remains disabled during deletion and returns to the stable row on completion/cancel. + +- [ ] **Step 1: Write failing geometry and interaction tests** + + Assert the first clear click expands confirmation inside the row, explanation remains mounted during exit, sibling row top offsets change through the disclosure wrapper instead of immediate insertion, cancel/delete animate closed, repeated clicks do not duplicate copy, and reduced-motion is immediate. + +- [ ] **Step 2: Verify RED** + + Run `pnpm -C web test -- src/components/settings/StoragePane.test.tsx`. Expected: direct conditional text insertion fails lifecycle/geometry assertions. + +- [ ] **Step 3: Integrate the shared primitive** + + Keep action and confirmation in a fixed row layout, animate the explanation/action group with `Reveal`, preserve focus on cancel, move focus to the next valid control after successful deletion, and keep backend failures visible through the same disclosure. + +- [ ] **Step 4: Verify GREEN** + + Run Storage and Reveal tests plus `pnpm -C web build`. + +- [ ] **Step 5: Commit storage motion** + + Commit as `fix(settings): animate model removal confirmation`. + +### Task 4: Move Library Home navigation into the category rail + +**Files:** +- Modify: `web/src/components/media/LibraryView.tsx` +- Modify: `web/src/components/media/LibraryView.test.tsx` +- Modify: `web/src/styles/components.css` + +**Interfaces:** +- `CategoryTree` owns the single Home button above category content and beneath title-bar safe space. +- The right content header contains only category title, search, sort, and filter controls. + +- [ ] **Step 1: Write the failing structure test** + + Assert one Home button exists, it is the first interactive element in the left navigation, it is absent from the right header for every category, and the category selection remains unchanged after return/re-entry. + +- [ ] **Step 2: Verify RED** + + Run `pnpm -C web test -- src/components/media/LibraryView.test.tsx`. Expected: current Home action is in the content header. + +- [ ] **Step 3: Relocate the action and apply safe-area spacing** + + Pass the navigation callback into `CategoryTree`, render it once above the category list, and use shared title-bar safe-area tokens. Remove the old content-header button and any duplicate mobile rendering. + +- [ ] **Step 4: Verify GREEN** + + Run Library tests and `pnpm -C web build`. + +- [ ] **Step 5: Commit navigation adjustment** + + Commit as `fix(library): place Home navigation in the global rail`. + +### Task 5: Remove Home generation activity and build useful 16:9 project cards + +**Files:** +- Modify: `web/src/components/home/HomeView.tsx` +- Modify: `web/src/components/home/HomeView.test.tsx` +- Modify: `web/src/components/home/HomeView.interaction.test.tsx` +- Modify: `web/src/components/home/HomeView.visual.test.ts` +- Modify: `web/src/lib/api.ts` +- Modify: `web/src/styles/components.css` + +**Interfaces:** +- no `GenerationActivity` state/effect/render branch in Home. +- project preview is a semantic figure with `aspect-ratio: 16 / 9`. +- fallback shows project name, canvas ratio, and a small track structure visualization. + +- [ ] **Step 1: Write failing absence and card tests** + + Assert Home never calls the generation-activity API, contains no generation record region, renders thumbnail URLs as images with 16:9 geometry and object-fit cover, and renders a structured named fallback rather than a lone Film icon. + +- [ ] **Step 2: Verify RED** + + Run `pnpm -C web test -- src/components/home/HomeView.test.tsx src/components/home/HomeView.interaction.test.tsx src/components/home/HomeView.visual.test.ts`. Expected: generation region and 48px preview fail. + +- [ ] **Step 3: Remove the Home-only generation request** + + Delete the component, state, effect, polling, imports, and Home invocation. Retain shared API methods only if another view/test calls them; otherwise remove the dead front-end wrapper without changing backend audit storage. + +- [ ] **Step 4: Rebuild the card visual hierarchy** + + Use responsive card columns, a 16:9 preview figure, actual thumbnail when present, and a structured CSS fallback that exposes project title and known aspect/track metadata. Preserve project open, context menu, keyboard focus, and loading states. + +- [ ] **Step 5: Verify GREEN** + + Run all Home tests and `pnpm -C web build`. + +- [ ] **Step 6: Commit Home UI changes** + + Commit as `fix(home): simplify activity and show useful project previews`. + +### Task 6: Generate project covers from the authoritative composite + +**Files:** +- Modify: `crates/opentake-media/src/thumbnail/project.rs` +- Modify: `crates/opentake-media/src/thumbnail/mod.rs` +- Modify: `crates/opentake-media/src/lib.rs` +- Modify: `src-tauri/src/commands.rs` +- Modify: `src-tauri/src/home.rs` + +**Interfaces:** +- `capture_project_composite_thumbnail(snapshot, manifest, frame, bounds) -> Option>` +- save/close writes `thumbnail.jpg` atomically only after successful composite encode. + +- [ ] **Step 1: Write failing composite thumbnail tests** + + Use a project with background video, overlay image/text, transform, and transition; assert the cover includes composite-layer evidence rather than the decoded representative source alone. Cover empty project, missing/offline source, invalid prior thumbnail, deterministic bounds, and atomic write failure. + +- [ ] **Step 2: Verify RED** + + Run `cargo test -p opentake-media thumbnail::project::tests::composite_ -- --nocapture` and `cargo test -p opentake-tauri home::tests::thumbnail_ --lib`. Expected: current source-only capture fails layered fixtures. + +- [ ] **Step 3: Reuse the compositor at a representative frame** + + Select a stable frame from visible content, build the same render snapshot as preview/export, composite at a bounded 16:9 output, encode JPEG, and atomically replace the bundle thumbnail. On capture failure retain the last valid thumbnail rather than deleting it. + +- [ ] **Step 4: Verify GREEN** + + Run focused media/Tauri tests and the existing project save/open test suites. + +- [ ] **Step 5: Commit composite covers** + + Commit as `feat(home): save composited project cover frames`. + +### Task 7: Align traffic lights and title-bar controls to one geometry contract + +**Files:** +- Modify: `web/src/components/shell/TitleBar.tsx` +- Modify: `web/src/components/shell/TitleBar.visual.test.ts` +- Modify: `web/src/components/shell/ShellComponentMapping.test.tsx` +- Modify: `web/src/styles/tokens.css` +- Modify: `web/src/styles/components.css` +- Modify: `src-tauri/tauri.conf.json` +- Create: `scripts/measure_titlebar_alignment.py` +- Create: `scripts/test_measure_titlebar_alignment.py` + +**Interfaces:** +- CSS variables `--titlebar-height`, `--titlebar-center-y`, `--titlebar-control-size`, `--titlebar-safe-left`. +- measurement script accepts packaged-app PNG plus traffic-light/icon sample rectangles and fails above 1 CSS px center deviation. + +- [ ] **Step 1: Write failing static and measurement tests** + + Require every left/right title-bar button to use the shared size/alignment class, forbid local vertical transforms/margins, and test the image measurement math with aligned and 2px-offset synthetic fixtures. + +- [ ] **Step 2: Verify RED** + + Run `pnpm -C web test -- src/components/shell/TitleBar.visual.test.ts src/components/shell/ShellComponentMapping.test.tsx` and `python3 -B -m unittest scripts/test_measure_titlebar_alignment.py`. Expected: missing shared geometry/script and current offsets fail. + +- [ ] **Step 3: Consolidate title-bar geometry** + + Use one grid/flex center line and 26px control boxes for all navigation/action icons, remove per-button y nudges, and derive safe areas from tokens. Set Tauri's `trafficLightPosition.y` to the matching packaged macOS center after accounting for native button radius. + +- [ ] **Step 4: Verify static GREEN** + + Run focused tests and `pnpm -C web build`. + +- [ ] **Step 5: Measure the packaged app** + + Build the release `.app`, capture Home, Library, Motion Studio, and Editor title bars at 1x CSS scale, run the measurement script, and require the traffic-light group center and all icon centers to differ by no more than 1 CSS px. + +- [ ] **Step 6: Commit geometry and evidence tooling** + + Commit as `fix(shell): align traffic lights and title-bar controls`. + +### Task 8: Record packaged UI evidence + +**Files:** +- Create: `docs/audit/2026-08-13/beta5-interface-polish.md` +- Create: `docs/audit/2026-08-13/screenshots/settings-dark-layouts.png` +- Create: `docs/audit/2026-08-13/screenshots/library-home-rail.png` +- Create: `docs/audit/2026-08-13/screenshots/home-project-cards.png` +- Create: `docs/audit/2026-08-13/screenshots/titlebar-alignment.png` + +- [ ] **Step 1: Run automated UI gates** + + Run focused tests from Tasks 1–7, full Web tests/build, relevant Rust thumbnail/home tests, visual contract scripts, and `git diff --check`. + +- [ ] **Step 2: Exercise model removal and layout switching** + + In the packaged app, record enter/exit for model clear at normal and reduced motion; switch standard/compact repeatedly; provoke one native resize error in a test harness and confirm rollback. Verify no text/checkmark shift and no light option. + +- [ ] **Step 3: Exercise Library and Home** + + Verify the sole Library Home control is atop the left rail, Home has no generation activity, and saved projects with content display composite 16:9 covers while an empty legacy project displays the structured fallback. + +- [ ] **Step 4: Record alignment receipts and commit** + + Store exact commands, screenshots, measured centers, and limitations; commit as `test(ui): verify Beta 5 interface polish`. diff --git a/docs/superpowers/plans/2026-08-13-beta5-motion-studio.md b/docs/superpowers/plans/2026-08-13-beta5-motion-studio.md new file mode 100644 index 00000000..a606b0e3 --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-beta5-motion-studio.md @@ -0,0 +1,308 @@ +# OpenTake Beta 5 Motion Studio Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a first-level Motion Studio where users and Agent edit the same real HTML/CSS files, preview deterministic visible animation, and publish through OpenTake's existing Chromium/FFmpeg atomic timeline path. + +**Architecture:** Each project owns a capability-confined motion document directory containing a manifest, `index.html`, and `styles.css`. Tauri provides typed atomic document commands and delegates preview/render to `opentake-motion`. React presents a CodeMirror editor, live 16:9 canvas, parameters, and keyframe timeline. MCP document tools use the same backend with baseline hashes, so user and Agent edits converge on one source of truth. + +**Tech Stack:** React 18, CodeMirror 6 MIT packages, TypeScript, Rust, Tauri 2, cap-std, SHA-256, headless Chromium CDP, FFmpeg, existing `motion_add`/`motion_edit`. + +## Global Constraints + +- Motion Studio is a top-level app view between Chat and Panel Management, never an Agent sub-tab. +- Only UTF-8 `index.html` and `styles.css` under the current project's controlled motion root are editable. +- Reject absolute paths, traversal, symlinks, network access, filesystem URLs, oversized documents, stale hashes, and unbounded render dimensions/duration. +- Save atomically and keep the last successful preview visible when a new preview fails. +- Preview and final render use the same source, dimensions, fps, duration, deterministic clock, and network-disabled Chromium sandbox. +- A failed or cancelled publish leaves media manifest and timeline unchanged. +- Record exact CodeMirror packages, versions, copyright, repository, and MIT license in third-party notices. + +--- + +### Task 1: Add the CodeMirror dependency contract and license evidence + +**Files:** +- Modify: `web/package.json` +- Modify: `web/pnpm-lock.yaml` +- Modify: `THIRD_PARTY_NOTICES.md` +- Modify: `scripts/check_license_inventory.py` +- Modify: `scripts/test_check_license_inventory.py` + +**Interfaces:** +- Runtime packages: `codemirror`, `@codemirror/lang-html`, `@codemirror/lang-css`, `@codemirror/theme-one-dark`. +- License inventory maps each resolved package/version to its official MIT source and installed license file. + +- [ ] **Step 1: Write the failing license inventory test** + + Require all four packages and their license entries, and add a mutation fixture that removes one notice or changes one resolved version. + +- [ ] **Step 2: Verify RED** + + Run `python3 -B -m unittest scripts/test_check_license_inventory.py`. Expected: missing CodeMirror package/notice failures. + +- [ ] **Step 3: Install pinned compatible dependencies** + + Use `pnpm -C web add codemirror@6.0.2 @codemirror/lang-html@6.4.12 @codemirror/lang-css@6.3.1 @codemirror/theme-one-dark@6.1.3`. Update notices from the installed packages and official repositories; do not add Animate.css, EasyLogic, or Motionity code. + +- [ ] **Step 4: Verify GREEN** + + Run the license test, `pnpm -C web build`, and `pnpm -C web licenses list --prod` if the installed pnpm supports the command. + +- [ ] **Step 5: Commit the dependency boundary** + + Commit as `build(motion): add licensed CodeMirror editor dependencies`. + +### Task 2: Build the project-confined motion document store + +**Files:** +- Create: `src-tauri/src/motion_documents.rs` +- Modify: `src-tauri/src/lib.rs` +- Modify: `src-tauri/Cargo.toml` + +**Interfaces:** +- `MotionDocumentSummary { id, title, revision_hash, updated_at }` +- `MotionDocument { summary, html, css, parameters }` +- `MotionDocumentStore::{list,create,read,save_patch}` +- patch request includes `document_id`, `file`, `baseline_hash`, replacement edits, and expected result hash. + +- [ ] **Step 1: Write failing store and confinement tests** + + Cover initial template creation, visible Chinese/English title/subtitle, atomic restart persistence, concurrent stale hash, traversal, absolute path, symlink escape, invalid UTF-8, oversized input, invalid manifest, and failed rename preserving prior content. + +- [ ] **Step 2: Verify RED** + + Run `cargo test -p opentake-tauri motion_documents::tests --lib`. Expected: module and commands are absent. + +- [ ] **Step 3: Implement capability-confined storage** + + Resolve the motion root from the currently saved project bundle, open it through `cap-std`, map document ids to generated safe directory names, and expose only the two known files. Normalize line endings, hash exact UTF-8 bytes, apply non-overlapping bounded edits, and atomically replace files plus manifest. + +- [ ] **Step 4: Add typed commands** + + Register `motion_document_list`, `motion_document_create`, `motion_document_read`, and `motion_document_patch`; each captures current project identity and fails closed if the project changes before commit. + +- [ ] **Step 5: Verify GREEN** + + Run the focused tests and `cargo test -p opentake-tauri motion_documents:: --lib`. + +- [ ] **Step 6: Commit the store** + + Commit as `feat(motion): persist confined HTML and CSS documents`. + +### Task 3: Add deterministic single-frame preview from the production renderer + +**Files:** +- Modify: `crates/opentake-motion/src/source.rs` +- Modify: `crates/opentake-motion/src/renderer.rs` +- Modify: `crates/opentake-motion/src/sandbox.rs` +- Modify: `crates/opentake-motion/src/integration.rs` +- Modify: `src-tauri/src/motion.rs` + +**Interfaces:** +- `MotionPreviewRequest { document_id, revision_hash, width, height, fps, duration_frames, frame }` +- `MotionPreviewResponse { revision_hash, frame, png_data_url, diagnostics }` +- HTML runtime calls `window.OpenTake.seek(seconds)` before deterministic capture. + +- [ ] **Step 1: Write failing source and deterministic-clock tests** + + Assert generated source contains the document's real title/subtitle, local CSS, OpenTake seek bridge, blocked network policy, and no filesystem URL. Render the same frame twice and require identical decoded pixel hashes; render two animation frames and require a meaningful pixel difference. + +- [ ] **Step 2: Verify RED** + + Run `cargo test -p opentake-motion preview_ --features chromium -- --nocapture`. Expected: document preview API is absent; live tests may explicitly skip only when the pinned Chromium sidecar is unavailable. + +- [ ] **Step 3: Implement the preview source and capture path** + + Generate a self-contained HTML document from sanitized user HTML/CSS, inject deterministic animation controls before user content, disable fetch/XHR/WebSocket/navigation, apply dimensions/fps/frame bounds, and capture PNG through the existing CDP process manager. Return structured line/column diagnostics and retain no browser process after cancellation. + +- [ ] **Step 4: Expose the Tauri command** + + Read the requested revision from `MotionDocumentStore`, reject a stale hash, call the production renderer, bound PNG/data URL size, and return sanitized diagnostics. + +- [ ] **Step 5: Verify GREEN** + + Run default offline motion tests, feature-gated live preview tests, and `cargo test -p opentake-tauri motion::tests --lib`. + +- [ ] **Step 6: Commit preview support** + + Commit as `feat(motion): preview real HTML and CSS deterministically`. + +### Task 4: Add the Motion Studio top-level view and navigation entry + +**Files:** +- Modify: `web/src/store/uiStore.ts` +- Modify: `web/src/store/uiStore.test.ts` +- Modify: `web/src/App.tsx` +- Modify: `web/src/App.lifecycle.test.tsx` +- Modify: `web/src/components/shell/TitleBar.tsx` +- Modify: `web/src/components/shell/TitleBar.interaction.test.tsx` +- Create: `web/src/components/motion/MotionStudio.tsx` +- Create: `web/src/components/motion/MotionStudio.test.tsx` +- Modify: `web/src/i18n/dict.ts` + +**Interfaces:** +- `AppView` adds `motion`. +- Title-bar order: Home, Chat, Motion Studio, Panel Management. +- Motion view mounts independently while editor/chat state remains in stores. + +- [ ] **Step 1: Write failing navigation tests** + + Assert the four buttons exist in exact order with 26px hit areas, selecting Motion mounts only Motion Studio, returning to Chat preserves the active chat session, and reloading an invalid persisted view falls back safely. + +- [ ] **Step 2: Verify RED** + + Run `pnpm -C web test -- src/store/uiStore.test.ts src/components/shell/TitleBar.interaction.test.tsx src/components/motion/MotionStudio.test.tsx`. Expected: `motion` is not a valid app view and no entry exists. + +- [ ] **Step 3: Add the view and semantic shell** + + Extend the store and App view switch, add the title-bar button with localized label/tooltip, and create landmarks for file rail, editor, preview canvas, inspector, and timeline. Keep editor hooks mounted without rendering the editor layout in Motion view. + +- [ ] **Step 4: Verify GREEN** + + Run focused tests and `pnpm -C web build`. + +- [ ] **Step 5: Commit navigation** + + Commit as `feat(motion): add Motion Studio as a primary view`. + +### Task 5: Implement the editor, live canvas, parameters, and keyframe strip + +**Files:** +- Modify: `web/src/components/motion/MotionStudio.tsx` +- Create: `web/src/components/motion/MotionCodeEditor.tsx` +- Create: `web/src/components/motion/MotionPreview.tsx` +- Create: `web/src/components/motion/MotionTimeline.tsx` +- Create: `web/src/components/motion/MotionStudio.interaction.test.tsx` +- Create: `web/src/store/motionStudioStore.ts` +- Create: `web/src/store/motionStudioStore.test.ts` +- Modify: `web/src/lib/types.ts` +- Modify: `web/src/lib/api.ts` +- Modify: `web/src/styles/components.css` + +**Interfaces:** +- HTML/CSS tabs backed by one controlled CodeMirror instance. +- 300ms debounced atomic patch, then preview; revision conflict offers reload or explicit reapply. +- Preview controls update frame without changing saved source. +- Publish parameters share width/height/fps/duration with backend request. + +- [ ] **Step 1: Write failing store and UI tests** + + Cover document load, HTML/CSS tab state, visible initial text, debounced save, stale response suppression, compile diagnostic line/column, retained last-good frame, play/pause/replay/scrub, parameter bounds, narrow layout folding order, keyboard focus, and reduced motion. + +- [ ] **Step 2: Verify RED** + + Run `pnpm -C web test -- src/store/motionStudioStore.test.ts src/components/motion/MotionStudio.interaction.test.tsx`. Expected: store/components do not exist. + +- [ ] **Step 3: Implement state and CodeMirror lifecycle** + + Create one editor view per mounted code panel, swap language extensions by active file, dispatch controlled source updates without cursor reset, dispose on unmount, and serialize saves through revision hashes. Keep errors adjacent to the affected source tab. + +- [ ] **Step 4: Implement the authoring layout** + + Build the Songxia/Codex-inspired dark workspace with low chrome: files/templates/history left, code and 16:9 preview center, parameters right, frame ruler/keyframes below. Use real text in the starter document and semantic buttons/sliders. + +- [ ] **Step 5: Implement deterministic preview scheduling** + + Abort superseded requests, ignore stale revision/frame replies, retain last success on failure, and ensure playback advances integer frames based on the configured fps. + +- [ ] **Step 6: Verify GREEN** + + Run all Motion front-end tests, `pnpm -C web test`, and `pnpm -C web build`. + +- [ ] **Step 7: Commit the authoring UI** + + Commit as `feat(motion): build HTML and CSS authoring workspace`. + +### Task 6: Publish the document through the existing atomic timeline path + +**Files:** +- Modify: `src-tauri/src/motion.rs` +- Modify: `crates/opentake-motion/src/integration.rs` +- Modify: `web/src/components/motion/MotionStudio.tsx` +- Modify: `web/src/lib/api.ts` +- Modify: `src-tauri/tests/motion_integration.rs` + +**Interfaces:** +- `MotionAddCommand` and edit request accept `document_id` plus revision hash while retaining legacy code/template inputs. +- Publish response identifies the committed clip, media asset, render hash, and source document. + +- [ ] **Step 1: Write failing publish integration tests** + + Publish a short visible text animation and verify decoded beginning/middle/end frames contain expected non-background pixels and differ over time. Cover cancellation, FFmpeg failure, stale document, invalid dimensions, reopen/re-render equivalence, and edit replacement without duplicate media registration. + +- [ ] **Step 2: Verify RED** + + Run `OPENTAKE_RUN_FFMPEG_TESTS=1 cargo test -p opentake-tauri --test motion_integration -- --nocapture`. Expected: document-backed publish cases fail while existing Motion Canvas cases remain green. + +- [ ] **Step 3: Connect document source to motion add/edit** + + Resolve the exact revision once, generate the same production source used by preview, render integer frames, encode through the provisioned FFmpeg sidecar, validate the output and cache manifest, then use the existing atomic add/edit commit. Remove staged output on every pre-commit failure/cancel path. + +- [ ] **Step 4: Wire publish UI** + + Disable publish while unsaved or preview-invalid, show frame progress and cancellation, and navigate to/select the committed timeline clip only after the backend returns success. + +- [ ] **Step 5: Verify GREEN** + + Run the integration suite, `cargo test -p opentake-motion --all-features`, the Tauri motion tests, and Motion front-end tests. + +- [ ] **Step 6: Commit publishing** + + Commit as `feat(motion): publish Studio documents atomically`. + +### Task 7: Give Agent conflict-safe Motion document tools + +**Files:** +- Create: `crates/opentake-agent/src/mcp/motion_documents.rs` +- Modify: `crates/opentake-agent/src/mcp/mod.rs` +- Modify: `crates/opentake-agent/src/mcp/server.rs` +- Modify: `src-tauri/src/mcp.rs` +- Modify: `src-tauri/src/motion_documents.rs` + +**Interfaces:** +- tools `list_motion_documents`, `read_motion_document`, `create_motion_document`, `patch_motion_document`, `preview_motion_document`, `publish_motion_document`. +- `MotionDocumentBridge` is capability-limited to current-project typed operations. + +- [ ] **Step 1: Write failing schema and capability tests** + + Verify exact JSON schemas, read/list limits, hash-required patch, stale conflict, traversal/absolute/symlink rejection, preview bounds, publish admission, project switch cancellation, and no raw filesystem path in results. + +- [ ] **Step 2: Verify RED** + + Run `cargo test -p opentake-agent mcp::motion_documents::tests -- --nocapture` and the Tauri MCP bridge tests. Expected: the bridge/tools are absent. + +- [ ] **Step 3: Implement typed bridge and handlers** + + Keep filesystem access entirely behind the Tauri bridge, register tools in the shared plugin registry, return revision hashes on every read/write, translate conflicts into structured non-mutating results, and reuse the production preview/publish functions. + +- [ ] **Step 4: Verify GREEN** + + Run all agent MCP tests and Tauri MCP tests, then use in-app Agent to change starter title/CSS and confirm the open editor receives the authoritative revision. + +- [ ] **Step 5: Commit Agent integration** + + Commit as `feat(agent): edit Motion Studio documents with hash-safe tools`. + +### Task 8: Verify real characters, animation, persistence, and cancellation + +**Files:** +- Create: `docs/audit/2026-08-13/beta5-motion-studio.md` +- Create: `docs/audit/2026-08-13/screenshots/motion-studio-editor.png` +- Create: `docs/audit/2026-08-13/screenshots/motion-studio-preview.png` + +- [ ] **Step 1: Run automated Motion gates** + + Run Rust default/all-feature tests, live Chromium tests, FFmpeg integration, all Motion UI tests, full Web tests/build, license inventory, and `git diff --check`. + +- [ ] **Step 2: Exercise the packaged workflow** + + Create a document, visibly edit Chinese and English characters plus CSS animation, scrub frames, publish, reopen the project, and compare preview/published representative frames. Cancel a second publish and verify no new timeline/media entry. + +- [ ] **Step 3: Exercise Agent co-editing** + + Ask Agent to patch the same document, provoke a stale hash conflict by typing concurrently, resolve explicitly, preview, and publish. Confirm no silent overwrite or path escape. + +- [ ] **Step 4: Record evidence and commit** + + Capture source, preview, timeline result, exact hashes/commands, and screenshots; commit as `test(motion): verify Beta 5 Studio end to end`. diff --git a/docs/superpowers/plans/2026-08-13-beta5-release.md b/docs/superpowers/plans/2026-08-13-beta5-release.md new file mode 100644 index 00000000..6b8f2d3c --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-beta5-release.md @@ -0,0 +1,185 @@ +# OpenTake 1.0.0-beta.5 Release Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Publish the verified Beta 5 product as immutable GitHub prerelease `v1.0.0-beta.5`, with the existing seventeen signed updater assets and auditable packaged-app evidence for every requested behavior. + +**Architecture:** Functional plans land first on `release/v1.0.0-beta.5`. A test-first version-contract migration updates Cargo, Web, Tauri, WiX, workflow, documentation, and validator digests as one identity. The frozen candidate passes focused, full, security, license, real MCP, Chromium/FFmpeg, and packaged GUI gates before it can move through a PR to remote `main`; only the verified remote main SHA receives the annotated tag. + +**Tech Stack:** Rust/Cargo, React/TypeScript/Vite/pnpm, Python validators, Tauri 2, GitHub Actions, gh CLI, Minisign/Tauri updater, macOS and Windows package jobs. + +## Global Constraints + +- Product version is exactly `1.0.0-beta.5`, tag `v1.0.0-beta.5`, WiX `1.0.0.5`. +- Do not tag, push, merge, publish, or modify secrets until all local implementation plans and preflight gates are complete. +- Never move/delete/reuse a release tag and never force-push. +- Preserve Beta 4 as rollback; fixes after publication use a higher version. +- Preserve the exact updater trust boundary and seventeen-asset release contract unless a failing primary-platform tool proves a required additive change. +- Keep macOS ad-hoc/not-notarized and Windows non-Authenticode limitations explicit. +- Never stage user-owned untracked/modified `docs/audit/2026-08-07/*` files. +- Use explicit staging paths and inspect every staged diff; never use `git add -A`. + +--- + +### Task 1: Migrate the repository release identity to Beta 5 + +**Files:** +- Modify: `scripts/test_check_release_workflow.py` +- Modify: `.github/workflows/release.yml` +- Modify: `scripts/check_release_workflow.py` +- Modify: `Cargo.toml` +- Modify: `Cargo.lock` +- Modify: `web/package.json` +- Modify: `src-tauri/tauri.conf.json` +- Create: `docs/releases/1.0.0-beta.5.md` +- Modify: `README.md` +- Modify: `docs/INDEX.md` +- Modify: `CHANGELOG.md` + +**Interfaces:** +- repository-wide Cargo/Tauri/Web `1.0.0-beta.5`, WiX `1.0.0.5`, release note `docs/releases/1.0.0-beta.5.md`. +- historical/generic updater fixtures remain historical; only current-release contracts change. + +- [ ] **Step 1: Write the failing metadata contract** + + Update current-release test fixtures to require Beta 5 identities, note path, tag trigger, artifact prefixes, and WiX fourth component. Add mutations for each stale Beta 4 value. + +- [ ] **Step 2: Verify RED** + + Run `python3 -B -m unittest discover -s scripts -p 'test_check_release_workflow.py'`. Expected: current production metadata/workflow remains Beta 4. + +- [ ] **Step 3: Apply the minimal identity migration** + + Update root workspace version, Web package, Tauri version/WiX version, release workflow/current validator literals, and approved validation/job digests derived from final YAML. Regenerate lockfiles with Cargo/pnpm rather than hand-editing resolved entries. + +- [ ] **Step 4: Write Beta 5 release notes** + + Cover persistent authenticated MCP, ordered Agent/tool conversation, clear-timeline PNG, Motion Studio, settings/library/Home/title-bar improvements, licenses, platform signing limitations, updater behavior, and rollback. Update current links without rewriting Beta 4 history. + +- [ ] **Step 5: Verify GREEN** + + Run release workflow tests/validator, strict YAML parser, actionlint, updater manifest/attestation tests, Windows workflow contract tests, and version searches that distinguish intentional historical references. + +- [ ] **Step 6: Commit release identity** + + Commit as `chore(release): prepare v1.0.0-beta.5 metadata`. + +### Task 2: Run the complete local release gate matrix + +**Files:** +- Modify: `docs/audit/2026-08-13/beta5-release-candidate.md` + +- [ ] **Step 1: Run focused product gates fresh** + + Re-run external MCP security/restart, Agent ordering/session/PNG, Motion document/Chromium/FFmpeg, appearance/storage, Library/Home thumbnail, and title-bar measurement suites from the four implementation plans. No cached historical receipt substitutes for a fresh command. + +- [ ] **Step 2: Run full Rust gates** + + Run `cargo test --workspace --no-fail-fast`, `cargo clippy --workspace --all-targets --all-features -- -D warnings`, `cargo fmt --all -- --check`, required feature-gated integration suites, and the repository security audit command documented in the release workflow. + +- [ ] **Step 3: Run full Web and dependency gates** + + Run `pnpm -C web test`, `pnpm -C web build`, lockfile/install integrity, license inventory, dependency audit commands used by CI, and ensure all CodeMirror notices match installed versions. + +- [ ] **Step 4: Run release/integrity gates** + + Run all Python release tests, workflow validator, actionlint, updater tests, `git diff --check`, generated-file checks, and a case-sensitive search for secrets/tokens/private keys in tracked/staged output. + +- [ ] **Step 5: Record exact results** + + Write command, exit code, test count/skip reason, timestamp, platform, artifact path/hash, and any accepted signing limitation to the candidate audit. Mark a gate not run or failed accurately; do not infer success. + +### Task 3: Build and visually verify the final packaged candidate + +**Files:** +- Modify: `docs/audit/2026-08-13/beta5-release-candidate.md` +- Create: `docs/audit/2026-08-13/screenshots/beta5-packaged-home.png` +- Create: `docs/audit/2026-08-13/screenshots/beta5-packaged-library.png` +- Create: `docs/audit/2026-08-13/screenshots/beta5-packaged-agent.png` +- Create: `docs/audit/2026-08-13/screenshots/beta5-packaged-motion.png` +- Create: `docs/audit/2026-08-13/screenshots/beta5-packaged-settings.png` + +- [ ] **Step 1: Build the release application and packages** + + Provision checksum-pinned FFmpeg/Chromium sidecars through repository scripts, then run the platform release build with the same feature set/environment as the workflow. Record `.app`/DMG paths, sizes, and SHA-256. + +- [ ] **Step 2: Run the acceptance matrix in the packaged `.app`** + + Verify external MCP across app restart/revoke, model clear animation, standard/compact switching, Library return placement, Home card content, continuous Agent/tool ordering, clear-timeline PNG, Motion real text/edit/preview/publish/reopen, and title-bar alignment. + +- [ ] **Step 3: Measure rather than eyeball geometry** + + Run the title-bar image measurement script on each applicable screenshot and assert at most 1 CSS px center deviation. Record 16:9 project/Motion preview pixel bounds and disclosure animation/reduced-motion observations. + +- [ ] **Step 4: Re-run affected checks after any correction** + + Any packaged-app defect returns to the relevant TDD task. Rebuild from a clean output directory and repeat the full affected acceptance slice before updating the receipt. + +### Task 4: Obtain final code, security, and release review + +**Files:** +- Review the complete Beta 5 diff and candidate audit; no new product file is expected unless findings require a fix. + +- [ ] **Step 1: Request independent code review** + + Review Rust/TypeScript correctness, compatibility, cancellation, persistence, stale-event handling, atomicity, and test coverage. Resolve every P0–P2 finding and rerun affected tests. + +- [ ] **Step 2: Request independent security review** + + Review MCP auth/loopback/Host/Origin/logging/keychain/revoke, Motion path/network/script confinement, token/image bounds, process cleanup, release secrets, updater trust, and dependency licenses. Resolve every P0–P2 finding and rerun the full security slice. + +- [ ] **Step 3: Inspect the final diff and worktree** + + Check changed file inventory, no debug code, no accidental binaries/build output, no unrelated formatting, no plaintext credential, required docs/screenshots present, and all user-owned audit assets still unstaged. + +- [ ] **Step 4: Freeze and commit the candidate** + + Stage explicit reviewed paths, inspect `git diff --cached --stat` and `git diff --cached`, run a staged secret scan and `git diff --cached --check`, then commit remaining candidate evidence as `chore(release): freeze v1.0.0-beta.5 candidate`. + +### Task 5: Merge Beta 5 through GitHub CI + +- [ ] **Step 1: Verify remote preconditions** + + Authenticate `gh`, fetch `origin/main` and tags, confirm no Beta 5 tag/release exists, confirm required signing secret names exist without reading values, and rebase/merge current remote main only through a reviewed non-destructive integration if it advanced. + +- [ ] **Step 2: Push the release branch** + + Push `release/v1.0.0-beta.5` with tracking and no force. Confirm the remote branch SHA equals the local candidate SHA. + +- [ ] **Step 3: Open a ready PR** + + Create a PR to `main` containing scope, risk boundaries, exact local evidence, external MCP threat model, new licenses, signing limitations, and rollback. Do not publish the tag from the branch. + +- [ ] **Step 4: Wait for all required checks** + + Monitor every branch-protection check to a terminal state. Diagnose any failure from logs, patch on the release branch, rerun local affected/full gates, push normally, and wait again. + +- [ ] **Step 5: Merge and validate merged main** + + Merge with the repository's required merge method. Wait for the merge commit's own `main` CI to pass and record immutable `MAIN_SHA`. If main advances again before tagging, repeat the boundary check rather than tagging an unverified SHA. + +### Task 6: Tag, publish, and verify the immutable prerelease + +- [ ] **Step 1: Recheck the release boundary** + + Confirm remote `main == MAIN_SHA`, merged metadata/note are Beta 5, signing secret names exist, all required main checks are green, and neither tag nor release exists. + +- [ ] **Step 2: Create and push the annotated tag** + + Run `git tag -a v1.0.0-beta.5 MAIN_SHA -m 'OpenTake 1.0.0-beta.5'` and push only `refs/tags/v1.0.0-beta.5`. Never move it after this step. + +- [ ] **Step 3: Monitor the tag-triggered workflow** + + Wait for validate, quality, macOS, Windows, and publish jobs to complete. A code failure requires a higher version; an external infrastructure retry may use workflow dispatch only if it leaves tag/SHA/source unchanged and the workflow contract permits it. + +- [ ] **Step 4: Verify the public prerelease** + + Confirm tag target SHA, prerelease/draft/latest flags, release-note content, exactly seventeen expected asset names, GitHub digests, `SHA256SUMS`, updater platform keys/URLs, Minisign companions, attestations, content types, and non-zero bounded downloads. + +- [ ] **Step 5: Verify updater discovery from Beta 4** + + Against the production endpoint, run the updater check from a clean Beta 4 installation/configuration, verify it selects Beta 5, validates signature/hash/size/attestation, and does not install an unexpected platform artifact. + +- [ ] **Step 6: Publish the final receipt** + + Record release URL, workflow URL, `MAIN_SHA`, tag object SHA, asset digests/sizes, updater result, release limitations, and rollback to Beta 4 in the audit and final user report. diff --git a/docs/superpowers/specs/2026-08-13-opentake-beta5-design.md b/docs/superpowers/specs/2026-08-13-opentake-beta5-design.md new file mode 100644 index 00000000..53c42aca --- /dev/null +++ b/docs/superpowers/specs/2026-08-13-opentake-beta5-design.md @@ -0,0 +1,220 @@ +# OpenTake 1.0.0-beta.5 设计规格 + +日期:2026-08-13(Asia/Shanghai) + +## 1. 目标与范围 + +Beta 5 在 Beta 4 的编辑、播放、导出和更新基线上完成六个相互关联的产品面: + +1. 恢复长期外部 MCP,但不恢复历史上无认证、固定端口即开放的实现; +2. 修复设置页模型清理、文字出现/收缩、深浅色与标准/紧凑切换; +3. 调整素材库、Home 工程预览和 macOS 窗口栏; +4. 把应用内 Agent 改为连续、无边框、工具调用按顺序穿插的对话; +5. 把动效从 Agent 窄栏中移出,建立可编辑真实 HTML/CSS 文件的独立 Motion Studio; +6. 将以上变更以 `1.0.0-beta.5` 通过完整发布门禁并发布为 prerelease。 + +不在 Beta 5 范围内:完整 After Effects/Figma 级可视化设计器、任意 Node/npm 工程执行、 +任意文件系统访问、透明 ProRes 4444 输出,以及浅色主题实现。Motion Canvas 现有模板继续兼容, +但不再作为主要作者界面。 + +## 2. 已选方案 + +采用原生集成方案: + +- CodeMirror 6(MIT)提供 HTML/CSS 源文件编辑; +- 现有 `opentake-motion` Chromium 沙箱负责确定性实时预览和逐帧渲染; +- 现有 FFmpeg sidecar 负责编码和原子落轨; +- 现有 Motion Canvas 3.17.2(MIT)作为兼容模板来源; +- UI 参考 Songxia/Codex 的密度、层次和连续对话感,但不复制受保护资产。 + +没有选择完整嵌入 EasyLogic Studio 或 Motionity:二者虽然是 MIT 开源项目,但各自以自身画布 +模型作为真相,会形成第二套项目/时间线状态,不能满足 Agent 与用户直接编辑同一 HTML/CSS 文件 +的要求。第三方名称、版本、仓库、版权和许可证进入 `THIRD_PARTY_NOTICES.md` 与依赖门禁。 + +## 3. 长期外部 MCP + +### 3.1 生命周期和认证 + +外部 MCP 仅绑定 IPv4 loopback `127.0.0.1:19789`,不监听 `0.0.0.0`、IPv6 任意地址或局域网。 +服务仅在至少一个已配对客户端存在且用户启用外部连接时运行;应用退出时关闭监听器。 + +每个配对客户端拥有: + +- 随机客户端 id; +- 用户可编辑显示名称; +- 独立 256-bit Bearer token; +- 创建时间、最后使用时间和撤销状态; +- 仅用于显示/查找的 token 摘要。 + +明文 token 只在创建或重新生成时返回一次,并存入系统钥匙串。持久化元数据不得包含明文 token。 +设置页支持创建、复制配置、重新生成和撤销;撤销后旧 token 立即失效并取消该客户端的活动请求。 + +### 3.2 请求安全边界 + +所有 `/mcp` 与 well-known 请求先经过以下边界: + +- Host 与 Origin 必须是当前 loopback 端点; +- `Authorization: Bearer` 必须匹配一个未撤销客户端; +- 请求体、Content-Type、协议版本、并发和有限 JSON 数字沿用 Beta 4 限制; +- 日志、错误、事件和设置页永不回显完整 token; +- 认证比较使用常量时间;失败统一返回认证错误,不泄露客户端是否存在; +- 无保存工程、工程正在切换或请求捕获的工程身份已过期时,编辑和媒体写入失败关闭。 + +外部连接复用应用内 Agent 的同一个 `Dispatcher`、能力桥和插件注册表,不创建第二套工具宇宙。 +每个 rmcp session 使用独立 undo scope;一个客户端不能撤销另一个客户端或应用内聊天的操作。 +工程切换先停止新请求、取消旧工程活动请求、等待副作用终止,再切换工程身份。 + +### 3.3 产品状态与命令 + +Tauri 持有一个 `ExternalMcpState`,统一管理监听器、配对目录、活动请求和状态事件。前端只通过 +类型化命令读取状态和执行配对操作。设置页展示:关闭、启动中、监听中、端口冲突、认证故障和 +暂停状态;不能把启动失败表现为已连接。 + +必须增加针对未认证、错误 token、撤销 token、远程 Host/Origin、工程切换竞态、跨 session undo、 +端口占用、重启恢复和日志脱敏的 Rust 集成测试。 + +## 4. Agent 连续对话 + +### 4.1 信息结构 + +Agent 面板只保留 Chat;删除 Chat/Motion 二选一和对应本地状态。Motion Studio 成为一级视图, +因此切换动效不会销毁或错误复用聊天会话。 + +Assistant 回复是一个无气泡背景、无外边框的连续内容流。用户消息可保留轻量表面以区分输入。 +工具调用显示为内联状态行,默认只显示工具图标、动作摘要、进度/成功/失败和展开箭头;展开后显示 +参数、结果和图片,但仍属于同一回复,不出现独立卡片边框。 + +### 4.2 有序内容模型 + +渲染以 `ChatMessage.blocks` 为权威顺序:`Text → ToolUse/ToolResult → Text` 原样展示。不能继续先渲染 +整段 `content`、再把 `toolCalls` 全堆到末尾。流式事件必须携带或维护 block 序号,使多轮工具调用 +不会挂到上一条 assistant 消息。旧会话在反序列化时迁移到等价 blocks,仍可打开。 + +### 4.3 时间线截图结果 + +改变可见时间线的工具完成后可附合成预览;当 Agent 删除时间线上最后一个可见片段时必须附图。 +截图通过 Rust 权威时间线与合成路径生成,不截 WebView DOM。空时间线生成带画布比例、时间码和 +“时间线已清空”语义的真实 PNG,而不是返回无图片或空 JSON。图片作为同一 ToolResult 的 image +block 按顺序展示,限制尺寸和编码大小。 + +## 5. Motion Studio + +### 5.1 一级导航和布局 + +编辑器左上入口顺序固定为:Home、Chat、Motion Studio、Panel Management。四个入口与交通灯 +共享同一窗口栏基线和 26px 命中区域。Motion Studio 是独立 `AppView`,不是 Agent 子标签。 + +Motion Studio 采用深色、紧凑、无多余卡片的作者工作区: + +- 左侧:当前工程的动效文档、内置模板和最近版本; +- 中央:16:9 实时画布及播放/暂停、重播和时间刮擦; +- 右侧:宽高、fps、时长、背景、文字和发布参数; +- 下方:帧标尺、播放头和属性关键帧; +- 代码区:`index.html`、`styles.css` 两个 CodeMirror 标签。 + +宽度不足时按代码区、属性栏、文件栏的顺序折叠,画布和发布按钮保持可达;所有键盘操作有明确 +焦点,`prefers-reduced-motion` 下禁用非必要过渡。 + +### 5.2 文档和 Agent 编辑边界 + +每个工程在受控目录保存 motion 文档清单与真实 UTF-8 `index.html`、`styles.css`。HTML/CSS 是 +唯一作者真相;自动保存使用原子临时文件 + rename,保存失败不能覆盖上一版本。 + +Agent 获得仅限当前工程 motion 根目录的类型化工具:列出文档、读取文件、应用带基线 hash 的 +补丁、创建文档、预览和发布。拒绝绝对路径、`..`、符号链接、超限文档、脚本网络和文件系统访问。 +补丁基线过期返回冲突,不静默覆盖用户正在编辑的内容。 + +### 5.3 真实预览与发布 + +初始模板必须包含可见的真实中文/英文标题、副标题和动画。编辑 HTML/CSS 后采用防抖预览;预览 +由网络禁用的 Chromium 沙箱执行,使用确定性时钟和 `OpenTake.seek(frame/fps)`,不是 CSS 假图。 +错误显示具体行列且保留上一张成功帧。 + +发布复用 `motion_add`/`motion_edit` 原子路径:同一文档、fps、宽高和帧数逐帧渲染,经 FFmpeg +编码 MP4,验证结果清单后才注册媒体并加入/替换时间线;失败或取消不改变 manifest/timeline。 +已存在 Motion Canvas 片段可打开为兼容模板参数视图;HTML/CSS 文档以现有 Code source 进入生产桥。 + +## 6. 设置和文字动画 + +### 6.1 外观 + +删除 `Theme = "dark" | "light"`、主题分段控件和 `data-theme=light` 写入。启动迁移删除旧 `theme` +localStorage 值并始终使用深色 tokens。浅色暂不显示为禁用选项,避免让用户误以为可以使用。 + +外观页只显示一个“深色布局”选择组: + +- 深色 · 标准; +- 深色 · 紧凑。 + +两项等宽、固定内容槽,无勾号;选择仅改变背景、描边和文字颜色,标签几何不移动。标准与紧凑 +继续调用真实窗口尺寸变更,并在失败时恢复上一选择和显示错误。 + +### 6.2 模型清理与动态文字 + +模型清理二次确认属于同一行的展开区域,进入前预留/动画化高度;不得突然把说明文字插入按钮 +下方造成跳动。进入和退出统一动画 `max-block-size/opacity/translate`,退出完成后才卸载节点。 + +所有条件性帮助、错误、状态、空态和展开详情使用共享 `Reveal`/`Collapse` 原语。文字本身不逐字 +跳动;容器以 150–200ms 动画出现和收缩。Toast、对话框和一级页面沿用同一 motion tokens。 +系统减少动态效果时过渡时长为 0,内容立即可见且无闪动。 + +## 7. Home、素材库和工程封面 + +### 7.1 素材库 + +“返回主页”从内容 header 移除,放到左侧 CategoryTree 的最上方交通灯安全区之后。它位于素材库 +全局侧栏而非某个分类具体页面;标题、搜索和排序仍在右侧 header。目标以用户更具体的“左侧侧边 +栏上方”要求为准。 + +### 7.2 Home + +完全移除 Home 的 AI 生成记录区块、对应加载 effect 和主页 API 请求;生成审计数据后端保留供 +工程审计使用,不在主页展示。 + +“我的项目”卡片的视觉预览区使用 `aspect-ratio: 16 / 9`,卡片宽度响应式,不能再固定为 48px +高的条形占位。保存/关闭工程后,后端用权威合成器生成代表帧封面并原子写入工程 bundle;Home +读取实际封面。没有可见片段、封面缺失或旧工程时显示包含画布比例、轨道结构和项目名称的结构化 +占位,仍有具体内容,不能只有 Film 图标。 + +## 8. macOS 窗口栏对齐 + +窗口栏高度、交通灯中心、四个左侧入口和右侧操作按钮使用同一垂直中心变量。Tauri +`trafficLightPosition.y` 与 CSS titlebar height 必须按物理像素实测校准,不能继续靠个别按钮 +`top: -2` 之类偏移。Home、素材库、Motion Studio 和设置侧栏使用相同的 safe-area tokens。 + +验收使用最终打包 `.app` 截图:三个交通灯的整体中心与标题栏图标中心误差不超过 1 CSS px, +所有左/右图标共享基线,窗口缩放后仍成立。 + +## 9. 版本、发布和回滚 + +从 Beta 4 不可变发布状态建立 `release/v1.0.0-beta.5`。统一更新: + +- Cargo workspace:`1.0.0-beta.5`; +- Tauri:`1.0.0-beta.5`; +- Web package:`1.0.0-beta.5`; +- Windows WiX:`1.0.0.5`; +- 发布合同、测试 fixture、文档索引、CHANGELOG 和 Release Notes。 + +验证顺序:功能级 TDD → Web 全测/构建 → Rust workspace 测试 → clippy/fmt → MCP live transport +安全测试 → Motion 确定性/取消/真实字符输出 → 最终打包 App 的 Home/素材库/设置/Agent/Motion/ +交通灯 GUI 验收 → 发布工作流合同。不得把浏览器 fallback 结果当作 Tauri 真机证据。 + +发布前确认签名 secrets 可用、远端 tag 不存在、远端分支 SHA 与本地完全一致。只有全部门禁通过 +后创建不可变 `v1.0.0-beta.5` tag 并发布 prerelease。若发布后回归,保留 Beta 5 tag/审计,回退 +到 `v1.0.0-beta.4`,修复只能使用更高版本标签。 + +## 10. 验收矩阵 + +完成必须逐项提供以下权威证据: + +- 外部 MCP:真实客户端重启后仍可连接;撤销后旧凭据拒绝;远程/无认证拒绝;工程切换无越权; +- 存储:模型确认说明进入和退出均平滑,无突然行高跳变; +- 外观:页面无深浅切换、无勾号;深色标准/紧凑真实改变窗口并且文字位置不动; +- 素材库:返回按钮只在左侧侧栏顶部; +- Home:无 AI 生成记录;卡片 16:9 且真实工程显示真实画面; +- Agent:工具调用位于有序文本之间、无独立卡片边框;切会话不串流;清空时间线返回 PNG; +- Motion:独立一级入口;HTML/CSS 可编辑并显示真实文字;预览、发布视频和重开工程结果一致; +- 窗口栏:最终打包截图测量对齐; +- 发布:版本和 17 项资产/签名合同通过,GitHub prerelease 可下载并由 updater 识别。 + +任何一项只有源码、单元测试或视觉推测而没有匹配范围的证据,都不视为完成。 diff --git a/scripts/check_license_inventory.py b/scripts/check_license_inventory.py new file mode 100644 index 00000000..735c3c9c --- /dev/null +++ b/scripts/check_license_inventory.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Validate the release-critical CodeMirror dependency and notice inventory.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +from dataclasses import dataclass +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] + + +@dataclass(frozen=True) +class PackageContract: + version: str + repository: str + copyright: str + license_sha256: str + + +CODEMIRROR_PACKAGES = { + "codemirror": PackageContract( + version="6.0.2", + repository="https://github.com/codemirror/basic-setup", + copyright="Copyright (C) 2018-2021 by Marijn Haverbeke and others", + license_sha256="05c6130cda97e7600ca91427a41e8a065efcf82365fc0293e7de80faec494c07", + ), + "@codemirror/lang-html": PackageContract( + version="6.4.12", + repository="https://code.haverbeke.berlin/codemirror/lang-html", + copyright="Copyright (C) 2018-2021 by Marijn Haverbeke and others", + license_sha256="05c6130cda97e7600ca91427a41e8a065efcf82365fc0293e7de80faec494c07", + ), + "@codemirror/lang-css": PackageContract( + version="6.3.1", + repository="https://github.com/codemirror/lang-css", + copyright="Copyright (C) 2018-2021 by Marijn Haverbeke and others", + license_sha256="05c6130cda97e7600ca91427a41e8a065efcf82365fc0293e7de80faec494c07", + ), + "@codemirror/state": PackageContract( + version="6.7.1", + repository="https://code.haverbeke.berlin/codemirror/state", + copyright="Copyright (C) 2018-2021 by Marijn Haverbeke and others", + license_sha256="05c6130cda97e7600ca91427a41e8a065efcf82365fc0293e7de80faec494c07", + ), + "@codemirror/theme-one-dark": PackageContract( + version="6.1.3", + repository="https://github.com/codemirror/theme-one-dark", + copyright="Copyright (C) 2018-2021 by Marijn Haverbeke and others", + license_sha256="05c6130cda97e7600ca91427a41e8a065efcf82365fc0293e7de80faec494c07", + ), +} + + +def _package_directory(node_modules: Path, package: str) -> Path: + return node_modules.joinpath(*package.split("/")) + + +def _mapping_body(document: str, name: str, indent: int) -> str: + indentation = " " * indent + match = re.search( + rf"(?ms)^{indentation}{re.escape(name)}:\n" + rf"(?P.*?)(?=^{indentation}\S[^\n]*:\n|\Z)", + document, + ) + return "" if match is None else match.group("body") + + +def _lock_importer_entry(lockfile: str, package: str) -> tuple[str, str] | None: + importers = _top_level_section(lockfile, "importers") + root_importer = _mapping_body(importers, ".", 2) + runtime_dependencies = _mapping_body(root_importer, "dependencies", 4) + key = f"'{package}'" if package.startswith("@") else package + match = re.search( + rf"(?m)^ {re.escape(key)}:\n" + rf" specifier: (?P[^\n]+)\n" + rf" version: (?P[^\s(]+)", + runtime_dependencies, + ) + if match is None: + return None + return match.group("specifier"), match.group("version") + + +def _top_level_section(lockfile: str, name: str) -> str: + match = re.search( + rf"(?ms)^{re.escape(name)}:\n(?P.*?)(?=^[A-Za-z][A-Za-z0-9_-]*:\n|\Z)", + lockfile, + ) + return "" if match is None else match.group("body") + + +def _has_section_package(section: str, package: str, version: str) -> bool: + key = re.escape(f"{package}@{version}") + return re.search(rf"(?m)^ ['\"]?{key}['\"]?:", section) is not None + + +def _normalize_repository(repository: object) -> str: + if isinstance(repository, dict): + repository = repository.get("url") + if not isinstance(repository, str): + return "" + normalized = repository.strip() + if normalized.startswith("git+"): + normalized = normalized[4:] + if normalized.endswith(".git"): + normalized = normalized[:-4] + return normalized.rstrip("/") + + +def validate_inventory(repository_root: Path = REPOSITORY_ROOT) -> list[str]: + errors: list[str] = [] + package_json_path = repository_root / "web" / "package.json" + lockfile_path = repository_root / "web" / "pnpm-lock.yaml" + notices_path = repository_root / "THIRD_PARTY_NOTICES.md" + + try: + package_json = json.loads(package_json_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return ["web/package.json must be readable valid JSON"] + + try: + lockfile = lockfile_path.read_text(encoding="utf-8") + except OSError: + lockfile = "" + errors.append("web/pnpm-lock.yaml must be readable") + + try: + notices = notices_path.read_text(encoding="utf-8") + except OSError: + notices = "" + errors.append("THIRD_PARTY_NOTICES.md must be readable") + + dependencies = package_json.get("dependencies") + if not isinstance(dependencies, dict): + dependencies = {} + direct_codemirror = { + package + for package in dependencies + if package == "codemirror" or package.startswith("@codemirror/") + } + if direct_codemirror != set(CODEMIRROR_PACKAGES): + errors.append( + "direct CodeMirror dependency set must exactly match the license contract" + ) + + node_modules = repository_root / "web" / "node_modules" + package_records = _top_level_section(lockfile, "packages") + snapshot_records = _top_level_section(lockfile, "snapshots") + for package, contract in CODEMIRROR_PACKAGES.items(): + expected_license = f"web/node_modules/{package}/LICENSE" + expected_notice = ( + f"| `{package}` | `{contract.version}` | " + f"[{contract.repository}]({contract.repository}) | MIT | " + f"`{expected_license}` |" + ) + + if dependencies.get(package) != contract.version: + errors.append(f"{package} must be an exact {contract.version} dependency") + + importer = _lock_importer_entry(lockfile, package) + if importer != (contract.version, contract.version): + errors.append(f"{package} lock importer must resolve exactly {contract.version}") + if not _has_section_package(package_records, package, contract.version): + errors.append( + f"{package}@{contract.version} package resolution record is missing" + ) + if not _has_section_package(snapshot_records, package, contract.version): + errors.append(f"{package}@{contract.version} snapshot record is missing") + + package_directory = _package_directory(node_modules, package) + installed_manifest = package_directory / "package.json" + installed_license = package_directory / "LICENSE" + try: + installed = json.loads(installed_manifest.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + errors.append(f"installed {package}@{contract.version} manifest is missing") + else: + if installed.get("version") != contract.version: + errors.append(f"installed {package} version must be {contract.version}") + if installed.get("license") != "MIT": + errors.append(f"installed {package} license must be MIT") + if _normalize_repository(installed.get("repository")) != contract.repository: + errors.append( + f"installed {package} repository does not match the official source" + ) + try: + license_bytes = installed_license.read_bytes() + license_text = license_bytes.decode("utf-8") + except OSError: + errors.append(f"installed {package} LICENSE file is missing") + except UnicodeDecodeError: + errors.append(f"installed {package} LICENSE must be UTF-8") + else: + if hashlib.sha256(license_bytes).hexdigest() != contract.license_sha256: + errors.append( + f"installed {package} LICENSE does not match its published license" + ) + if license_text.strip() not in notices: + errors.append( + f"{package}@{contract.version} full MIT license notice is missing" + ) + + if expected_notice not in notices: + errors.append(f"{package}@{contract.version} third-party notice is missing") + if contract.copyright not in notices: + errors.append(f"{package}@{contract.version} copyright notice is missing") + + return errors + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repository-root", type=Path, default=REPOSITORY_ROOT) + args = parser.parse_args() + errors = validate_inventory(args.repository_root.resolve()) + if errors: + for error in errors: + print(f"ERROR: {error}") + return 1 + print("CodeMirror dependency and license inventory is valid") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_release_workflow.py b/scripts/check_release_workflow.py index a0b841ae..ac0efa91 100644 --- a/scripts/check_release_workflow.py +++ b/scripts/check_release_workflow.py @@ -29,9 +29,14 @@ RELEASE_NOTES_PATH = Path( os.environ.get( "OPENTAKE_RELEASE_NOTES_PATH", - REPOSITORY_ROOT / "docs" / "releases" / "1.0.0-beta.4.md", + REPOSITORY_ROOT / "docs" / "releases" / "1.0.0-beta.5.md", ) ).resolve() +CURRENT_RELEASE_VERSION = "1.0.0-beta.5" +APPROVED_REPOSITORY_IDENTITIES = { + "1.0.0-beta.4": ("1.0.0.4", "Beta 4"), + CURRENT_RELEASE_VERSION: ("1.0.0.5", "Beta 5"), +} PINNED_ACTIONS = { "actions/checkout": "11d5960a326750d5838078e36cf38b85af677262", "actions/setup-node": "49933ea5288caeca8642d1e84afbd3f7d6820020", @@ -64,6 +69,7 @@ "name:Validate Windows and release workflow contracts", "name:Provisioner unit tests", "name:Install locked Web dependencies", + "name:Validate Web dependency licenses", "name:Rust formatting", "name:Rust workspace clippy", "name:Rust workspace tests", @@ -169,15 +175,16 @@ } APPROVED_COMPLEX_RUN_SHA256 = { - ("validate", "Validate tag, source SHA, versions, and notes"): "d451f703d50cd202d583cd42cc631927bfb97ea64aa6988bca5210dc40509379", + ("validate", "Validate tag, source SHA, versions, and notes"): "eefb9d97ad80e8090b817178093824af2b897ae339a144b2e16a91de3f9f8334", ("validate", "Reassert exact source after validation"): "953657d26d2eda8490c18e7030c66ddb19aba64a5c8b19808da9a853fd1bfdd2", ("quality", "Assert exact checked-out SHA"): "ff0b148eecdf8603712586a6c4a05e752df0b36b5c97a366760f6cba10e58ddd", ("quality", "Free disk space"): "5848415c4d0e696f46965d62a2e17c8b7a0dd45ae600d28102af0b04108d9bf6", ("quality", "Install system deps (ffmpeg + Tauri/GTK)"): "ee466d2d3fff1c3703d50f9dabe4d21e1cee4b399924d064c6d2714dae34d16b", ("quality", "Audit Motion Canvas dependencies and licenses"): "a3517fae1a8663e519138196c9f3721d8f4df19ac8f115c49a079c4aaa60c8b3", ("quality", "Test and reproduce Motion Canvas runner"): "8bcd55de9b045f9d7be6343163a5422cba0ab545f7844da50ca1a7c8623fe640", - ("quality", "Validate Windows and release workflow contracts"): "f70c00caca2a1ea66ce7843bd5e4b6e9702422d2ad0cc7d77d714decdb93b351", + ("quality", "Validate Windows and release workflow contracts"): "76d3d0db11222f5121e5b404470296695c8cfea0b4e41f0c3a1d853612331e7d", ("quality", "Provisioner unit tests"): "f57d4d7d6df403d573d31bbda02589804109596040c2cefcca60f3e9352e891a", + ("quality", "Validate Web dependency licenses"): "5b914e6aaddab4c9ca0ac03ff0cc03d23b44fc3d66621b8c9fa5eeac07a5cfcd", ("quality", "Live playback transport integration"): "461f79546009551e5e7adbf50f869abb9449c2ae7666a66a425c7cd3c24acea9", ("quality", "Reassert exact source after quality gates"): "953657d26d2eda8490c18e7030c66ddb19aba64a5c8b19808da9a853fd1bfdd2", ("macos_arm64", "Assert exact checked-out SHA"): "ff0b148eecdf8603712586a6c4a05e752df0b36b5c97a366760f6cba10e58ddd", @@ -214,8 +221,8 @@ } APPROVED_JOB_SHA256 = { - "validate": "eb09b22d3681439d20d6a2e793d81b18aa2901e0d5f646dbcc9b809226954b46", - "quality": "a2947370289ebd299042159fbe8fd046f7fedf72b47037d58b4398ed8e85baee", + "validate": "ce61e176161ab7ec892d92ed9cf1a3e379c6b5f7b5781bf1addc4f29379a85b2", + "quality": "d0078d8cd49919be4a1f71f1208c0d05369282c13f5ae5c38142063d648816c2", "macos_arm64": "1785d765c96278190c25e312c9e610070619e17b7b2b0d922f0bd234501df525", "windows_x64": "63bd70d85e40a3f1177e9059d4674d7f93d4502181fc378f7706e839af953378", "publish": "ea3fe6d18a94c0850d3ac7f21c4e23fb8fcf572189f77f659e5b34eab776bd85", @@ -1468,9 +1475,16 @@ def validate_workflow(workflow: str) -> list[str]: 'tauri = json.loads(Path("src-tauri/tauri.conf.json").read_text(encoding="utf-8"))', 'web = json.loads(Path("web/package.json").read_text(encoding="utf-8"))', 'notes = Path("docs/releases") / f"{version}.md"', + 'event_name = os.environ["GITHUB_EVENT_NAME"]', + 'if event_name == "workflow_dispatch":', + 'expected_version = "1.0.0-beta.4"', + 'expected_wix_version = "1.0.0.4"', + 'expected_version = "1.0.0-beta.5"', + 'expected_wix_version = "1.0.0.5"', 'if versions != {version}:', + 'if version != expected_version:', 'wix_version = tauri["bundle"]["windows"]["wix"]["version"]', - 'if wix_version != "1.0.0.4":', + 'if wix_version != expected_wix_version:', ), ): errors.append("Cargo, Tauri, and Web versions match tag") @@ -1480,7 +1494,7 @@ def validate_workflow(workflow: str) -> list[str]: 'if "+" in tag:', 'raise SystemExit("SemVer build metadata is unsupported for updater asset URLs")', 'if SEMVER_RE.fullmatch(tag) is None:', - 'if version == "1.0.0-beta.4" and not prerelease:', + 'if version == "1.0.0-beta.5" and not prerelease:', 'emit("prerelease", "true")', ), ): @@ -1566,6 +1580,8 @@ def validate_workflow(workflow: str) -> list[str]: ("Live playback transport integration", ("cargo", "test", "-p", "opentake-tauri", "--features", "playback-engine", "--test", "playback_transport_integration", "--", "--test-threads=1")), ("Minimal-feature Tauri clippy", ("cargo", "clippy", "-p", "opentake-tauri", "--no-default-features", "--all-targets", "--", "-D", "warnings")), ("Install locked Web dependencies", ("pnpm", "-C", "web", "install", "--frozen-lockfile")), + ("Validate Web dependency licenses", ("python3", "-B", "-m", "unittest", "discover", "-s", "scripts", "-p", "test_check_license_inventory.py")), + ("Validate Web dependency licenses", ("python3", "-B", "scripts/check_license_inventory.py")), ("Web editor behavior suite", ("pnpm", "-C", "web", "test")), ("Web production build", ("pnpm", "-C", "web", "build")), ) @@ -1574,6 +1590,18 @@ def validate_workflow(workflow: str) -> list[str]: _has_command(_structured_step(quality, step_name), command) for step_name, command in quality_commands ) + license_step = _structured_step(quality, "Validate Web dependency licenses") + quality_ok = quality_ok and _has_code_lines( + license_step, + ( + 'case "$OPENTAKE_EXPECTED_RELEASE_VERSION" in', + "1.0.0-beta.5)", + "python3 -B -m unittest discover -s scripts -p 'test_check_license_inventory.py'", + "python3 -B scripts/check_license_inventory.py", + "1.0.0-beta.4)", + 'node -e \'const d=require("./web/package.json").dependencies??{}; if(Object.keys(d).some((name)=>name==="codemirror"||name.startsWith("@codemirror/"))) process.exit(1)\'', + ), + ) system_deps = _structured_step(quality, "Install system deps (ffmpeg + Tauri/GTK)") quality_ok = quality_ok and _has_command( system_deps, @@ -1595,6 +1623,8 @@ def validate_workflow(workflow: str) -> list[str]: quality_env is not None and quality_env.get("RELEASE_TOOLING_SHA") == "${{ needs.validate.outputs.tooling_sha }}" + and quality_env.get("OPENTAKE_EXPECTED_RELEASE_VERSION") + == "${{ needs.validate.outputs.version }}" and _has_code_lines( quality_release_contract, ( @@ -1607,7 +1637,7 @@ def validate_workflow(workflow: str) -> list[str]: 'git cat-file blob "$RELEASE_TOOLING_SHA:scripts/provision_ffmpeg_sidecars.py" \\', 'git cat-file blob "$RELEASE_TOOLING_SHA:scripts/tests/test_provision_ffmpeg_sidecars.py" \\', 'git cat-file blob "$RELEASE_TOOLING_SHA:.github/workflows/release.yml" \\', - 'git cat-file blob "$RELEASE_TOOLING_SHA:docs/releases/1.0.0-beta.4.md" \\', + 'git cat-file blob "$RELEASE_TOOLING_SHA:docs/releases/1.0.0-beta.5.md" \\', 'OPENTAKE_REPOSITORY_ROOT="$GITHUB_WORKSPACE" \\', 'OPENTAKE_RELEASE_WORKFLOW_PATH="$tooling_root/release.yml" \\', 'OPENTAKE_RELEASE_NOTES_PATH="$tooling_root/release-notes.md" \\', @@ -2515,7 +2545,7 @@ def validate_release_notes_contract(notes_path: Path) -> list[str]: try: notes = notes_path.read_text(encoding="utf-8") except (OSError, UnicodeError): - return ["Beta 4 release notes document dual-SHA recovery provenance"] + return ["Beta 5 release notes document dual-SHA recovery provenance"] normalized = " ".join(notes.split()) required = ( "正常 tag push", @@ -2538,12 +2568,22 @@ def validate_release_notes_contract(notes_path: Path) -> list[str]: "notes commit", ) if not notes.strip() or any(marker not in normalized for marker in required): - return ["Beta 4 release notes document dual-SHA recovery provenance"] + return ["Beta 5 release notes document dual-SHA recovery provenance"] return [] -def validate_repository_metadata(repository_root: Path) -> list[str]: +def validate_repository_metadata( + repository_root: Path, *, expected_version: str | None = None +) -> list[str]: errors: list[str] = [] + if expected_version is None: + expected_version = os.environ.get( + "OPENTAKE_EXPECTED_RELEASE_VERSION", CURRENT_RELEASE_VERSION + ) + expected_identity = APPROVED_REPOSITORY_IDENTITIES.get(expected_version) + if expected_identity is None: + return ["approved repository release identity"] + expected_wix_version, release_name = expected_identity cargo_path = repository_root / "Cargo.toml" tauri_path = repository_root / "src-tauri" / "tauri.conf.json" web_path = repository_root / "web" / "package.json" @@ -2566,11 +2606,11 @@ def validate_repository_metadata(repository_root: Path) -> list[str]: except (KeyError, OSError, TypeError, UnicodeError, ValueError): return ["readable Cargo, Tauri, and Web version metadata"] - if versions != {"1.0.0-beta.4"}: - errors.append("repository metadata is OpenTake 1.0.0-beta.4") - if wix_version != "1.0.0.4": - errors.append("Windows installer version is 1.0.0.4") - notes = repository_root / "docs" / "releases" / "1.0.0-beta.4.md" + if versions != {expected_version}: + errors.append(f"repository metadata is OpenTake {expected_version}") + if wix_version != expected_wix_version: + errors.append(f"Windows installer version is {expected_wix_version}") + notes = repository_root / "docs" / "releases" / f"{expected_version}.md" try: notes_missing = not notes.is_file() or not notes.read_text( encoding="utf-8" @@ -2578,7 +2618,7 @@ def validate_repository_metadata(repository_root: Path) -> list[str]: except (OSError, UnicodeError): notes_missing = True if notes_missing: - errors.append("Beta 4 release notes exist") + errors.append(f"{release_name} release notes exist") return errors diff --git a/scripts/measure_titlebar_alignment.py b/scripts/measure_titlebar_alignment.py new file mode 100644 index 00000000..f2f4704d --- /dev/null +++ b/scripts/measure_titlebar_alignment.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +"""Measure packaged macOS title-bar control alignment from PNG sample rectangles.""" + +from __future__ import annotations + +import argparse +import math +import struct +import sys +import zlib +from dataclasses import dataclass +from pathlib import Path + + +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" +MAX_PNG_BYTES = 64 * 1024 * 1024 +MAX_PNG_CHUNKS = 4096 +MAX_DECODED_BYTES = 256 * 1024 * 1024 +ALIGNMENT_TOLERANCE_CSS_PX = 1.0 +KNOWN_CRITICAL_CHUNKS = {b"IHDR", b"PLTE", b"IDAT", b"IEND"} +VALID_BIT_DEPTHS = { + 0: {1, 2, 4, 8, 16}, + 2: {8, 16}, + 3: {1, 2, 4, 8}, + 4: {8, 16}, + 6: {8, 16}, +} + + +@dataclass(frozen=True) +class SampleRect: + name: str + x: float + y: float + width: float + height: float + + @property + def center_y(self) -> float: + return self.y + self.height / 2 + + +def parse_rect(raw: str) -> SampleRect: + try: + name, coordinates = raw.split(":", 1) + values = tuple(float(value) for value in coordinates.split(",")) + except ValueError as error: + raise argparse.ArgumentTypeError( + "rectangle must use NAME:X,Y,WIDTH,HEIGHT" + ) from error + if not name or len(values) != 4 or not all(math.isfinite(value) for value in values): + raise argparse.ArgumentTypeError("rectangle must use NAME:X,Y,WIDTH,HEIGHT") + x, y, width, height = values + if x < 0 or y < 0 or width <= 0 or height <= 0: + raise argparse.ArgumentTypeError("rectangle coordinates must be non-negative and sized") + return SampleRect(name=name, x=x, y=y, width=width, height=height) + + +def png_dimensions(path: Path) -> tuple[int, int]: + try: + file_size = path.stat().st_size + if file_size > MAX_PNG_BYTES: + raise ValueError(f"PNG exceeds the {MAX_PNG_BYTES}-byte evidence limit") + with path.open("rb") as image: + if image.read(8) != PNG_SIGNATURE: + raise ValueError("file does not have a PNG signature") + + width = 0 + height = 0 + saw_ihdr = False + saw_plte = False + saw_idat = False + idat_closed = False + bit_depth = 0 + color_type = -1 + row_stride = 0 + expected_decoded = 0 + decoded_count = 0 + decoded_rows = 0 + decoded_pending = bytearray() + decompressor = None + + def consume_decoded(data: bytes) -> None: + nonlocal decoded_count, decoded_rows + decoded_count += len(data) + if decoded_count > expected_decoded: + raise ValueError("PNG decoded pixel data does not match IHDR") + decoded_pending.extend(data) + consumed = 0 + while len(decoded_pending) - consumed >= row_stride: + if decoded_pending[consumed] > 4: + raise ValueError("PNG scanline uses an invalid filter type") + consumed += row_stride + decoded_rows += 1 + if consumed: + del decoded_pending[:consumed] + + def feed_idat(data: bytes) -> None: + if decompressor is None: + raise ValueError("PNG IDAT decoder was not initialized") + compressed = data + while compressed: + before = len(compressed) + output_limit = min( + 1024 * 1024, + max(1, expected_decoded - decoded_count + 1), + ) + try: + output = decompressor.decompress(compressed, output_limit) + except zlib.error as error: + raise ValueError("PNG IDAT is not a valid zlib stream") from error + compressed = decompressor.unconsumed_tail + consume_decoded(output) + if decompressor.unused_data: + raise ValueError("PNG IDAT contains bytes after the zlib stream") + if compressed and not output and len(compressed) >= before: + raise ValueError("PNG IDAT decoder made no progress") + + for _ in range(MAX_PNG_CHUNKS): + chunk_header = image.read(8) + if not chunk_header: + missing = [] + if not saw_idat: + missing.append("IDAT") + missing.append("IEND") + raise ValueError(f"PNG is missing {' and '.join(missing)}") + if len(chunk_header) != 8: + raise ValueError("truncated PNG chunk header") + chunk_length, chunk_type = struct.unpack(">I4s", chunk_header) + if not all( + ord("A") <= value <= ord("Z") or ord("a") <= value <= ord("z") + for value in chunk_type + ): + raise ValueError("PNG chunk type contains non-letter bytes") + if chunk_type[0] & 0x20 == 0 and chunk_type not in KNOWN_CRITICAL_CHUNKS: + raise ValueError(f"unknown critical PNG chunk {chunk_type!r}") + if chunk_length > MAX_PNG_BYTES: + raise ValueError("PNG chunk exceeds the evidence size limit") + if not saw_ihdr and (chunk_type != b"IHDR" or chunk_length != 13): + raise ValueError("PNG must begin with one 13-byte IHDR chunk") + if saw_ihdr and chunk_type == b"IHDR": + raise ValueError("PNG contains more than one IHDR chunk") + if chunk_type == b"IDAT": + if idat_closed: + raise ValueError("PNG IDAT chunks must be consecutive") + if not saw_idat: + if color_type == 3 and not saw_plte: + raise ValueError("indexed-color PNG requires PLTE before IDAT") + decompressor = zlib.decompressobj() + + crc = zlib.crc32(chunk_type) + remaining = chunk_length + captured = bytearray() + while remaining: + block = image.read(min(remaining, 64 * 1024)) + if not block: + raise ValueError("truncated PNG chunk data") + crc = zlib.crc32(block, crc) + if chunk_type == b"IHDR": + captured.extend(block) + elif chunk_type == b"IDAT": + feed_idat(block) + remaining -= len(block) + stored_crc = image.read(4) + if len(stored_crc) != 4: + raise ValueError("truncated PNG chunk CRC") + if struct.unpack(">I", stored_crc)[0] != crc & 0xFFFFFFFF: + raise ValueError(f"{chunk_type.decode('ascii')} CRC mismatch") + + if chunk_type == b"IHDR": + width, height, bit_depth, color_type, compression, filtering, interlace = ( + struct.unpack(">IIBBBBB", captured) + ) + if width == 0 or height == 0 or width >= 2**31 or height >= 2**31: + raise ValueError("PNG dimensions are outside the PNG specification") + if bit_depth not in VALID_BIT_DEPTHS.get(color_type, set()): + raise ValueError("PNG bit depth and color type are incompatible") + if compression != 0 or filtering != 0 or interlace not in (0, 1): + raise ValueError("PNG IHDR uses an unsupported encoding method") + if interlace != 0: + raise ValueError("packaged evidence PNG must be non-interlaced") + channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}[color_type] + row_stride = 1 + (width * channels * bit_depth + 7) // 8 + expected_decoded = row_stride * height + if expected_decoded > MAX_DECODED_BYTES: + raise ValueError( + f"PNG decoded pixels exceed the {MAX_DECODED_BYTES}-byte evidence limit" + ) + saw_ihdr = True + elif chunk_type == b"PLTE": + if saw_plte or saw_idat: + raise ValueError("PNG PLTE must appear at most once before IDAT") + if color_type in (0, 4): + raise ValueError("grayscale PNG must not contain PLTE") + if chunk_length == 0 or chunk_length % 3 != 0 or chunk_length > 768: + raise ValueError("PNG PLTE has an invalid palette size") + if color_type == 3 and chunk_length // 3 > 2**bit_depth: + raise ValueError("PNG PLTE exceeds the indexed bit depth") + saw_plte = True + elif chunk_type == b"IDAT": + saw_idat = True + elif chunk_type == b"IEND": + if chunk_length != 0: + raise ValueError("PNG IEND chunk must be empty") + if not saw_idat: + raise ValueError("PNG is missing IDAT") + try: + consume_decoded(decompressor.flush()) + except zlib.error as error: + raise ValueError("PNG IDAT is not a valid zlib stream") from error + if not decompressor.eof: + raise ValueError("PNG IDAT zlib stream is truncated") + if decompressor.unused_data: + raise ValueError("PNG IDAT contains bytes after the zlib stream") + if ( + decoded_count != expected_decoded + or decoded_rows != height + or decoded_pending + ): + raise ValueError("PNG decoded pixel data does not match IHDR") + if image.read(1): + raise ValueError("PNG contains trailing bytes after IEND") + return width, height + elif saw_idat: + idat_closed = True + + raise ValueError(f"PNG exceeds the {MAX_PNG_CHUNKS}-chunk evidence limit") + except OSError as error: + raise ValueError(f"cannot read PNG: {error}") from error + + +def validate_rect(rect: SampleRect, image_width: int, image_height: int) -> None: + if rect.x + rect.width > image_width or rect.y + rect.height > image_height: + raise ValueError( + f"{rect.name} rectangle is outside the {image_width}x{image_height} PNG" + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Compare traffic-light and title-bar icon vertical centers measured " + "from a packaged-app PNG. Coordinates are physical image pixels." + ) + ) + parser.add_argument("image", type=Path, help="packaged-app PNG") + parser.add_argument("--scale", type=float, required=True, help="PNG pixels per CSS pixel") + parser.add_argument( + "--traffic-rect", + type=parse_rect, + required=True, + help="traffic-light group as NAME:X,Y,WIDTH,HEIGHT", + ) + parser.add_argument( + "--icon-rect", + type=parse_rect, + action="append", + required=True, + help="title-bar icon sample as NAME:X,Y,WIDTH,HEIGHT; repeat for each icon", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + if not math.isfinite(args.scale) or args.scale <= 0: + parser.error("--scale must be a positive finite number") + + try: + width, height = png_dimensions(args.image) + samples = [args.traffic_rect, *args.icon_rect] + if len({sample.name for sample in samples}) != len(samples): + raise ValueError("sample names must be unique") + for sample in samples: + validate_rect(sample, width, height) + except ValueError as error: + parser.error(str(error)) + + traffic_center = args.traffic_rect.center_y / args.scale + deviations: list[float] = [] + print(f"image: {args.image} ({width}x{height}, scale {args.scale:g}x)") + print(f"{args.traffic_rect.name}: center {traffic_center:.3f} CSS px") + for icon in args.icon_rect: + center = icon.center_y / args.scale + deviation = abs(center - traffic_center) + deviations.append(deviation) + print(f"{icon.name}: center {center:.3f} CSS px; deviation {deviation:.3f} CSS px") + + maximum = max(deviations) + passed = maximum <= ALIGNMENT_TOLERANCE_CSS_PX + print(f"maximum deviation: {maximum:.3f} CSS px") + print( + "PASS" + if passed + else f"FAIL (tolerance {ALIGNMENT_TOLERANCE_CSS_PX:.3f} CSS px)" + ) + return 0 if passed else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test_check_license_inventory.py b/scripts/test_check_license_inventory.py new file mode 100644 index 00000000..7344b950 --- /dev/null +++ b/scripts/test_check_license_inventory.py @@ -0,0 +1,291 @@ +import json +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import check_license_inventory as inventory + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +EXPECTED_PACKAGES = { + "codemirror": "6.0.2", + "@codemirror/lang-html": "6.4.12", + "@codemirror/lang-css": "6.3.1", + "@codemirror/state": "6.7.1", + "@codemirror/theme-one-dark": "6.1.3", +} +LICENSE_TEXT = """MIT License + +Copyright (C) 2018-2021 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + + +def _notice_row(package: str, contract: inventory.PackageContract) -> str: + repository = contract.repository + return ( + f"| `{package}` | `{contract.version}` | [{repository}]({repository}) | MIT | " + f"`web/node_modules/{package}/LICENSE` |" + ) + + +def _write_valid_fixture(root: Path) -> None: + dependencies = { + package: contract.version + for package, contract in inventory.CODEMIRROR_PACKAGES.items() + } + web = root / "web" + web.mkdir(parents=True) + (web / "package.json").write_text( + json.dumps({"dependencies": dependencies}), encoding="utf-8" + ) + + importer_lines: list[str] = [] + package_lines: list[str] = [] + snapshot_lines: list[str] = [] + notice_lines = ["# Third-party notices", ""] + for package, contract in inventory.CODEMIRROR_PACKAGES.items(): + key = f"'{package}'" if package.startswith("@") else package + importer_lines.extend( + [ + f" {key}:", + f" specifier: {contract.version}", + f" version: {contract.version}", + ] + ) + package_key = ( + f"'{package}@{contract.version}'" + if package.startswith("@") + else f"{package}@{contract.version}" + ) + package_lines.extend([f" {package_key}:", " resolution: {}"]) + snapshot_lines.extend([f" {package_key}:", " dependencies: {}"]) + notice_lines.extend([_notice_row(package, contract), contract.copyright]) + + package_dir = web / "node_modules" / Path(*package.split("/")) + package_dir.mkdir(parents=True) + (package_dir / "package.json").write_text( + json.dumps( + { + "name": package, + "version": contract.version, + "license": "MIT", + "repository": {"type": "git", "url": contract.repository + ".git"}, + } + ), + encoding="utf-8", + ) + (package_dir / "LICENSE").write_text( + LICENSE_TEXT, encoding="utf-8" + ) + + (web / "pnpm-lock.yaml").write_text( + "lockfileVersion: '9.0'\nimporters:\n .:\n dependencies:\n" + + "\n".join(importer_lines) + + "\npackages:\n" + + "\n".join(package_lines) + + "\nsnapshots:\n" + + "\n".join(snapshot_lines) + + "\n", + encoding="utf-8", + ) + (root / "THIRD_PARTY_NOTICES.md").write_text( + "\n".join(notice_lines) + "\n" + LICENSE_TEXT, encoding="utf-8" + ) + + +class LicenseInventoryTests(unittest.TestCase): + def test_contract_requires_the_exact_five_direct_packages(self) -> None: + self.assertEqual( + EXPECTED_PACKAGES, + { + package: contract.version + for package, contract in inventory.CODEMIRROR_PACKAGES.items() + }, + ) + + def test_ci_and_release_workflows_run_the_fail_closed_inventory(self) -> None: + commands = ( + "python3 -B -m unittest discover -s scripts -p 'test_check_license_inventory.py'", + "python3 -B scripts/check_license_inventory.py", + ) + for workflow in ("ci.yml", "release.yml"): + with self.subTest(workflow=workflow): + document = ( + REPOSITORY_ROOT / ".github" / "workflows" / workflow + ).read_text(encoding="utf-8") + for command in commands: + self.assertIn(command, document) + + def test_repository_inventory_is_valid(self) -> None: + self.assertEqual([], inventory.validate_inventory(REPOSITORY_ROOT)) + + def test_missing_notice_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + _write_valid_fixture(root) + notices_path = root / "THIRD_PARTY_NOTICES.md" + notices = notices_path.read_text(encoding="utf-8") + package = "@codemirror/lang-css" + contract = inventory.CODEMIRROR_PACKAGES[package] + notices_path.write_text( + notices.replace(_notice_row(package, contract), "", 1), + encoding="utf-8", + ) + self.assertIn( + f"{package}@{contract.version} third-party notice is missing", + inventory.validate_inventory(root), + ) + + def test_changed_lock_version_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + _write_valid_fixture(root) + lockfile_path = root / "web" / "pnpm-lock.yaml" + lockfile = lockfile_path.read_text(encoding="utf-8") + lockfile_path.write_text( + lockfile.replace("version: 6.0.2", "version: 6.0.1", 1), + encoding="utf-8", + ) + self.assertIn( + "codemirror lock importer must resolve exactly 6.0.2", + inventory.validate_inventory(root), + ) + + def test_runtime_dependency_moved_to_dev_dependencies_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + _write_valid_fixture(root) + lockfile_path = root / "web" / "pnpm-lock.yaml" + lockfile = lockfile_path.read_text(encoding="utf-8") + stanza = ( + " codemirror:\n" + " specifier: 6.0.2\n" + " version: 6.0.2\n" + ) + self.assertIn(stanza, lockfile) + mutated = lockfile.replace(stanza, "", 1).replace( + "\npackages:\n", + "\n devDependencies:\n" + stanza + "packages:\n", + 1, + ) + lockfile_path.write_text(mutated, encoding="utf-8") + self.assertIn( + "codemirror lock importer must resolve exactly 6.0.2", + inventory.validate_inventory(root), + ) + + def test_unregistered_direct_codemirror_dependency_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + _write_valid_fixture(root) + package_json_path = root / "web" / "package.json" + package_json = json.loads(package_json_path.read_text(encoding="utf-8")) + package_json["dependencies"]["@codemirror/view"] = "6.43.8" + package_json_path.write_text(json.dumps(package_json), encoding="utf-8") + self.assertIn( + "direct CodeMirror dependency set must exactly match the license contract", + inventory.validate_inventory(root), + ) + + def test_missing_package_or_snapshot_record_is_rejected(self) -> None: + for section, record, expected in ( + ( + "packages", + " codemirror@6.0.2:\n resolution: {}\n", + "codemirror@6.0.2 package resolution record is missing", + ), + ( + "snapshots", + " codemirror@6.0.2:\n dependencies: {}\n", + "codemirror@6.0.2 snapshot record is missing", + ), + ): + with self.subTest(section=section), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + _write_valid_fixture(root) + lockfile_path = root / "web" / "pnpm-lock.yaml" + lockfile = lockfile_path.read_text(encoding="utf-8") + self.assertIn(record, lockfile) + lockfile_path.write_text( + lockfile.replace(record, "", 1), encoding="utf-8" + ) + self.assertIn(expected, inventory.validate_inventory(root)) + + def test_installed_repository_and_full_license_are_verified(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + _write_valid_fixture(root) + package_dir = root / "web" / "node_modules" / "codemirror" + manifest_path = package_dir / "package.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["repository"]["url"] = "https://example.invalid/lookalike.git" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + self.assertIn( + "installed codemirror repository does not match the official source", + inventory.validate_inventory(root), + ) + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + _write_valid_fixture(root) + license_path = root / "web" / "node_modules" / "codemirror" / "LICENSE" + license_path.write_text("MIT License\n", encoding="utf-8") + self.assertIn( + "installed codemirror LICENSE does not match its published license", + inventory.validate_inventory(root), + ) + + def test_state_repository_and_notice_drift_are_rejected(self) -> None: + package = "@codemirror/state" + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + _write_valid_fixture(root) + package_dir = root / "web" / "node_modules" / "@codemirror" / "state" + manifest_path = package_dir / "package.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["repository"]["url"] = "https://github.com/codemirror/state.git" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + self.assertIn( + "installed @codemirror/state repository does not match the official source", + inventory.validate_inventory(root), + ) + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + _write_valid_fixture(root) + contract = inventory.CODEMIRROR_PACKAGES[package] + notices_path = root / "THIRD_PARTY_NOTICES.md" + notices = notices_path.read_text(encoding="utf-8") + notices_path.write_text( + notices.replace(_notice_row(package, contract), "", 1), + encoding="utf-8", + ) + self.assertIn( + "@codemirror/state@6.7.1 third-party notice is missing", + inventory.validate_inventory(root), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_check_release_workflow.py b/scripts/test_check_release_workflow.py index ea71479e..ee25f351 100644 --- a/scripts/test_check_release_workflow.py +++ b/scripts/test_check_release_workflow.py @@ -23,7 +23,7 @@ RELEASE_NOTES_PATH = Path( os.environ.get( "OPENTAKE_RELEASE_NOTES_PATH", - REPOSITORY_ROOT / "docs" / "releases" / "1.0.0-beta.4.md", + REPOSITORY_ROOT / "docs" / "releases" / "1.0.0-beta.5.md", ) ).resolve() WORKFLOW = WORKFLOW_PATH.read_text(encoding="utf-8") if WORKFLOW_PATH.is_file() else "" @@ -1227,8 +1227,8 @@ def test_recovery_release_notes_are_loaded_from_exact_tooling_commit( ) -> None: mutations = ( ( - 'git cat-file blob "$RELEASE_TOOLING_SHA:docs/releases/1.0.0-beta.4.md" \\\n', - 'cp docs/releases/1.0.0-beta.4.md \\\n', + 'git cat-file blob "$RELEASE_TOOLING_SHA:docs/releases/1.0.0-beta.5.md" \\\n', + 'cp docs/releases/1.0.0-beta.5.md \\\n', "exact release tooling provenance", ), ( @@ -1264,17 +1264,47 @@ def test_validation_must_compare_all_public_versions(self) -> None: def test_validation_must_bind_windows_installer_version(self) -> None: mutated = self.mutate( - 'if wix_version != "1.0.0.4":', - 'if wix_version != "1.0.0.3":', + 'expected_wix_version = "1.0.0.5"', + 'expected_wix_version = "1.0.0.4"', ) self.assert_rejected(mutated, "Cargo, Tauri, and Web versions match tag") + def test_recovery_must_bind_the_beta4_installer_identity(self) -> None: + mutated = self.mutate( + 'expected_wix_version = "1.0.0.4"', + 'expected_wix_version = "1.0.0.5"', + ) + self.assert_rejected(mutated, "Cargo, Tauri, and Web versions match tag") + + def test_validation_binds_product_identity_to_the_authenticated_event_path( + self, + ) -> None: + for fixture in ( + 'event_name = os.environ["GITHUB_EVENT_NAME"]', + 'if event_name == "workflow_dispatch":', + 'expected_version = "1.0.0-beta.4"', + 'expected_wix_version = "1.0.0.4"', + 'expected_version = "1.0.0-beta.5"', + 'expected_wix_version = "1.0.0.5"', + "if version != expected_version:", + "if wix_version != expected_wix_version:", + ): + with self.subTest(fixture=fixture): + self.assertIn(fixture, WORKFLOW) + def test_release_tag_must_reject_semver_build_metadata(self) -> None: guard = ' if "+" in tag:\n' self.assertEqual(1, WORKFLOW.count(guard)) mutated = self.mutate(guard, ' if False:\n') self.assert_rejected(mutated, "SemVer build metadata is unsupported") + def test_beta5_candidate_must_keep_its_explicit_prerelease_guard(self) -> None: + mutated = self.mutate( + 'if version == "1.0.0-beta.5" and not prerelease:', + 'if version == "1.0.0-beta.4" and not prerelease:', + ) + self.assert_rejected(mutated, "SemVer build metadata is unsupported") + def test_publish_must_depend_on_every_gate(self) -> None: mutated = self.mutate( "needs: [validate, quality, macos_arm64, windows_x64]", @@ -1844,6 +1874,38 @@ def test_quality_must_pin_ruby_before_the_python_validator(self) -> None: mutated, "quality pins Ruby Psych before the release validator" ) + def test_quality_runs_web_license_inventory_fail_closed(self) -> None: + step = ( + " - name: Validate Web dependency licenses\n" + " run: |\n" + " set -euo pipefail\n" + " case \"$OPENTAKE_EXPECTED_RELEASE_VERSION\" in\n" + " 1.0.0-beta.5)\n" + " python3 -B -m unittest discover -s scripts -p 'test_check_license_inventory.py'\n" + " python3 -B scripts/check_license_inventory.py\n" + " ;;\n" + " 1.0.0-beta.4)\n" + " node -e 'const d=require(\"./web/package.json\").dependencies??{}; if(Object.keys(d).some((name)=>name===\"codemirror\"||name.startsWith(\"@codemirror/\"))) process.exit(1)'\n" + " ;;\n" + " *)\n" + " exit 1\n" + " ;;\n" + " esac\n" + ) + self.assertIn(step, WORKFLOW) + self.assert_rejected( + WORKFLOW.replace(step, "", 1), + "approved release step sets", + ) + self.assert_rejected( + WORKFLOW.replace( + " 1.0.0-beta.4)\n", + " 1.0.0-beta.3)\n", + 1, + ), + "approved release run templates", + ) + def test_duplicate_yaml_mapping_key_is_rejected(self) -> None: mutated = self.mutate( "name: Release\n\n", @@ -2104,8 +2166,13 @@ def test_publish_command_cannot_be_faked_by_echo(self) -> None: ) self.assert_rejected(mutated, "verified prerelease publication") - def test_repository_metadata_is_beta4_and_release_notes_exist(self) -> None: - self.assertEqual([], contract.validate_repository_metadata(REPOSITORY_ROOT)) + def test_repository_metadata_is_beta5_and_release_notes_exist(self) -> None: + self.assertEqual( + [], + contract.validate_repository_metadata( + REPOSITORY_ROOT, expected_version=contract.CURRENT_RELEASE_VERSION + ), + ) def test_release_notes_document_normal_push_and_dual_sha_recovery(self) -> None: self.assertTrue(RELEASE_NOTES_PATH.is_file()) @@ -2118,7 +2185,7 @@ def test_release_notes_document_normal_push_and_dual_sha_recovery(self) -> None: "tag must always equal current main\n", encoding="utf-8" ) self.assertEqual( - ["Beta 4 release notes document dual-SHA recovery provenance"], + ["Beta 5 release notes document dual-SHA recovery provenance"], contract.validate_release_notes_contract(notes), ) canonical = RELEASE_NOTES_PATH.read_text(encoding="utf-8") @@ -2137,35 +2204,40 @@ def test_release_notes_document_normal_push_and_dual_sha_recovery(self) -> None: ) self.assertEqual( [ - "Beta 4 release notes document dual-SHA recovery provenance" + "Beta 5 release notes document dual-SHA recovery provenance" ], contract.validate_release_notes_contract(notes), ) class ReleaseRepositoryMetadataTests(unittest.TestCase): - def make_repository(self) -> tuple[tempfile.TemporaryDirectory[str], Path]: + def make_repository( + self, + *, + version: str = "1.0.0-beta.5", + wix_version: str = "1.0.0.5", + ) -> tuple[tempfile.TemporaryDirectory[str], Path]: temporary = tempfile.TemporaryDirectory() root = Path(temporary.name) (root / "src-tauri").mkdir() (root / "web").mkdir() (root / "docs" / "releases").mkdir(parents=True) (root / "Cargo.toml").write_text( - '[workspace.package]\nversion = "1.0.0-beta.4"\n', encoding="utf-8" + f'[workspace.package]\nversion = "{version}"\n', encoding="utf-8" ) (root / "src-tauri" / "tauri.conf.json").write_text( json.dumps( { - "version": "1.0.0-beta.4", - "bundle": {"windows": {"wix": {"version": "1.0.0.4"}}}, + "version": version, + "bundle": {"windows": {"wix": {"version": wix_version}}}, } ), encoding="utf-8", ) (root / "web" / "package.json").write_text( - json.dumps({"version": "1.0.0-beta.4"}), encoding="utf-8" + json.dumps({"version": version}), encoding="utf-8" ) - (root / "docs" / "releases" / "1.0.0-beta.4.md").write_text( + (root / "docs" / "releases" / f"{version}.md").write_text( "release notes\n", encoding="utf-8" ) return temporary, root @@ -2178,13 +2250,15 @@ def test_non_object_json_metadata_returns_a_contract_error(self) -> None: (root / relative_path).write_text("[]\n", encoding="utf-8") self.assertEqual( ["readable Cargo, Tauri, and Web version metadata"], - contract.validate_repository_metadata(root), + contract.validate_repository_metadata( + root, expected_version=contract.CURRENT_RELEASE_VERSION + ), ) def test_release_notes_io_error_returns_a_contract_error(self) -> None: temporary, root = self.make_repository() self.addCleanup(temporary.cleanup) - notes = root / "docs" / "releases" / "1.0.0-beta.4.md" + notes = root / "docs" / "releases" / "1.0.0-beta.5.md" real_read_text = Path.read_text def fail_notes(path: Path, *args, **kwargs): @@ -2194,8 +2268,10 @@ def fail_notes(path: Path, *args, **kwargs): with mock.patch.object(Path, "read_text", autospec=True, side_effect=fail_notes): self.assertEqual( - ["Beta 4 release notes exist"], - contract.validate_repository_metadata(root), + ["Beta 5 release notes exist"], + contract.validate_repository_metadata( + root, expected_version=contract.CURRENT_RELEASE_VERSION + ), ) def test_wrong_windows_installer_version_returns_a_contract_error(self) -> None: @@ -2204,16 +2280,88 @@ def test_wrong_windows_installer_version_returns_a_contract_error(self) -> None: (root / "src-tauri" / "tauri.conf.json").write_text( json.dumps( { - "version": "1.0.0-beta.4", - "bundle": {"windows": {"wix": {"version": "1.0.0.3"}}}, + "version": "1.0.0-beta.5", + "bundle": {"windows": {"wix": {"version": "1.0.0.4"}}}, } ), encoding="utf-8", ) self.assertEqual( - ["Windows installer version is 1.0.0.4"], - contract.validate_repository_metadata(root), + ["Windows installer version is 1.0.0.5"], + contract.validate_repository_metadata( + root, expected_version=contract.CURRENT_RELEASE_VERSION + ), + ) + + def test_each_stale_beta4_product_identity_is_rejected(self) -> None: + mutations = ( + ("Cargo.toml", '[workspace.package]\nversion = "1.0.0-beta.4"\n'), + ( + "src-tauri/tauri.conf.json", + json.dumps( + { + "version": "1.0.0-beta.4", + "bundle": {"windows": {"wix": {"version": "1.0.0.5"}}}, + } + ), + ), + ("web/package.json", json.dumps({"version": "1.0.0-beta.4"})), + ) + for relative_path, contents in mutations: + with self.subTest(path=relative_path): + temporary, root = self.make_repository() + self.addCleanup(temporary.cleanup) + (root / relative_path).write_text(contents, encoding="utf-8") + self.assertIn( + "repository metadata is OpenTake 1.0.0-beta.5", + contract.validate_repository_metadata( + root, expected_version=contract.CURRENT_RELEASE_VERSION + ), + ) + + def test_beta4_notes_do_not_satisfy_the_beta5_release_contract(self) -> None: + temporary, root = self.make_repository() + self.addCleanup(temporary.cleanup) + (root / "docs" / "releases" / "1.0.0-beta.5.md").unlink() + (root / "docs" / "releases" / "1.0.0-beta.4.md").write_text( + "historical release notes\n", encoding="utf-8" + ) + + self.assertEqual( + ["Beta 5 release notes exist"], + contract.validate_repository_metadata( + root, expected_version=contract.CURRENT_RELEASE_VERSION + ), + ) + + def test_approved_beta4_recovery_identity_is_accepted(self) -> None: + temporary, root = self.make_repository( + version="1.0.0-beta.4", wix_version="1.0.0.4" ) + self.addCleanup(temporary.cleanup) + self.assertEqual( + [], + contract.validate_repository_metadata( + root, expected_version="1.0.0-beta.4" + ), + ) + + def test_beta4_and_beta5_identities_cannot_cross_authenticated_paths(self) -> None: + cases = ( + ("1.0.0-beta.4", "1.0.0.4", "1.0.0-beta.5"), + ("1.0.0-beta.5", "1.0.0.5", "1.0.0-beta.4"), + ) + for source_version, source_wix, expected_version in cases: + with self.subTest(source=source_version, expected=expected_version): + temporary, root = self.make_repository( + version=source_version, wix_version=source_wix + ) + self.addCleanup(temporary.cleanup) + self.assertTrue( + contract.validate_repository_metadata( + root, expected_version=expected_version + ) + ) class ReleaseDraftStateMachineTests(unittest.TestCase): diff --git a/scripts/test_measure_titlebar_alignment.py b/scripts/test_measure_titlebar_alignment.py new file mode 100644 index 00000000..c5c197fa --- /dev/null +++ b/scripts/test_measure_titlebar_alignment.py @@ -0,0 +1,178 @@ +import struct +import subprocess +import sys +import tempfile +import unittest +import zlib +from pathlib import Path + + +SCRIPT = Path(__file__).with_name("measure_titlebar_alignment.py") + + +def png_chunk(kind: bytes, data: bytes) -> bytes: + payload = kind + data + return struct.pack(">I", len(data)) + payload + struct.pack(">I", zlib.crc32(payload)) + + +def png_bytes(width: int = 400, height: int = 120) -> bytes: + rows = b"".join(b"\0" + (b"\0\0\0" * width) for _ in range(height)) + return png_with_idat(zlib.compress(rows), width, height) + + +def png_with_idat(idat: bytes, width: int = 400, height: int = 120) -> bytes: + return ( + b"\x89PNG\r\n\x1a\n" + + png_chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + + png_chunk(b"IDAT", idat) + + png_chunk(b"IEND", b"") + ) + + +def write_png(path: Path, width: int = 400, height: int = 120) -> None: + path.write_bytes(png_bytes(width, height)) + + +class TitlebarAlignmentCliTests(unittest.TestCase): + def run_measurement(self, *rectangles: str) -> subprocess.CompletedProcess[str]: + with tempfile.TemporaryDirectory() as temp_dir: + image = Path(temp_dir) / "titlebar.png" + write_png(image) + command = [ + sys.executable, + str(SCRIPT), + str(image), + "--scale", + "2", + "--traffic-rect", + "traffic:20,20,100,28", + ] + for rectangle in rectangles: + command.extend(("--icon-rect", rectangle)) + return subprocess.run(command, capture_output=True, text=True, check=False) + + def run_invalid_image(self, payload: bytes) -> subprocess.CompletedProcess[str]: + with tempfile.TemporaryDirectory() as temp_dir: + image = Path(temp_dir) / "titlebar.png" + image.write_bytes(payload) + return subprocess.run( + [ + sys.executable, + str(SCRIPT), + str(image), + "--scale", + "2", + "--traffic-rect", + "traffic:20,20,100,28", + "--icon-rect", + "home:160,22,24,24", + ], + capture_output=True, + text=True, + check=False, + ) + + def test_accepts_icon_centers_within_one_css_pixel(self) -> None: + result = self.run_measurement( + "home:160,22,24,24", + "chat:196,20,28,28", + "motion:236,18,32,32", + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("maximum deviation: 0.000 CSS px", result.stdout) + self.assertIn("PASS", result.stdout) + + def test_rejects_an_icon_center_more_than_one_css_pixel_away(self) -> None: + result = self.run_measurement("settings:300,25,24,24") + + self.assertEqual(result.returncode, 1) + self.assertIn("1.500 CSS px", result.stdout) + self.assertIn("FAIL", result.stdout) + + def test_accepts_the_exact_one_css_pixel_boundary(self) -> None: + result = self.run_measurement("editor:280,22,24,28") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("1.000 CSS px", result.stdout) + self.assertIn("PASS", result.stdout) + + def test_rejects_sample_rectangles_outside_the_png(self) -> None: + result = self.run_measurement("export:390,20,24,24") + + self.assertEqual(result.returncode, 2) + self.assertIn("outside the 400x120 PNG", result.stderr) + + def test_does_not_allow_the_one_css_pixel_gate_to_be_relaxed(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + image = Path(temp_dir) / "titlebar.png" + write_png(image) + result = subprocess.run( + [ + sys.executable, + str(SCRIPT), + str(image), + "--scale", + "2", + "--traffic-rect", + "traffic:20,20,100,28", + "--icon-rect", + "settings:300,25,24,24", + "--tolerance", + "1.001", + ], + capture_output=True, + text=True, + check=False, + ) + + self.assertEqual(result.returncode, 2) + self.assertIn("unrecognized arguments: --tolerance 1.001", result.stderr) + + def test_rejects_a_truncated_png_header(self) -> None: + result = self.run_invalid_image(png_bytes()[:24]) + + self.assertEqual(result.returncode, 2) + self.assertIn("truncated PNG chunk", result.stderr) + + def test_rejects_a_png_with_a_bad_chunk_crc(self) -> None: + payload = bytearray(png_bytes()) + payload[-1] ^= 1 + + result = self.run_invalid_image(bytes(payload)) + + self.assertEqual(result.returncode, 2) + self.assertIn("IEND CRC mismatch", result.stderr) + + def test_rejects_a_png_without_idat_or_iend(self) -> None: + incomplete = png_bytes() + idat_offset = incomplete.index(b"IDAT") - 4 + + result = self.run_invalid_image(incomplete[:idat_offset]) + + self.assertEqual(result.returncode, 2) + self.assertIn("PNG is missing IDAT and IEND", result.stderr) + + def test_rejects_idat_that_is_not_a_zlib_stream(self) -> None: + result = self.run_invalid_image(png_with_idat(b"not-a-zlib-stream")) + + self.assertEqual(result.returncode, 2) + self.assertIn("IDAT is not a valid zlib stream", result.stderr) + + def test_rejects_a_truncated_deflate_stream(self) -> None: + rows = b"".join(b"\0" + (b"\0\0\0" * 400) for _ in range(120)) + result = self.run_invalid_image(png_with_idat(zlib.compress(rows)[:-2])) + + self.assertEqual(result.returncode, 2) + self.assertIn("IDAT zlib stream is truncated", result.stderr) + + def test_rejects_decoded_pixels_that_do_not_match_ihdr(self) -> None: + one_row = b"\0" + (b"\0\0\0" * 400) + result = self.run_invalid_image(png_with_idat(zlib.compress(one_row))) + + self.assertEqual(result.returncode, 2) + self.assertIn("decoded pixel data does not match IHDR", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7dee8519..ffc8705a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -61,6 +61,7 @@ same-file = "1.0.6" cap-std = "4.0.2" cap-fs-ext = "4.0.2" sha2 = "0.10" +getrandom = "0.3" uuid = { workspace = true } tempfile = "3" # Optional account scaffold: verify a token only against a user-configured @@ -113,8 +114,24 @@ windows-sys = { version = "0.61", features = [ opentake-media = { workspace = true, features = ["test-faults"] } tauri = { version = "2", features = ["protocol-asset", "test"] } tokio = { version = "1", features = ["test-util"] } +rmcp = { version = "2.2.0", features = [ + "client", + "transport-streamable-http-client-reqwest", +] } +# rmcp 2.2's reqwest transport calls the compatibility alias added in 0.2.4; +# 0.2.2/0.2.3 expose only the differently named predecessor. +sse-stream = "=0.2.4" +tracing = "0.1" + +[[test]] +name = "external_mcp_integration" +path = "tests/external_mcp_integration.rs" +required-features = ["external-mcp-integration"] [features] +# Exposes the narrow real-keychain lifecycle harness only to the explicitly +# selected integration target. Normal debug and release builds omit it. +external-mcp-integration = [] # `default` now includes `playback-engine`: the continuous Rust streaming engine # is the shipped preview path (upstream's single always-composited surface), so a # plain `cargo build`/`cargo test` compiles and exercises it. The feature is kept diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 59f59e6c..9434cad2 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -11,6 +11,8 @@ "core:event:allow-listen", "core:event:allow-emit", "core:window:allow-set-fullscreen", + "core:window:allow-set-position", + "core:window:allow-set-size", "dialog:default", "dialog:allow-open", "dialog:allow-save" diff --git a/src-tauri/src/chat.rs b/src-tauri/src/chat.rs index 26649bb2..eb9281a5 100644 --- a/src-tauri/src/chat.rs +++ b/src-tauri/src/chat.rs @@ -18,8 +18,8 @@ use serde::Serialize; use tauri::{AppHandle, Emitter, State}; use opentake_agent::chat::{ - ChatLoop, ChatMessage, ChatSession, ChatSessionStore, ChatTurnGate, EmitLoop, LlmError, - LoopError, LoopEvent, Role, + next_message_id, AgentContentBlock, ChatLoop, ChatMessage, ChatSession, ChatSessionStore, + ChatTurn, ChatTurnGate, EmitLoop, LlmError, LoopError, LoopEvent, Role, ToolCall, }; use opentake_agent::mcp::advanced::AdvancedWorkflowBridge; use opentake_agent::mcp::core_handle::{AppCoreHandle, CoreHandle}; @@ -46,6 +46,15 @@ pub struct ChatState { admission: crate::updater::InstallAdmissionGate, } +/// Immutable handles for long-lived MCP sessions to enter the exact same +/// dispatcher and workflow registry used by in-app Agent chat. +#[derive(Clone)] +#[allow(dead_code)] // Task 4's listener consumes both handles. +pub(crate) struct ExternalMcpComponents { + pub(crate) dispatcher: Arc, + pub(crate) registry: Arc>, +} + #[derive(Clone, Debug, PartialEq, Eq, Hash)] struct SessionKey { project_epoch: u64, @@ -66,6 +75,13 @@ struct TurnCancel { requested: Arc, media: opentake_media::MediaCancelToken, phase: Mutex, + completion: tokio::sync::watch::Sender, +} + +#[derive(Clone, Debug)] +enum TurnCompletion { + Pending, + Terminal(Result, String>), } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -77,10 +93,12 @@ enum TurnPhase { impl TurnCancel { fn new() -> Self { + let (completion, _) = tokio::sync::watch::channel(TurnCompletion::Pending); Self { requested: Arc::new(AtomicBool::new(false)), media: opentake_media::MediaCancelToken::new(), phase: Mutex::new(TurnPhase::Running), + completion, } } @@ -108,6 +126,23 @@ impl TurnCancel { *phase = TurnPhase::Finalizing; true } + + fn complete(&self, result: Result, String>) { + self.completion + .send_replace(TurnCompletion::Terminal(result)); + } + + async fn wait_completion(&self) -> Result, String> { + let mut completion = self.completion.subscribe(); + loop { + if let TurnCompletion::Terminal(result) = &*completion.borrow() { + return result.clone(); + } + if completion.changed().await.is_err() { + return Err("Agent turn completion channel closed".into()); + } + } + } } #[derive(Default)] @@ -122,6 +157,11 @@ enum TurnFinalization { Cancelled, } +enum AuthoritativeHistoryState { + Wait(Arc), + Ready(Vec), +} + #[derive(Clone)] struct ChatProjectContext { project_epoch: u64, @@ -140,6 +180,36 @@ impl ChatProjectContext { } impl ChatState { + pub(crate) fn external_mcp_components(&self) -> ExternalMcpComponents { + ExternalMcpComponents { + dispatcher: self.dispatcher.clone(), + registry: self.registry.clone(), + } + } + + fn project_turn_gate( + &self, + project: &ChatProjectContext, + session_key: &SessionKey, + cancel: Arc, + ) -> Arc { + Arc::new(ProjectTurnGate { + state: self.clone(), + project: project.clone(), + cancel, + undo_scope: agent_undo_scope(session_key), + }) + } + + #[cfg(test)] + pub(crate) fn project_turn_gate_for_test(&self, session_id: &str) -> Arc { + let project = self.project_context().expect("saved test project"); + self.put_project_session(&project, ChatSession::new(session_id)) + .expect("persist test chat session"); + let session_key = project.key(session_id); + self.project_turn_gate(&project, &session_key, Arc::new(TurnCancel::new())) + } + #[cfg(test)] pub fn new( core: AppCore, @@ -155,6 +225,7 @@ impl ChatState { None, None, None, + None, crate::updater::InstallAdmissionGate::default(), ) } @@ -175,6 +246,7 @@ impl ChatState { None, None, None, + None, admission, ) } @@ -190,6 +262,7 @@ impl ChatState { generation_bridge: Arc, motion_bridge: Arc, advanced_bridge: Arc, + motion_document_notify: crate::mcp::MotionDocumentNotifier, admission: crate::updater::InstallAdmissionGate, ) -> Self { Self::new_inner( @@ -200,6 +273,7 @@ impl ChatState { Some(generation_bridge), Some(motion_bridge), Some(advanced_bridge), + Some(motion_document_notify), admission, ) } @@ -213,19 +287,28 @@ impl ChatState { generation_bridge: Option>, motion_bridge: Option>, advanced_bridge: Option>, + motion_document_notify: Option, admission: crate::updater::InstallAdmissionGate, ) -> Self { let handle: Arc = Arc::new(AppCoreHandle::new(core.clone())); let registry = Arc::new(RwLock::new(crate::mcp::build_registry(&workflows_dir))); - let bridge = crate::mcp::build_media_bridge(core.clone(), cache_root, models_dir); - let dispatcher = Arc::new(Dispatcher::with_all_capability_bridges( - handle, - registry.clone(), - Some(bridge), - generation_bridge, - motion_bridge, - advanced_bridge, - )); + let bridge = crate::mcp::build_media_bridge(core.clone(), cache_root.clone(), models_dir); + let motion_documents = crate::mcp::build_motion_document_bridge( + core.clone(), + cache_root, + motion_document_notify, + ); + let dispatcher = Arc::new( + Dispatcher::with_all_capability_bridges( + handle, + registry.clone(), + Some(bridge), + generation_bridge, + motion_bridge, + advanced_bridge, + ) + .with_motion_document_bridge(Some(motion_documents)), + ); let store: Arc = Arc::new(KeyringStore::new()); let sessions = Arc::new(Mutex::new(HashMap::new())); let turns = Arc::new(Mutex::new(TurnRegistry::default())); @@ -276,6 +359,7 @@ impl ChatState { && project_dir.as_ref() == Some(&key.project_dir); if !current { cancel.request(); + cancel.complete(Err("stale Agent chat project identity".into())); } current }); @@ -414,21 +498,17 @@ impl ChatState { owner: &Arc, session: ChatSession, ) -> Result { - let mut turns = self.turns.lock().map_err(|e| e.to_string())?; + let turns = self.turns.lock().map_err(|e| e.to_string())?; let owns_turn = turns .running .get(key) .is_some_and(|registered| Arc::ptr_eq(registered, owner)); if !owns_turn || turns.transition_depth > 0 || !owner.begin_finalization() { - if owns_turn { - turns.running.remove(key); - } return Ok(TurnFinalization::Cancelled); } let _identity = self.core.lock_project_identity_workflow(); let persisted = self.put_project_session_with_identity_held(project, session); - turns.running.remove(key); persisted?; Ok(TurnFinalization::Committed) } @@ -483,8 +563,69 @@ impl ChatState { fn release_turn(&self, key: &SessionKey) { if let Ok(mut turns) = self.turns.lock() { - turns.running.remove(key); + if let Some(owner) = turns.running.remove(key) { + owner.complete(Err( + "Agent turn ended without a durable terminal snapshot".into() + )); + } + } + } + + fn complete_turn(&self, key: &SessionKey, owner: &Arc) { + let terminal = self + .sessions + .lock() + .map_err(|error| error.to_string()) + .and_then(|sessions| { + sessions + .get(key) + .map(|session| session.messages.clone()) + .ok_or_else(|| "Agent turn completed without persisted history".to_string()) + }); + if let Ok(mut turns) = self.turns.lock() { + let owns_turn = turns + .running + .get(key) + .is_some_and(|registered| Arc::ptr_eq(registered, owner)); + if owns_turn { + turns.running.remove(key); + owner.complete(terminal); + } + } + } + + async fn authoritative_project_history( + &self, + expected_project_epoch: u64, + expected_project_path: &str, + session_id: &str, + ) -> Result, String> { + let project = self.project_context_for(expected_project_epoch, expected_project_path)?; + let key = project.key(session_id); + match self.authoritative_history_state(&project, &key, session_id)? { + AuthoritativeHistoryState::Wait(owner) => { + let messages = owner.wait_completion().await?; + self.ensure_project_context(&project)?; + Ok(messages) + } + AuthoritativeHistoryState::Ready(messages) => Ok(messages), + } + } + + fn authoritative_history_state( + &self, + project: &ChatProjectContext, + key: &SessionKey, + session_id: &str, + ) -> Result { + // Exclude a new turn only for this synchronous authoritative read. No + // runtime mutex escapes this helper or crosses an async suspension. + let turns = self.turns.lock().map_err(|e| e.to_string())?; + if let Some(owner) = turns.running.get(key).cloned() { + return Ok(AuthoritativeHistoryState::Wait(owner)); } + let messages = self.take_project_session(project, session_id)?.messages; + Ok(AuthoritativeHistoryState::Ready(messages)) } } @@ -492,20 +633,28 @@ impl ChatState { #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] -struct DeltaPayload { +struct BlockDeltaPayload { project_epoch: u64, project_path: String, session_id: String, + message_id: String, + sequence: u64, + block_index: usize, delta: String, } #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] -struct ToolCallPayload { +struct BlockUpsertPayload { project_epoch: u64, project_path: String, session_id: String, - tool_call: opentake_agent::chat::ToolCall, + message_id: String, + sequence: u64, + block_index: usize, + block: AgentContentBlock, + #[serde(skip_serializing_if = "Option::is_none")] + tool_call: Option, } #[derive(Clone, Serialize)] @@ -514,15 +663,112 @@ struct DonePayload { project_epoch: u64, project_path: String, session_id: String, + message_id: String, + sequence: u64, message: ChatMessage, } +#[derive(Default)] +struct StreamSequenceGate { + next_by_message: HashMap, +} + +impl StreamSequenceGate { + fn accept(&mut self, message_id: &str, sequence: u64) -> bool { + let expected = self + .next_by_message + .entry(message_id.to_string()) + .or_insert(0); + if sequence != *expected { + return false; + } + *expected = expected.saturating_add(1); + true + } + + fn next(&self, message_id: &str) -> u64 { + self.next_by_message.get(message_id).copied().unwrap_or(0) + } +} + +#[derive(Default)] +struct MessageEventSequence(u64); + +impl MessageEventSequence { + fn take(&mut self) -> u64 { + let sequence = self.0; + self.0 = self.0.saturating_add(1); + sequence + } + + fn next(&self) -> u64 { + self.0 + } +} + +fn terminal_message_is_allowed(expected_id: &str, message: &ChatMessage) -> bool { + if message.id != expected_id { + return false; + } + match message.role { + opentake_agent::chat::Role::Assistant => { + message.tool_call_id.is_none() + && message.tool_is_error.is_none() + && message.blocks.iter().all(|block| { + matches!( + block, + AgentContentBlock::Text { .. } | AgentContentBlock::ToolUse { .. } + ) + }) + } + opentake_agent::chat::Role::Tool => { + let Some(tool_call_id) = message.tool_call_id.as_deref() else { + return false; + }; + !tool_call_id.is_empty() + && message.tool_calls.is_empty() + && !message.blocks.is_empty() + && message.blocks.iter().all(|block| { + matches!( + block, + AgentContentBlock::ToolResult { + tool_use_id, + is_error, + .. + } if tool_use_id == tool_call_id + && *is_error == message.tool_is_error + ) + }) + } + opentake_agent::chat::Role::System | opentake_agent::chat::Role::User => false, + } +} + /// Adapt `AppHandle::emit` to the loop's [`EmitLoop`] trait. Each loop event /// becomes a Tauri event the front end listens for. struct AppEmitter { app: AppHandle, state: ChatState, project: ChatProjectContext, + sequences: Mutex, +} + +impl AppEmitter { + fn accept_sequence(&self, message_id: &str, sequence: u64) -> bool { + let accepted = self + .sequences + .lock() + .map(|mut gate| gate.accept(message_id, sequence)) + .unwrap_or(false); + accepted + } + + fn next_sequence(&self, message_id: &str) -> u64 { + self.sequences + .lock() + .map(|gate| gate.next(message_id)) + .unwrap_or(0) + } } impl EmitLoop for AppEmitter { @@ -532,41 +778,88 @@ impl EmitLoop for AppEmitter { return; } match event { - LoopEvent::Delta { session_id, delta } => { + LoopEvent::BlockDelta { + session_id, + message_id, + sequence, + block_index, + delta, + } => { + if !self.accept_sequence(&message_id, sequence) { + return; + } let _ = self.app.emit( "chat_delta", - DeltaPayload { + BlockDeltaPayload { project_epoch: self.project.project_epoch, project_path: self.project.project_dir.to_string_lossy().into_owned(), session_id, + message_id, + sequence, + block_index, delta, }, ); } - LoopEvent::ToolCall { + LoopEvent::BlockUpsert { session_id, - tool_call, + message_id, + sequence, + block_index, + block, } => { + if !self.accept_sequence(&message_id, sequence) { + return; + } let _ = self.app.emit( "chat_tool_call", - ToolCallPayload { + BlockUpsertPayload { project_epoch: self.project.project_epoch, project_path: self.project.project_dir.to_string_lossy().into_owned(), session_id, - tool_call, + message_id, + sequence, + block_index, + tool_call: match &block { + AgentContentBlock::ToolUse { + id, + name, + input, + result, + is_error, + } => Some(ToolCall { + id: id.clone(), + name: name.clone(), + args: input.clone(), + result: result.clone(), + is_error: *is_error, + }), + _ => None, + }, + block, }, ); } LoopEvent::Done { session_id, + message_id, + sequence, message, } => { + if !terminal_message_is_allowed(&message_id, &message) { + return; + } + if !self.accept_sequence(&message_id, sequence) { + return; + } let _ = self.app.emit( "chat_done", DonePayload { project_epoch: self.project.project_epoch, project_path: self.project.project_dir.to_string_lossy().into_owned(), session_id, + message_id, + sequence, message, }, ); @@ -610,9 +903,17 @@ impl ChatTurnGate for ProjectTurnGate { name: &str, args: serde_json::Value, ) -> Option { - self.with_current_project(|| { - dispatcher.dispatch_cancellable_scoped(&self.undo_scope, name, args, &self.cancel.media) - }) + let receipt = self.with_current_project(|| { + dispatcher.dispatch_cancellable_scoped_deferred( + &self.undo_scope, + name, + args, + &self.cancel.media, + ) + })?; + let result = dispatcher.finish_dispatch(receipt, &self.cancel.media); + self.with_current_project(|| ())?; + Some(result) } fn request_cancel(&self) { @@ -681,7 +982,6 @@ pub async fn chat_send( ) -> Result<(), String> { let project = state.project_context_for(expected_project_epoch, &expected_project_path)?; let session_key = project.key(&session_id); - let undo_scope = agent_undo_scope(&session_key); let turn_cancel = Arc::new(TurnCancel::new()); let turn_admission = state.reserve_turn(session_key.clone(), turn_cancel.clone())?; let cancel = turn_cancel.requested.clone(); @@ -701,6 +1001,7 @@ pub async fn chat_send( } let state_clone = state.inner().clone(); let sid = session_id.clone(); + let first_message_id = next_message_id(); tauri::async_runtime::spawn(async move { let _turn_admission = turn_admission; @@ -708,16 +1009,13 @@ pub async fn chat_send( app: app.clone(), state: state_clone.clone(), project: project.clone(), + sequences: Mutex::new(StreamSequenceGate::default()), }; let turn_owner = turn_cancel.clone(); - let gate: Arc = Arc::new(ProjectTurnGate { - state: state_clone.clone(), - project: project.clone(), - cancel: turn_cancel, - undo_scope, - }); + let gate = state_clone.project_turn_gate(&project, &session_key, turn_cancel); let is_codex = chat_provider == "codex"; - let mut codex_final: Option<(String, ChatMessage)> = None; + let mut codex_final: Option = None; + let mut codex_sequence = MessageEventSequence::default(); let result = if is_codex { session.provider = Some("codex".into()); session.model = Some("official-codex-default".into()); @@ -728,71 +1026,148 @@ pub async fn chat_send( gate: gate.clone(), cancel: cancel.clone(), }; + let mut draft = ChatMessage::assistant_blocks_with_id(&first_message_id, Vec::new()); match crate::codex::run_agent_turn(context, &prompt, |tool_call| { - emitter.emit(LoopEvent::ToolCall { - session_id: sid.clone(), - tool_call, - }); + let block_index = draft.upsert_tool_use(tool_call); + if let Some(block) = draft.blocks.get(block_index).cloned() { + emitter.emit(LoopEvent::BlockUpsert { + session_id: sid.clone(), + message_id: first_message_id.clone(), + sequence: codex_sequence.take(), + block_index, + block, + }); + } }) .await { Ok(output) => { - let message = ChatMessage::assistant(output.text.clone(), output.tool_calls); - session.messages.push(message.clone()); - codex_final = Some((output.text, message)); - Ok(()) + for tool_call in output.tool_calls { + let block_index = draft.upsert_tool_use(tool_call); + if let Some(block) = draft.blocks.get(block_index).cloned() { + emitter.emit(LoopEvent::BlockUpsert { + session_id: sid.clone(), + message_id: first_message_id.clone(), + sequence: codex_sequence.take(), + block_index, + block, + }); + } + } + let block_index = draft.append_text_delta(&output.text); + session.messages.push(draft.clone()); + emitter.emit(LoopEvent::BlockDelta { + session_id: sid.clone(), + message_id: first_message_id.clone(), + sequence: codex_sequence.take(), + block_index, + delta: output.text, + }); + codex_final = Some(draft); + Ok(first_message_id.clone()) } - Err(crate::codex::CodexTurnError::Cancelled) => Err(LoopError::Cancelled), + Err(crate::codex::CodexTurnError::Cancelled) => Err(LoopError::cancelled( + &first_message_id, + codex_sequence.next(), + )), Err(crate::codex::CodexTurnError::Unavailable) => { let guide = "Official Codex CLI was not found. Install Codex, then return to Settings → AI and choose Official Codex / ChatGPT.".to_string(); - let message = ChatMessage::assistant(guide.clone(), Vec::new()); + let message = + ChatMessage::assistant_with_id(&first_message_id, &guide, Vec::new()); session.messages.push(message.clone()); - codex_final = Some((guide, message)); - Ok(()) + emitter.emit(LoopEvent::BlockDelta { + session_id: sid.clone(), + message_id: first_message_id.clone(), + sequence: codex_sequence.take(), + block_index: 0, + delta: guide, + }); + codex_final = Some(message); + Ok(first_message_id.clone()) } Err(crate::codex::CodexTurnError::NotAuthenticated) => { let guide = "Codex is not signed in. Open Settings → AI, choose Official Codex / ChatGPT, and sign in with ChatGPT.".to_string(); - let message = ChatMessage::assistant(guide.clone(), Vec::new()); + let message = + ChatMessage::assistant_with_id(&first_message_id, &guide, Vec::new()); session.messages.push(message.clone()); - codex_final = Some((guide, message)); - Ok(()) + emitter.emit(LoopEvent::BlockDelta { + session_id: sid.clone(), + message_id: first_message_id.clone(), + sequence: codex_sequence.take(), + block_index: 0, + delta: guide, + }); + codex_final = Some(message); + Ok(first_message_id.clone()) } Err(crate::codex::CodexTurnError::IncompatibleCli) | Err(crate::codex::CodexTurnError::StrictConfigRejected) => { let guide = "The installed official Codex CLI is not compatible with this OpenTake Beta. Update Codex CLI to version 0.146.0 or newer, then try again.".to_string(); - let message = ChatMessage::assistant(guide.clone(), Vec::new()); + let message = + ChatMessage::assistant_with_id(&first_message_id, &guide, Vec::new()); session.messages.push(message.clone()); - codex_final = Some((guide, message)); - Ok(()) + emitter.emit(LoopEvent::BlockDelta { + session_id: sid.clone(), + message_id: first_message_id.clone(), + sequence: codex_sequence.take(), + block_index: 0, + delta: guide, + }); + codex_final = Some(message); + Ok(first_message_id.clone()) } Err(crate::codex::CodexTurnError::McpStart) | Err(crate::codex::CodexTurnError::Timeout) | Err(crate::codex::CodexTurnError::Protocol) - | Err(crate::codex::CodexTurnError::ProviderFailed) => { - Err(LoopError::Llm(LlmError::Provider( + | Err(crate::codex::CodexTurnError::ProviderFailed) => Err(LoopError::llm( + LlmError::Provider( "official Codex turn failed; check the Codex login status and try again" .into(), - ))) - } + ), + &first_message_id, + codex_sequence.next(), + )), } } else { state_clone .loop_ - .run_turn_gated(&mut session, chat_provider, text, &emitter, cancel, gate) + .run_turn_gated( + &mut session, + chat_provider, + text, + ChatTurn { + first_message_id: first_message_id.clone(), + cancel, + gate, + }, + &emitter, + ) .await }; if is_codex { - let terminal = match result { - Ok(()) => codex_final, - Err(LoopError::Cancelled) => Some(( - String::new(), - ChatMessage::assistant(String::new(), Vec::new()), - )), + let (terminal, terminal_sequence) = match result { + Ok(_) => (codex_final, codex_sequence.next()), + Err(LoopError::Cancelled { + message_id, + sequence, + }) => ( + Some(ChatMessage::assistant_with_id( + message_id, + String::new(), + Vec::new(), + )), + sequence, + ), Err(error) => { - let message = ChatMessage::assistant(format!("⚠️ {error}"), Vec::new()); + let sequence = error.sequence(); + let message = ChatMessage::assistant_with_id( + error.message_id(), + format!("⚠️ {error}"), + Vec::new(), + ); session.messages.push(message.clone()); - Some((String::new(), message)) + (Some(message), sequence) } }; match state_clone.finalize_project_turn( @@ -802,66 +1177,93 @@ pub async fn chat_send( session.clone(), ) { Ok(TurnFinalization::Committed) => { - if let Some((delta, message)) = terminal { - if !delta.is_empty() { - emitter.emit(LoopEvent::Delta { - session_id: sid.clone(), - delta, - }); - } + if let Some(message) = terminal { emitter.emit(LoopEvent::Done { session_id: sid.clone(), + message_id: message.id.clone(), + sequence: terminal_sequence, message, }); } } Ok(TurnFinalization::Cancelled) => { + let message = terminal.unwrap_or_else(|| { + ChatMessage::assistant_with_id(&first_message_id, String::new(), Vec::new()) + }); emitter.emit(LoopEvent::Done { session_id: sid.clone(), - message: ChatMessage::assistant(String::new(), Vec::new()), + message_id: message.id.clone(), + sequence: terminal_sequence, + message, }); } Err(error) => { + let message_id = terminal + .as_ref() + .map(|message| message.id.as_str()) + .unwrap_or(&first_message_id); + let message = ChatMessage::assistant_with_id( + message_id, + format!("⚠️ Chat history could not be saved: {error}"), + Vec::new(), + ); emitter.emit(LoopEvent::Done { session_id: sid.clone(), - message: ChatMessage::assistant( - format!("⚠️ Chat history could not be saved: {error}"), - Vec::new(), - ), + message_id: message.id.clone(), + sequence: terminal_sequence, + message, }); } } + state_clone.complete_turn(&session_key, &turn_owner); return; } match &result { - Err(LoopError::Cancelled) => { + Err(LoopError::Cancelled { + message_id, + sequence, + }) => { + let message = ChatMessage::assistant_with_id(message_id, String::new(), Vec::new()); emitter.emit(LoopEvent::Done { session_id: sid.clone(), - message: ChatMessage::assistant(String::new(), Vec::new()), + message_id: message.id.clone(), + sequence: *sequence, + message, }); } Err(e) => { - let msg = ChatMessage::assistant(format!("⚠️ {e}"), Vec::new()); + let msg = + ChatMessage::assistant_with_id(e.message_id(), format!("⚠️ {e}"), Vec::new()); emitter.emit(LoopEvent::Done { session_id: sid.clone(), + message_id: msg.id.clone(), + sequence: e.sequence(), message: msg.clone(), }); session.messages.push(msg); } - Ok(()) => {} + Ok(_) => {} } if let Err(error) = state_clone.put_project_session(&project, session) { + let message_id = match &result { + Ok(message_id) => message_id.as_str(), + Err(error) => error.message_id(), + }; + let message = ChatMessage::assistant_with_id( + message_id, + format!("⚠️ Chat history could not be saved: {error}"), + Vec::new(), + ); emitter.emit(LoopEvent::Done { session_id: sid.clone(), - message: ChatMessage::assistant( - format!("⚠️ Chat history could not be saved: {error}"), - Vec::new(), - ), + message_id: message.id.clone(), + sequence: emitter.next_sequence(&message.id), + message, }); } - state_clone.release_turn(&session_key); + state_clone.complete_turn(&session_key, &turn_owner); }); Ok(()) @@ -880,6 +1282,23 @@ pub fn chat_history( Ok(state.take_project_session(&project, &session_id)?.messages) } +/// Return only a terminal durable snapshot. If this exact project/session has +/// an active turn, wait for its terminal event boundary without holding the +/// turn registry mutex across the async suspension. +#[tauri::command] +pub async fn chat_history_authoritative( + state: State<'_, ChatState>, + session_id: String, + expected_project_epoch: u64, + expected_project_path: String, +) -> Result, String> { + state + .inner() + .clone() + .authoritative_project_history(expected_project_epoch, &expected_project_path, &session_id) + .await +} + /// `chat_sessions`: newest-first persistent conversations for the project. #[tauri::command] pub fn chat_sessions( @@ -961,6 +1380,45 @@ mod tests { } } + struct BlockingTimelineResultBridge { + capture_started: std::sync::mpsc::Sender<()>, + release_capture: Mutex>, + } + + impl opentake_agent::mcp::media_bridge::MediaBridge for BlockingTimelineResultBridge { + fn visible_timeline_clip_count( + &self, + timeline: &opentake_domain::Timeline, + ) -> Result { + Ok(timeline + .tracks + .iter() + .flat_map(|track| &track.clips) + .filter(|clip| clip.duration_frames > 0) + .count()) + } + + fn capture_timeline_result( + &self, + _request: &opentake_agent::mcp::media_bridge::TimelineResultCaptureRequest, + _cancel: &opentake_media::MediaCancelToken, + ) -> Result< + opentake_agent::tools::result::Block, + opentake_agent::mcp::media_bridge::BridgeError, + > { + self.capture_started.send(()).unwrap(); + self.release_capture + .lock() + .unwrap() + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("test must release the blocked timeline capture"); + Ok(opentake_agent::tools::result::Block::image( + "iVBORw0KGgo=", + "image/png", + )) + } + } + struct RedactionAdvancedBridge; const MOTION_PRIVATE_RENDERER: &str = "PRIVATE_CHAT_MOTION_RENDERER"; @@ -1020,6 +1478,7 @@ mod tests { duration_seconds: 3.0, content_hash: MOTION_PRIVATE_HASH.into(), }, + source_document: None, } } @@ -1116,28 +1575,143 @@ mod tests { } #[test] - fn event_payloads_serialize_in_camel_case() { - let payload = DeltaPayload { + fn event_payloads_serialize_block_addresses_in_camel_case() { + let payload = BlockDeltaPayload { project_epoch: 7, project_path: "/tmp/A.opentake".into(), session_id: "sess-1".into(), + message_id: "assistant-1".into(), + sequence: 0, + block_index: 2, delta: "hi".into(), }; let json = serde_json::to_value(payload).unwrap(); assert_eq!(json["projectEpoch"], 7); assert_eq!(json["projectPath"], "/tmp/A.opentake"); assert_eq!(json["sessionId"], "sess-1"); + assert_eq!(json["messageId"], "assistant-1"); + assert_eq!(json["sequence"], 0); + assert_eq!(json["blockIndex"], 2); assert_eq!(json["delta"], "hi"); + let message = ChatMessage::assistant_with_id("assistant-1", "done", Vec::new()); let done = DonePayload { project_epoch: 7, project_path: "/tmp/A.opentake".into(), session_id: "sess-1".into(), - message: ChatMessage::assistant("done", Vec::new()), + message_id: message.id.clone(), + sequence: 1, + message, }; let json = serde_json::to_value(done).unwrap(); assert_eq!(json["sessionId"], "sess-1"); + assert_eq!(json["messageId"], "assistant-1"); + assert_eq!(json["sequence"], 1); assert_eq!(json["message"]["role"], "assistant"); + assert_eq!(json["message"]["id"], json["messageId"]); + } + + #[test] + fn terminal_contract_allows_only_matching_nonempty_tool_result_messages() { + let assistant = ChatMessage::assistant_with_id("assistant-1", "done", Vec::new()); + assert!(terminal_message_is_allowed("assistant-1", &assistant)); + + let tool = ChatMessage::tool_result("call-1", serde_json::json!({"summary": "ok"})); + assert!(terminal_message_is_allowed(&tool.id, &tool)); + + let mut empty = tool.clone(); + empty.blocks.clear(); + assert!(!terminal_message_is_allowed(&empty.id, &empty)); + + let mut wrong_block = tool.clone(); + wrong_block.blocks = vec![AgentContentBlock::Text { text: "ok".into() }]; + assert!(!terminal_message_is_allowed(&wrong_block.id, &wrong_block)); + + let mut mismatched = tool.clone(); + mismatched.tool_call_id = Some("call-other".into()); + assert!(!terminal_message_is_allowed(&mismatched.id, &mismatched)); + + let mut mismatched_error = tool.clone(); + mismatched_error.tool_is_error = Some(true); + assert!(!terminal_message_is_allowed( + &mismatched_error.id, + &mismatched_error, + )); + + let mut assistant_with_tool_result = assistant; + assistant_with_tool_result.blocks = tool.blocks; + assert!(!terminal_message_is_allowed( + &assistant_with_tool_result.id, + &assistant_with_tool_result, + )); + } + + #[test] + fn tool_done_payload_preserves_terminal_identity_and_sequence() { + let message = ChatMessage::tool_result("call-1", serde_json::json!({"summary": "ok"})); + let payload = DonePayload { + project_epoch: 7, + project_path: "/tmp/A.opentake".into(), + session_id: "sess-1".into(), + message_id: message.id.clone(), + sequence: 1, + message, + }; + + let json = serde_json::to_value(payload).unwrap(); + assert_eq!(json["sequence"], 1); + assert_eq!(json["messageId"], json["message"]["id"]); + assert_eq!(json["message"]["role"], "tool"); + assert_eq!(json["message"]["toolCallId"], "call-1"); + assert_eq!(json["message"]["blocks"][0]["type"], "toolResult"); + assert_eq!(json["message"]["blocks"][0]["toolUseId"], "call-1"); + } + + #[test] + fn stream_sequence_gate_rejects_duplicates_and_gaps_per_message() { + let mut gate = StreamSequenceGate::default(); + + assert!(gate.accept("message-a", 0)); + assert!(!gate.accept("message-a", 0)); + assert!(!gate.accept("message-a", 2)); + assert!(gate.accept("message-a", 1)); + assert!(gate.accept("message-b", 0)); + } + + #[test] + fn block_upsert_payload_retains_beta4_tool_call_decoder_fields() { + let block = AgentContentBlock::ToolUse { + id: "call-1".into(), + name: "split_clip".into(), + input: serde_json::json!({"clipId": "c1"}), + result: Some(serde_json::json!({"ok": true})), + is_error: Some(false), + }; + let payload = BlockUpsertPayload { + project_epoch: 7, + project_path: "/tmp/A.opentake".into(), + session_id: "sess-1".into(), + message_id: "assistant-1".into(), + sequence: 3, + block_index: 1, + tool_call: Some(ToolCall { + id: "call-1".into(), + name: "split_clip".into(), + args: serde_json::json!({"clipId": "c1"}), + result: Some(serde_json::json!({"ok": true})), + is_error: Some(false), + }), + block: block.clone(), + }; + + let wire = serde_json::to_value(payload).unwrap(); + + assert_eq!(wire["messageId"], "assistant-1"); + assert_eq!(wire["sequence"], 3); + assert_eq!(wire["blockIndex"], 1); + assert_eq!(wire["block"], serde_json::to_value(block).unwrap()); + assert_eq!(wire["toolCall"]["id"], "call-1"); + assert_eq!(wire["toolCall"]["args"]["clipId"], "c1"); } #[test] @@ -1516,6 +2090,94 @@ mod tests { assert!(core.media().folders.is_empty()); } + #[test] + fn project_turn_gate_releases_identity_lease_before_timeline_result_capture() { + let temp = tempfile::tempdir().unwrap(); + let core = AppCore::new(); + core.save_project(Some(temp.path().join("A.opentake"))) + .unwrap(); + let state = ChatState::new( + core.clone(), + temp.path().join("no-workflows"), + temp.path().join("chat-cache"), + temp.path().join("chat-models"), + ); + let cancel = Arc::new(TurnCancel::new()); + let gate = ProjectTurnGate { + project: state.project_context().unwrap(), + state, + cancel, + undo_scope: "test:deferred-timeline-capture".into(), + }; + let handle: Arc = Arc::new(AppCoreHandle::new(core.clone())); + let registry = Arc::new(RwLock::new(crate::mcp::build_registry( + &temp.path().join("no-workflows"), + ))); + let (capture_started_tx, capture_started_rx) = std::sync::mpsc::channel(); + let (release_capture_tx, release_capture_rx) = std::sync::mpsc::channel(); + let dispatcher = Dispatcher::with_bridge( + handle, + registry, + Some(Arc::new(BlockingTimelineResultBridge { + capture_started: capture_started_tx, + release_capture: Mutex::new(release_capture_rx), + })), + ); + + let add_result = dispatcher.dispatch( + "add_texts", + serde_json::json!({ + "entries": [ + {"startFrame": 0, "durationFrames": 30, "content": "visible"} + ] + }), + ); + assert!(!add_result.is_error, "{}", add_result.text_joined()); + let clip_id = dispatcher.timeline().tracks[0].clips[0].id.clone(); + + let dispatch_thread = std::thread::spawn(move || { + gate.dispatch( + &dispatcher, + "remove_clips", + serde_json::json!({"clipIds": [clip_id]}), + ) + }); + capture_started_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("remove-last-clip should start timeline capture"); + + let replacement_bundle = temp.path().join("B.opentake"); + let (transitioned_tx, transitioned_rx) = std::sync::mpsc::channel(); + let transition_core = core.clone(); + let transition_thread = std::thread::spawn(move || { + let result = transition_core.save_project(Some(replacement_bundle)); + transitioned_tx.send(result).unwrap(); + }); + let transition_during_capture = + transitioned_rx.recv_timeout(std::time::Duration::from_millis(250)); + let transitioned_while_capture_blocked = transition_during_capture.is_ok(); + + release_capture_tx.send(()).unwrap(); + let dispatch_result = dispatch_thread.join().unwrap(); + let transition_result = match transition_during_capture { + Ok(result) => result, + Err(_) => transitioned_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("project transition should finish after capture is released"), + }; + transition_thread.join().unwrap(); + transition_result.unwrap(); + + assert!( + transitioned_while_capture_blocked, + "timeline capture must not hold the project identity workflow lease" + ); + assert!( + dispatch_result.is_none(), + "a result captured for the replaced project must be discarded" + ); + } + #[test] fn reserving_a_turn_is_atomic_per_project_session() { let temp = tempfile::tempdir().unwrap(); @@ -1687,6 +2349,78 @@ mod tests { assert_eq!(persisted.messages[1].content, "committed"); } + #[test] + fn authoritative_history_waits_until_the_exact_session_terminal_boundary() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap(); + runtime.block_on(async { + let temp = tempfile::tempdir().unwrap(); + let core = AppCore::new(); + let project_path = temp.path().join("Project.opentake"); + core.save_project(Some(project_path.clone())).unwrap(); + let state = ChatState::new( + core, + temp.path().join("no-workflows"), + temp.path().join("chat-cache"), + temp.path().join("chat-models"), + ); + let project = state.project_context().unwrap(); + let mut session = ChatSession::new("chat-authoritative"); + session.messages.push(ChatMessage::user("request")); + state.put_project_session(&project, session.clone()).unwrap(); + let owner = Arc::new(TurnCancel::new()); + let key = project.key(&session.id); + let _turn_admission = state + .reserve_turn(key.clone(), owner.clone()) + .unwrap(); + + let expected_path = project_path.to_string_lossy().into_owned(); + let session_id = session.id.clone(); + let mut history = Box::pin(state.authoritative_project_history( + project.project_epoch, + &expected_path, + &session_id, + )); + assert!(tokio::time::timeout( + std::time::Duration::from_millis(10), + history.as_mut(), + ) + .await + .is_err()); + + session + .messages + .push(ChatMessage::assistant("final reply", Vec::new())); + assert_eq!( + state + .finalize_project_turn(&project, &key, &owner, session) + .unwrap(), + TurnFinalization::Committed, + ); + assert!(tokio::time::timeout( + std::time::Duration::from_millis(10), + history.as_mut(), + ) + .await + .is_err(), "durable commit alone must not precede the terminal event"); + + state.complete_turn(&key, &owner); + let next_owner = Arc::new(TurnCancel::new()); + let _next_turn_admission = state + .reserve_turn(key.clone(), next_owner) + .expect("a new turn may start after the observed turn completes"); + let result = tokio::time::timeout(std::time::Duration::from_secs(1), history).await; + state.release_turn(&key); + let messages = result + .expect("authoritative history must return the observed turn instead of waiting for its successor") + .unwrap(); + assert_eq!(messages.len(), 2); + assert_eq!(messages[1].content, "final reply"); + }); + } + #[test] fn save_as_cancels_and_purges_the_previous_project_turn() { let temp = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/codex.rs b/src-tauri/src/codex.rs index 6ddfeb7b..ff782601 100644 --- a/src-tauri/src/codex.rs +++ b/src-tauri/src/codex.rs @@ -18,6 +18,7 @@ use base64::Engine as _; use opentake_agent::chat::{ChatTurnGate, ToolCall}; use opentake_agent::mcp::dispatch::Dispatcher; use opentake_agent::plugin::registry::PluginRegistry; +use opentake_agent::tools::result::Block; use serde::Serialize; use serde_json::Value; use sha2::{Digest, Sha256}; @@ -38,6 +39,8 @@ const MAX_STDERR_CAPTURE_BYTES: usize = 64 * 1024; const MAX_PROBE_CAPTURE_BYTES: usize = 16 * 1024; const MAX_FINAL_TEXT_BYTES: usize = 256 * 1024; const MAX_TOOL_CALLS: usize = 512; +const MAX_TOOL_RESULT_BLOCKS: usize = 64; +const MAX_TOOL_RESULT_IMAGE_BASE64_BYTES: usize = 1024 * 1024; const CODEX_MCP_BEARER_ENV: &str = "OPENTAKE_CODEX_MCP_BEARER_TOKEN"; const CODEX_CLEANUP_RESERVE: Duration = Duration::from_secs(2); @@ -675,6 +678,63 @@ enum ExecEvent { TurnFailed, } +fn normalized_codex_tool_result(item: &Value, failed: bool) -> Result { + if failed { + return Ok(serde_json::json!({ "status": "failed" })); + } + let Some(content) = item.get("result").and_then(|result| result.get("content")) else { + return Ok(serde_json::json!({ "status": "completed" })); + }; + let content = content.as_array().ok_or(CodexTurnError::Protocol)?; + if content.is_empty() { + return Ok(serde_json::json!({ "status": "completed" })); + } + if content.len() > MAX_TOOL_RESULT_BLOCKS { + return Err(CodexTurnError::Protocol); + } + + let mut blocks = Vec::with_capacity(content.len()); + for content_block in content { + match content_block.get("type").and_then(Value::as_str) { + Some("text") => { + let text = content_block + .get("text") + .and_then(Value::as_str) + .ok_or(CodexTurnError::Protocol)?; + if text.len() > MAX_FINAL_TEXT_BYTES { + return Err(CodexTurnError::Protocol); + } + blocks.push(Block::text(text)); + } + Some("image") => { + let base64 = content_block + .get("data") + .and_then(Value::as_str) + .ok_or(CodexTurnError::Protocol)?; + let media_type = content_block + .get("mimeType") + .and_then(Value::as_str) + .ok_or(CodexTurnError::Protocol)?; + if base64.is_empty() + || base64.len() > MAX_TOOL_RESULT_IMAGE_BASE64_BYTES + || !matches!( + media_type, + "image/png" | "image/jpeg" | "image/webp" | "image/gif" + ) + || base64::engine::general_purpose::STANDARD + .decode(base64) + .is_err() + { + return Err(CodexTurnError::Protocol); + } + blocks.push(Block::image(base64, media_type)); + } + _ => return Err(CodexTurnError::Protocol), + } + } + Ok(serde_json::json!({ "content": blocks })) +} + fn parse_exec_event( line: &str, tool_calls: &mut HashMap, @@ -726,15 +786,23 @@ fn parse_exec_event( .cloned() .unwrap_or_else(|| serde_json::json!({})); let args = redacted_tool_args(&name, args); + let failed = if event_type == "item.completed" { + let result_error = match item.get("result").and_then(|result| result.get("isError")) + { + Some(Value::Bool(value)) => *value, + Some(_) => return Err(CodexTurnError::Protocol), + None => false, + }; + item.get("error").is_some_and(|value| !value.is_null()) || result_error + } else { + false + }; let mut call = tool_calls .remove(&id) .unwrap_or_else(|| ToolCall::request(id.clone(), name, args)); if event_type == "item.completed" { - let failed = item.get("error").is_some_and(|value| !value.is_null()); call.is_error = Some(failed); - call.result = Some(serde_json::json!({ - "status": if failed { "failed" } else { "completed" } - })); + call.result = Some(normalized_codex_tool_result(item, failed)?); } let changed = !existed || previous_result.as_ref() != call.result.as_ref(); tool_calls.insert(id.clone(), call); @@ -1613,6 +1681,116 @@ mod tests { ); } + #[test] + fn preserves_bounded_codex_mcp_text_and_raster_result_content() { + let mut calls = HashMap::new(); + let event = serde_json::json!({ + "type": "item.completed", + "item": { + "id": "clear-timeline", + "type": "mcp_tool_call", + "tool": "remove_clips", + "arguments": { "clipIds": ["clip-1"] }, + "result": { + "content": [ + { "type": "text", "text": "Removed 1 clip" }, + { + "type": "image", + "data": "iVBORw0KGgo=", + "mimeType": "image/png" + } + ], + "structuredContent": { "privatePath": "/must/not/persist" } + }, + "error": null + } + }); + + assert_eq!( + parse_exec_event(&event.to_string(), &mut calls), + Ok(ExecEvent::ToolChanged("clear-timeline".into())) + ); + assert_eq!( + calls["clear-timeline"].result, + Some(serde_json::json!({ + "content": [ + { "kind": "text", "text": "Removed 1 clip" }, + { + "kind": "image", + "base64": "iVBORw0KGgo=", + "mediaType": "image/png" + } + ] + })) + ); + assert!(!serde_json::to_string(&calls) + .unwrap() + .contains("privatePath")); + assert_eq!( + parse_exec_event(&event.to_string(), &mut calls), + Ok(ExecEvent::Ignored), + "an exact rich-result retry must remain idempotent" + ); + } + + #[test] + fn codex_mcp_error_marker_is_strict_and_error_content_is_redacted() { + const PRIVATE_SENTINEL: &str = "PRIVATE_CODEX_MCP_ERROR_SENTINEL"; + let malformed = serde_json::json!({ + "type": "item.completed", + "item": { + "id": "malformed-error", + "type": "mcp_tool_call", + "tool": "remove_clips", + "arguments": {}, + "result": { + "isError": "true", + "content": [{ "type": "text", "text": PRIVATE_SENTINEL }] + }, + "error": null + } + }); + let mut calls = HashMap::new(); + assert_eq!( + parse_exec_event(&malformed.to_string(), &mut calls), + Err(CodexTurnError::Protocol) + ); + assert!(calls.is_empty()); + + for (index, item_error) in [ + Value::Null, + serde_json::json!({ "message": PRIVATE_SENTINEL }), + ] + .into_iter() + .enumerate() + { + let event = serde_json::json!({ + "type": "item.completed", + "item": { + "id": format!("private-error-{index}"), + "type": "mcp_tool_call", + "tool": "remove_clips", + "arguments": {}, + "result": { + "isError": item_error.is_null(), + "content": [{ "type": "text", "text": PRIVATE_SENTINEL }] + }, + "error": item_error + } + }); + assert!(matches!( + parse_exec_event(&event.to_string(), &mut calls), + Ok(ExecEvent::ToolChanged(_)) + )); + } + let persisted = serde_json::to_string(&calls).unwrap(); + assert!(!persisted.contains(PRIVATE_SENTINEL)); + assert!(calls.values().all(|call| { + call.result == Some(serde_json::json!({ "status": "failed" })) + && call.is_error == Some(true) + })); + } + #[test] fn codex_import_args_are_redacted_before_events_blocks_and_session_json() { const URL_TOKEN: &str = "CODEX_SENTINEL_URL_TOKEN"; diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f32f2cba..c51233ea 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -562,47 +562,352 @@ pub async fn project_open(app: AppHandle, path: String) -> Result, - admission: State<'_, crate::updater::InstallAdmissionGate>, +pub async fn project_save( + app: AppHandle, path: Option, expected_project_epoch: u64, expected_project_path: Option, ) -> Result { - let _activity = - crate::updater::begin_mutating_activity(&admission).map_err(validation_error)?; - project_save_for_project( - &core, + save_project_with_composite_cover(app, path, expected_project_epoch, expected_project_path) + .await +} + +const PROJECT_COVER_SAVE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); + +#[derive(Debug, PartialEq, Eq)] +enum ProjectCoverCapture { + Captured(Vec), + NoVisibleContent, + CaptureFailed, +} + +const COVER_SAVE_CAPTURING: u8 = 0; +const COVER_SAVE_PRECOMMIT: u8 = 1; +const COVER_SAVE_COMMITTING: u8 = 2; +const COVER_SAVE_CANCELLED: u8 = 3; + +/// Linearizes timeout cancellation against the final project publication. +/// A timeout may return immediately while capture/precommit work is still +/// running, but once the worker owns `COMMITTING` the async caller must await +/// and report the real publication result. +#[derive(Debug, Default)] +struct ProjectCoverCommitGate { + phase: std::sync::atomic::AtomicU8, +} + +impl ProjectCoverCommitGate { + fn enter_precommit(&self) -> bool { + self.phase + .compare_exchange( + COVER_SAVE_CAPTURING, + COVER_SAVE_PRECOMMIT, + std::sync::atomic::Ordering::AcqRel, + std::sync::atomic::Ordering::Acquire, + ) + .is_ok() + } + + fn begin_commit(&self) -> bool { + self.phase + .compare_exchange( + COVER_SAVE_PRECOMMIT, + COVER_SAVE_COMMITTING, + std::sync::atomic::Ordering::AcqRel, + std::sync::atomic::Ordering::Acquire, + ) + .is_ok() + } + + /// Returns `true` only when timeout won before publication began. + fn cancel_before_commit(&self) -> bool { + loop { + let phase = self.phase.load(std::sync::atomic::Ordering::Acquire); + match phase { + COVER_SAVE_CAPTURING | COVER_SAVE_PRECOMMIT => { + if self + .phase + .compare_exchange( + phase, + COVER_SAVE_CANCELLED, + std::sync::atomic::Ordering::AcqRel, + std::sync::atomic::Ordering::Acquire, + ) + .is_ok() + { + return true; + } + } + COVER_SAVE_COMMITTING => return false, + COVER_SAVE_CANCELLED => return true, + _ => unreachable!("invalid project cover commit phase"), + } + } + } +} + +async fn await_project_cover_save_worker( + operation: &'static str, + timeout: std::time::Duration, + cancel: opentake_media::MediaCancelToken, + gate: std::sync::Arc, + mut task: tauri::async_runtime::JoinHandle>, +) -> Result { + match tokio::time::timeout(timeout, &mut task).await { + Ok(Ok(result)) => result, + Ok(Err(error)) => Err(internal_error(format!("{operation} task failed: {error}"))), + Err(_) => { + cancel.cancel(); + if gate.cancel_before_commit() { + Err(internal_error(format!( + "{operation} timed out after {timeout:?}" + ))) + } else { + match task.await { + Ok(result) => result, + Err(error) => Err(internal_error(format!( + "{operation} task failed after commit began: {error}" + ))), + } + } + } + } +} + +pub(crate) async fn save_project_with_composite_cover( + app: AppHandle, + path: Option, + expected_project_epoch: u64, + expected_project_path: Option, +) -> Result { + if let Some(target) = path.as_deref().map(std::path::Path::new) { + if !crate::safe_asset_protocol::scope_allows_lexical_path( + &app.asset_protocol_scope(), + target, + ) { + return Err(validation_error( + "project path has not been approved by a native file dialog".to_string(), + )); + } + } + let cancel = opentake_media::MediaCancelToken::new(); + let worker_cancel = cancel.clone(); + let gate = std::sync::Arc::new(ProjectCoverCommitGate::default()); + let worker_gate = gate.clone(); + let deadline = std::time::Instant::now() + PROJECT_COVER_SAVE_TIMEOUT; + let task = tauri::async_runtime::spawn_blocking(move || { + let admission = app.state::(); + let _activity = + crate::updater::begin_mutating_activity(&admission).map_err(validation_error)?; + let core = app.state::(); + let render = app.state::(); + save_project_with_composite_cover_blocking( + &app, + &core, + &render, + path, + expected_project_epoch, + expected_project_path, + &worker_cancel, + deadline, + &worker_gate, + ) + }); + await_project_cover_save_worker( + "project save", + PROJECT_COVER_SAVE_TIMEOUT, + cancel, + gate, + task, + ) + .await +} + +/// CloseRequested parity entry point. It snapshots the current identity once +/// and delegates to exactly the same bounded authoritative-cover save helper as +/// the explicit Save command. +pub(crate) async fn save_current_project_with_composite_cover( + app: AppHandle, +) -> Result { + let snapshot = app.state::().runtime_snapshot(); + save_project_with_composite_cover( + app, + None, + snapshot.project_epoch, + snapshot + .project_dir + .map(|path| path.to_string_lossy().into_owned()), + ) + .await +} + +#[allow(clippy::too_many_arguments)] +fn save_project_with_composite_cover_blocking( + app: &AppHandle, + core: &AppCore, + render: &crate::render::RenderState, + path: Option, + expected_project_epoch: u64, + expected_project_path: Option, + cancel: &opentake_media::MediaCancelToken, + deadline: std::time::Instant, + gate: &ProjectCoverCommitGate, +) -> Result { + project_save_for_project_with_commit_gate( + core, path, expected_project_epoch, expected_project_path, - |snapshot| { - opentake_media::capture_project_thumbnail( - &snapshot.timeline, - &snapshot.media, - snapshot.project_dir.as_deref(), - ) - }, + |snapshot| capture_composite_project_thumbnail(app, core, snapshot, render, cancel), + gate, + || {}, + || !cancel.is_cancelled() && std::time::Instant::now() < deadline, ) } +fn capture_composite_project_thumbnail( + app: &AppHandle, + core: &AppCore, + snapshot: &opentake_core::ProjectRuntimeSnapshot, + render: &crate::render::RenderState, + cancel: &opentake_media::MediaCancelToken, +) -> ProjectCoverCapture { + let bounds = opentake_media::PROJECT_COMPOSITE_COVER_BOUNDS; + let frame_index = match crate::render::representative_timeline_frame( + &snapshot.timeline, + &snapshot.media, + bounds.0.max(bounds.1), + ) { + Ok(Some(frame)) => frame, + Ok(None) => return ProjectCoverCapture::NoVisibleContent, + Err(_) => return ProjectCoverCapture::CaptureFailed, + }; + let authority = match authorize_composite_sources(app, core, snapshot) { + Ok(authority) => authority, + Err(_) => return ProjectCoverCapture::CaptureFailed, + }; + let composite = match crate::render::composite_timeline_frame_authorized( + &snapshot.timeline, + &snapshot.media, + &snapshot.project_dir, + render, + frame_index, + bounds.0.max(bounds.1), + cancel, + &authority, + ) { + Ok(composite) => composite, + Err(error) => { + eprintln!("[project-cover] {error}"); + return ProjectCoverCapture::CaptureFailed; + } + }; + let frame = opentake_media::RgbaFrame::new(composite.width, composite.height, composite.rgba); + opentake_media::encode_project_composite_thumbnail(&frame, bounds).map_or( + ProjectCoverCapture::CaptureFailed, + ProjectCoverCapture::Captured, + ) +} + +fn authorize_composite_sources( + app: &AppHandle, + core: &AppCore, + snapshot: &opentake_core::ProjectRuntimeSnapshot, +) -> Result { + let project_authority = core.project_asset_authority(); + let scope = app.asset_protocol_scope(); + let mut files = std::collections::HashMap::new(); + for entry in &snapshot.media.entries { + let retained = match &entry.source { + opentake_domain::MediaSource::External { absolute_path } => { + let requested = std::path::Path::new(absolute_path); + if !crate::safe_asset_protocol::scope_allows_lexical_path(&scope, requested) { + continue; + } + let Ok((file, final_path)) = + crate::safe_asset_protocol::open_retained_regular_file(requested) + else { + continue; + }; + if !crate::safe_asset_protocol::scope_allows_lexical_path(&scope, &final_path) { + continue; + } + file + } + opentake_domain::MediaSource::Project { relative_path } => { + let Ok(file) = core.open_project_asset(std::path::Path::new(relative_path)) else { + continue; + }; + file + } + }; + files.insert(entry.id.clone(), retained); + } + if project_authority + .as_ref() + .is_some_and(|authority| !core.project_asset_authority_matches(authority)) + || core.project_revision().project_epoch != snapshot.project_epoch + { + return Err("project source authority changed during cover capture".to_string()); + } + Ok(crate::render::CompositeSourceAuthority::new(files)) +} + +#[cfg(test)] fn project_save_for_project( core: &AppCore, path: Option, expected_project_epoch: u64, expected_project_path: Option, - capture_thumbnail: impl FnOnce(&opentake_core::ProjectRuntimeSnapshot) -> Option>, + capture_thumbnail: impl FnOnce(&opentake_core::ProjectRuntimeSnapshot) -> ProjectCoverCapture, +) -> Result { + project_save_for_project_with_checkpoint( + core, + path, + expected_project_epoch, + expected_project_path, + capture_thumbnail, + || true, + ) +} + +#[cfg(test)] +fn project_save_for_project_with_checkpoint( + core: &AppCore, + path: Option, + expected_project_epoch: u64, + expected_project_path: Option, + capture_thumbnail: impl FnOnce(&opentake_core::ProjectRuntimeSnapshot) -> ProjectCoverCapture, + can_commit: impl FnOnce() -> bool, +) -> Result { + let gate = ProjectCoverCommitGate::default(); + project_save_for_project_with_commit_gate( + core, + path, + expected_project_epoch, + expected_project_path, + capture_thumbnail, + &gate, + || {}, + can_commit, + ) +} + +#[allow(clippy::too_many_arguments)] +fn project_save_for_project_with_commit_gate( + core: &AppCore, + path: Option, + expected_project_epoch: u64, + expected_project_path: Option, + capture_thumbnail: impl FnOnce(&opentake_core::ProjectRuntimeSnapshot) -> ProjectCoverCapture, + gate: &ProjectCoverCommitGate, + before_publication: impl FnOnce(), + can_commit: impl FnOnce() -> bool, ) -> Result { let snapshot = core.runtime_snapshot(); if snapshot.project_epoch != expected_project_epoch @@ -611,14 +916,26 @@ fn project_save_for_project( { return Err(CmdError::from(opentake_core::CoreError::StaleProject)); } - let thumbnail = capture_thumbnail(&snapshot); - + let thumbnail = match capture_thumbnail(&snapshot) { + ProjectCoverCapture::Captured(bytes) => opentake_project::ThumbnailUpdate::Replace(bytes), + ProjectCoverCapture::NoVisibleContent => opentake_project::ThumbnailUpdate::Remove, + ProjectCoverCapture::CaptureFailed => opentake_project::ThumbnailUpdate::Preserve, + }; + if !gate.enter_precommit() { + return Err(internal_error( + "project cover save was cancelled before precommit", + )); + } let target = path.map(std::path::PathBuf::from); - core.save_project_with_thumbnail_for_project( + core.save_project_with_thumbnail_update_for_project_if( expected_project_epoch, expected_project_path.as_deref().map(std::path::Path::new), target, thumbnail, + || { + before_publication(); + can_commit() && gate.begin_commit() + }, ) .map(|p| p.to_string_lossy().into_owned()) .map_err(CmdError::from) @@ -2040,12 +2357,25 @@ impl KeyframeValueDto { #[cfg(test)] mod project_open_async_tests { use super::{ - prepare_saved_project_off_thread, project_save_for_project, run_blocking_with_timeout, - ProjectLifecycleCoordinator, + await_project_cover_save_worker, capture_composite_project_thumbnail, internal_error, + prepare_saved_project_off_thread, project_save_for_project, + project_save_for_project_with_checkpoint, project_save_for_project_with_commit_gate, + run_blocking_with_timeout, save_current_project_with_composite_cover, ProjectCoverCapture, + ProjectCoverCommitGate, ProjectLifecycleCoordinator, }; use opentake_core::core::PreparedProjectOpen; use opentake_core::AppCore; use std::time::Duration; + use tauri::Manager as _; + + fn jpeg_bytes(color: [u8; 3]) -> Vec { + let pixels = color.repeat(16 * 9); + let mut bytes = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 80) + .encode(&pixels, 16, 9, image::ExtendedColorType::Rgb8) + .expect("encode test JPEG"); + bytes + } #[cfg(unix)] #[test] fn prepared_project_detects_an_ambient_namespace_rebind_before_commit() { @@ -2375,7 +2705,7 @@ mod project_open_async_tests { |_| { capture_started.send(()).expect("announce capture"); capture_released.recv().expect("release capture"); - Some(b"thumbnail".to_vec()) + ProjectCoverCapture::Captured(b"thumbnail".to_vec()) }, ) }); @@ -2398,6 +2728,565 @@ mod project_open_async_tests { Some(second.as_path()) ); } + + #[test] + fn thumbnail_capture_failure_preserves_the_previous_valid_cover() { + let fixture = tempfile::tempdir().expect("fixture tempdir"); + let bundle = fixture.path().join("KeepCover.opentake"); + let previous = jpeg_bytes([20, 40, 80]); + let mut project = opentake_project::Project::new(&bundle); + project.thumbnail = Some(previous.clone()); + project.save().expect("save prior cover fixture"); + let core = AppCore::new(); + core.open_project(&bundle).expect("open fixture"); + let snapshot = core.runtime_snapshot(); + + project_save_for_project( + &core, + None, + snapshot.project_epoch, + Some(bundle.to_string_lossy().into_owned()), + |_| ProjectCoverCapture::CaptureFailed, + ) + .expect("project save remains best effort"); + + assert_eq!( + std::fs::read(bundle.join("thumbnail.jpg")).expect("read retained cover"), + previous + ); + } + + #[test] + fn thumbnail_no_visible_content_removes_even_an_invalid_prior_cover() { + let fixture = tempfile::tempdir().expect("fixture tempdir"); + let bundle = fixture.path().join("EmptyCover.opentake"); + let mut project = opentake_project::Project::new(&bundle); + project.thumbnail = Some(b"not a jpeg".to_vec()); + project.save().expect("save invalid prior fixture"); + let core = AppCore::new(); + core.open_project(&bundle).expect("open fixture"); + let snapshot = core.runtime_snapshot(); + + project_save_for_project( + &core, + None, + snapshot.project_epoch, + Some(bundle.to_string_lossy().into_owned()), + |_| ProjectCoverCapture::NoVisibleContent, + ) + .expect("empty project save succeeds"); + + assert!(!bundle.join("thumbnail.jpg").exists()); + } + + #[test] + fn cancelled_cover_worker_cannot_commit_a_late_capture() { + let fixture = tempfile::tempdir().expect("cancel fixture"); + let bundle = fixture.path().join("Cancelled.opentake"); + let previous = jpeg_bytes([11, 22, 33]); + let mut project = opentake_project::Project::new(&bundle); + project.thumbnail = Some(previous.clone()); + project.save().expect("save prior cover"); + let core = AppCore::new(); + core.open_project(&bundle).expect("open fixture"); + let snapshot = core.runtime_snapshot(); + + let error = project_save_for_project_with_checkpoint( + &core, + None, + snapshot.project_epoch, + Some(bundle.to_string_lossy().into_owned()), + |_| ProjectCoverCapture::Captured(jpeg_bytes([200, 100, 50])), + || false, + ) + .expect_err("cancelled worker must not enter the commit"); + + assert_eq!(error.code, "internal"); + assert_eq!( + std::fs::read(bundle.join("thumbnail.jpg")).expect("prior cover remains"), + previous + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn timed_out_cover_precommit_barrier_prevents_post_response_publication() { + let fixture = tempfile::tempdir().expect("timeout fixture"); + let bundle = fixture.path().join("TimedOut.opentake"); + let previous = jpeg_bytes([12, 24, 48]); + let replacement = jpeg_bytes([220, 110, 55]); + let mut project = opentake_project::Project::new(&bundle); + project.thumbnail = Some(previous.clone()); + project.save().expect("save prior cover"); + let core = AppCore::new(); + core.open_project(&bundle).expect("open fixture"); + let snapshot = core.runtime_snapshot(); + let gate = std::sync::Arc::new(ProjectCoverCommitGate::default()); + let cancel = opentake_media::MediaCancelToken::new(); + let (reached_tx, reached_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let (done_tx, done_rx) = tokio::sync::oneshot::channel(); + let worker_gate = gate.clone(); + let worker = tauri::async_runtime::spawn_blocking(move || { + let result = project_save_for_project_with_commit_gate( + &core, + None, + snapshot.project_epoch, + Some(bundle.to_string_lossy().into_owned()), + |_| ProjectCoverCapture::Captured(replacement), + &worker_gate, + move || { + reached_tx.send(()).expect("signal precommit barrier"); + release_rx.recv().expect("release precommit barrier"); + }, + || true, + ); + let _ = done_tx.send(()); + result + }); + + tokio::time::timeout(Duration::from_secs(1), reached_rx) + .await + .expect("worker reaches the post-checkpoint barrier") + .expect("barrier sender remains live"); + let error = await_project_cover_save_worker( + "project save", + Duration::from_millis(10), + cancel, + gate, + worker, + ) + .await + .expect_err("precommit timeout returns without publishing"); + + assert_eq!(error.code, "internal"); + assert!(error.message.contains("timed out")); + assert_eq!( + std::fs::read(fixture.path().join("TimedOut.opentake/thumbnail.jpg")) + .expect("cover remains at timeout response"), + previous + ); + + release_tx.send(()).expect("release detached worker"); + tokio::time::timeout(Duration::from_secs(1), done_rx) + .await + .expect("cancelled worker finishes") + .expect("done sender remains live"); + assert_eq!( + std::fs::read(fixture.path().join("TimedOut.opentake/thumbnail.jpg")) + .expect("cover remains after detached worker finishes"), + previous + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn timeout_waits_for_the_actual_result_once_cover_commit_begins() { + let gate = std::sync::Arc::new(ProjectCoverCommitGate::default()); + assert!(gate.enter_precommit()); + assert!(gate.begin_commit()); + let cancel = opentake_media::MediaCancelToken::new(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let worker = tauri::async_runtime::spawn_blocking(move || { + release_rx.recv().expect("release committing worker"); + Err(internal_error("actual publication failure")) + }); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(40)).await; + release_tx.send(()).expect("release after timeout"); + }); + + let error = await_project_cover_save_worker( + "project save", + Duration::from_millis(10), + cancel, + gate, + worker, + ) + .await + .expect_err("committing worker's real result wins over timeout"); + + assert_eq!(error.code, "internal"); + assert_eq!(error.message, "actual publication failure"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn close_requested_uses_the_same_authoritative_project_local_cover_save() { + use opentake_domain::{Clip, ClipType, MediaManifestEntry, MediaSource, Track}; + + if !opentake_media::ffmpeg_status::ffmpeg_available() + || opentake_render::RenderDevice::try_new().is_err() + { + return; + } + let fixture = tempfile::tempdir().expect("close fixture"); + let bundle = fixture.path().join("Close.opentake"); + std::fs::create_dir_all(bundle.join("media")).expect("create retained media"); + image::RgbaImage::from_pixel(80, 120, image::Rgba([30, 140, 220, 255])) + .save(bundle.join("media/inside.png")) + .expect("write project-local image"); + let mut project = opentake_project::Project::new(&bundle); + project.timeline.width = 80; + project.timeline.height = 120; + let mut clip = Clip::new("clip", "local", 0, 30); + clip.media_type = ClipType::Image; + let mut track = Track::new("video", ClipType::Video); + track.clips.push(clip); + project.timeline.tracks.push(track); + project.manifest.entries.push(MediaManifestEntry { + id: "local".into(), + name: "local".into(), + kind: ClipType::Image, + source: MediaSource::Project { + relative_path: "media/inside.png".into(), + }, + duration: 1.0, + source_width: Some(80), + source_height: Some(120), + source_fps: None, + generation_input: None, + has_audio: Some(false), + color: None, + proxy: None, + folder_id: None, + cached_remote_url: None, + cached_remote_url_expires_at: None, + }); + project.save().expect("save local project"); + let core = AppCore::new(); + core.open_project(&bundle).expect("open local project"); + let app = tauri::test::mock_builder() + .manage(core) + .manage(crate::render::RenderState::new()) + .manage(crate::updater::InstallAdmissionGate::default()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("build managed mock app"); + + save_current_project_with_composite_cover(app.handle().clone()) + .await + .expect("close parity save"); + + let cover = image::open(bundle.join("thumbnail.jpg")) + .expect("close writes cover") + .to_rgb8(); + assert_eq!((cover.width(), cover.height()), (640, 360)); + let center = cover.get_pixel(320, 180).0; + assert!(center[2] > 120 && center[1] > 70, "{center:?}"); + assert!(cover + .get_pixel(20, 180) + .0 + .iter() + .all(|channel| *channel < 20)); + } + + fn capture_corrupt_external(kind: opentake_domain::ClipType) -> ProjectCoverCapture { + use opentake_domain::{Clip, MediaManifestEntry, MediaSource, Track}; + use tauri::Manager as _; + + let fixture = tempfile::tempdir().expect("corrupt fixture"); + let source = fixture.path().join(match kind { + opentake_domain::ClipType::Video => "broken.mp4", + _ => "broken.png", + }); + std::fs::write(&source, b"corrupt media bytes").expect("write corrupt source"); + let bundle = fixture.path().join("Corrupt.opentake"); + let mut project = opentake_project::Project::new(&bundle); + let mut clip = Clip::new("clip", "media", 0, 30); + clip.media_type = kind; + let mut track = Track::new("video", opentake_domain::ClipType::Video); + track.clips.push(clip); + project.timeline.tracks.push(track); + project.manifest.entries.push(MediaManifestEntry { + id: "media".into(), + name: "media".into(), + kind, + source: MediaSource::External { + absolute_path: source.to_string_lossy().into_owned(), + }, + duration: 1.0, + source_width: Some(64), + source_height: Some(64), + source_fps: (kind == opentake_domain::ClipType::Video).then_some(30.0), + generation_input: None, + has_audio: Some(false), + color: None, + proxy: None, + folder_id: None, + cached_remote_url: None, + cached_remote_url_expires_at: None, + }); + project.save().expect("save corrupt project"); + let core = AppCore::new(); + core.open_project(&bundle).expect("open corrupt project"); + let app = tauri::test::mock_app(); + app.handle() + .asset_protocol_scope() + .allow_file(&source) + .expect("authorize corrupt source"); + capture_composite_project_thumbnail( + app.handle(), + &core, + &core.runtime_snapshot(), + &crate::render::RenderState::new(), + &opentake_media::MediaCancelToken::new(), + ) + } + + #[test] + fn corrupt_planned_image_is_capture_failed() { + if !opentake_media::ffmpeg_status::ffmpeg_available() + || opentake_render::RenderDevice::try_new().is_err() + { + return; + } + assert_eq!( + capture_corrupt_external(opentake_domain::ClipType::Image), + ProjectCoverCapture::CaptureFailed + ); + } + + #[test] + fn corrupt_planned_video_is_capture_failed() { + if !opentake_media::ffmpeg_status::ffmpeg_available() + || opentake_render::RenderDevice::try_new().is_err() + { + return; + } + assert_eq!( + capture_corrupt_external(opentake_domain::ClipType::Video), + ProjectCoverCapture::CaptureFailed + ); + } + + #[test] + fn corrupt_planned_text_is_capture_failed() { + use opentake_domain::{Clip, ClipType, TextStyle, Track}; + let fixture = tempfile::tempdir().expect("text fixture"); + let bundle = fixture.path().join("Text.opentake"); + AppCore::new() + .save_project(Some(bundle.clone())) + .expect("save project"); + let core = AppCore::new(); + core.open_project(&bundle).expect("open project"); + let mut snapshot = core.runtime_snapshot(); + let mut text = Clip::new("text", "", 0, 30); + text.media_type = ClipType::Text; + text.text_content = Some("invalid".into()); + text.text_style = Some(TextStyle { + font_size: f64::NAN, + ..TextStyle::default() + }); + let mut track = Track::new("text", ClipType::Text); + track.clips.push(text); + snapshot.timeline.tracks.push(track); + let app = tauri::test::mock_app(); + + let result = capture_composite_project_thumbnail( + app.handle(), + &core, + &snapshot, + &crate::render::RenderState::new(), + &opentake_media::MediaCancelToken::new(), + ); + + assert_eq!(result, ProjectCoverCapture::CaptureFailed); + } + + #[test] + fn unapproved_external_source_is_capture_failed_without_opening_it() { + use opentake_domain::{Clip, ClipType, MediaManifestEntry, MediaSource, Track}; + let fixture = tempfile::tempdir().expect("scope fixture"); + let source = fixture.path().join("unapproved.png"); + image::RgbaImage::from_pixel(32, 32, image::Rgba([10, 20, 30, 255])) + .save(&source) + .expect("write image"); + let bundle = fixture.path().join("Scoped.opentake"); + let mut project = opentake_project::Project::new(&bundle); + let mut clip = Clip::new("clip", "media", 0, 30); + clip.media_type = ClipType::Image; + let mut track = Track::new("video", ClipType::Video); + track.clips.push(clip); + project.timeline.tracks.push(track); + project.manifest.entries.push(MediaManifestEntry { + id: "media".into(), + name: "media".into(), + kind: ClipType::Image, + source: MediaSource::External { + absolute_path: source.to_string_lossy().into_owned(), + }, + duration: 1.0, + source_width: Some(32), + source_height: Some(32), + source_fps: None, + generation_input: None, + has_audio: Some(false), + color: None, + proxy: None, + folder_id: None, + cached_remote_url: None, + cached_remote_url_expires_at: None, + }); + project.save().expect("save scope project"); + let core = AppCore::new(); + core.open_project(&bundle).expect("open scope project"); + let app = tauri::test::mock_app(); + app.handle() + .asset_protocol_scope() + .forbid_file(&source) + .expect("forbid unapproved source"); + + assert_eq!( + capture_composite_project_thumbnail( + app.handle(), + &core, + &core.runtime_snapshot(), + &crate::render::RenderState::new(), + &opentake_media::MediaCancelToken::new(), + ), + ProjectCoverCapture::CaptureFailed + ); + } + + #[test] + fn thumbnail_authoritative_composite_contains_transition_overlay_transform_and_text() { + use opentake_domain::{ + Clip, ClipType, Fill, MediaManifestEntry, MediaSource, Point, Rgba, TextStyle, Track, + Transform, Transition, TransitionKind, + }; + use opentake_media::{ + ffmpeg_status::ffmpeg_available, ExportPreset, ExportResolution, RgbaFrame, VideoCodec, + VideoEncoder, + }; + + if !ffmpeg_available() || opentake_render::RenderDevice::try_new().is_err() { + eprintln!("skip: authoritative cover fixture needs ffmpeg and a GPU adapter"); + return; + } + let fixture = tempfile::tempdir().expect("fixture tempdir"); + let video = fixture.path().join("background.mp4"); + let preset = ExportPreset::new(VideoCodec::H264, ExportResolution::P720); + let mut encoder = + VideoEncoder::new(&video, 320, 180, 30, &preset).expect("start background encoder"); + for _ in 0..60 { + encoder + .push_frame(&RgbaFrame::new( + 320, + 180, + [210, 20, 20, 255].repeat(320 * 180), + )) + .expect("encode background frame"); + } + encoder.finish().expect("finish background video"); + let incoming = fixture.path().join("incoming.png"); + let overlay = fixture.path().join("overlay.png"); + image::RgbaImage::from_pixel(320, 180, image::Rgba([20, 180, 20, 255])) + .save(&incoming) + .expect("save incoming image"); + image::RgbaImage::from_pixel(320, 180, image::Rgba([20, 40, 220, 255])) + .save(&overlay) + .expect("save overlay image"); + + let entry = |id: &str, kind: ClipType, path: &std::path::Path| MediaManifestEntry { + id: id.into(), + name: id.into(), + kind, + source: MediaSource::External { + absolute_path: path.to_string_lossy().into_owned(), + }, + duration: 2.0, + generation_input: None, + source_width: Some(320), + source_height: Some(180), + source_fps: (kind == ClipType::Video).then_some(30.0), + has_audio: Some(false), + color: None, + proxy: None, + folder_id: None, + cached_remote_url: None, + cached_remote_url_expires_at: None, + }; + let mut outgoing = Clip::new("outgoing", "background", 0, 30); + outgoing.transition_out = Some(Transition { + from_clip_id: "outgoing".into(), + to_clip_id: "incoming".into(), + kind: TransitionKind::CrossDissolve, + duration_frames: 10, + }); + let mut incoming_clip = Clip::new("incoming", "incoming", 30, 30); + incoming_clip.media_type = ClipType::Image; + let mut background_track = Track::new("background", ClipType::Video); + background_track.clips = vec![outgoing, incoming_clip]; + let mut overlay_clip = Clip::new("overlay", "overlay", 20, 12); + overlay_clip.media_type = ClipType::Image; + overlay_clip.transform = Transform::from_center(Point { x: 0.78, y: 0.5 }, 0.3, 0.6); + overlay_clip.transform.rotation = 8.0; + let mut overlay_track = Track::new("overlay", ClipType::Video); + overlay_track.clips.push(overlay_clip); + let mut text_clip = Clip::new("text", "", 20, 20); + text_clip.media_type = ClipType::Text; + text_clip.text_content = Some("Composite".into()); + let text_style = TextStyle { + background: Fill::new(true, Rgba::new(0.8, 0.1, 0.8, 1.0)), + ..TextStyle::default() + }; + text_clip.text_style = Some(text_style); + text_clip.transform = Transform::from_center(Point { x: 0.28, y: 0.5 }, 0.4, 0.2); + let mut text_track = Track::new("text", ClipType::Text); + text_track.clips.push(text_clip); + let mut timeline = opentake_domain::Timeline::new(); + timeline.width = 320; + timeline.height = 180; + // Track zero is the topmost visual track, matching the production render + // plan. Keep the transformed overlay above the transition background. + timeline.tracks = vec![overlay_track, background_track, text_track]; + let mut project = opentake_project::Project::new(fixture.path().join("Composite.opentake")); + project.timeline = timeline; + project.manifest.entries = vec![ + entry("background", ClipType::Video, &video), + entry("incoming", ClipType::Image, &incoming), + entry("overlay", ClipType::Image, &overlay), + ]; + project.save().expect("save composite fixture"); + let core = AppCore::new(); + core.open_project(&project.bundle_path) + .expect("open composite fixture"); + let app = tauri::test::mock_app(); + use tauri::Manager as _; + for path in [&video, &incoming, &overlay] { + app.handle() + .asset_protocol_scope() + .allow_file(path) + .expect("authorize composite fixture"); + } + let cancel = opentake_media::MediaCancelToken::new(); + + let capture = capture_composite_project_thumbnail( + app.handle(), + &core, + &core.runtime_snapshot(), + &crate::render::RenderState::new(), + &cancel, + ); + let ProjectCoverCapture::Captured(bytes) = capture else { + panic!("capture authoritative cover: {capture:?}"); + }; + let cover = image::load_from_memory(&bytes) + .expect("decode authoritative cover") + .to_rgb8(); + assert_eq!((cover.width(), cover.height()), (640, 360)); + let transition = cover.get_pixel(360, 40).0; + let overlay_pixel = cover.get_pixel(500, 180).0; + let outside_overlay = cover.get_pixel(620, 180).0; + let text_background = cover.get_pixel(180, 180).0; + assert!(transition[0] > 70 && transition[1] > 50, "{transition:?}"); + assert!( + overlay_pixel[2] > 120 && overlay_pixel[0] < 100, + "{overlay_pixel:?}" + ); + assert!(outside_overlay[2] < 100, "{outside_overlay:?}"); + assert!( + text_background[0] > 120 && text_background[2] > 120, + "{text_background:?}" + ); + } } #[cfg(all(test, feature = "playback-engine"))] diff --git a/src-tauri/src/external_mcp.rs b/src-tauri/src/external_mcp.rs new file mode 100644 index 00000000..7ace8c8e --- /dev/null +++ b/src-tauri/src/external_mcp.rs @@ -0,0 +1,3512 @@ +use std::{ + collections::HashMap, + fs::{self, OpenOptions}, + io::Write, + net::Ipv4Addr, + path::{Path, PathBuf}, + sync::{atomic::AtomicU64, Arc, RwLock}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +#[cfg(feature = "external-mcp-integration")] +use opentake_agent::mcp::{ + core_handle::AppCoreHandle, + dispatch::Dispatcher, + media_bridge::{BridgeError, ImportOutcome, ImportSource, MediaBridge}, +}; +use opentake_agent::mcp::{ + server::{bind_managed_gated_on, ManagedMcpEndpoint}, + AuthenticatedMcpClient, BearerAuthorizer, +}; +use opentake_core::AppCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tauri::{Emitter, Manager}; + +use crate::chat::ExternalMcpComponents; +use crate::mcp::LiveProjectMcpGate; +use crate::secret::McpSecretStore; + +const CATALOG_VERSION: u32 = 1; +const CATALOG_DIRECTORY: &str = "external-mcp"; +const CATALOG_FILE: &str = "clients.json"; +const PENDING_FILE: &str = "clients.pending.json"; +const PREFERENCES_FILE: &str = "preferences.json"; +const PREFERENCES_PENDING_FILE: &str = "preferences.pending.json"; +const EXTERNAL_MCP_ENDPOINT: &str = "http://127.0.0.1:19789/mcp"; +const EXTERNAL_MCP_STATUS_CHANGED: &str = "external_mcp_status_changed"; +const EXTERNAL_MCP_PORT: u16 = 19_789; +const MAX_CLIENT_NAME_CHARS: usize = 128; +const TOKEN_BYTES: usize = 32; +const TOKEN_DIGEST_HEX_CHARS: usize = 12; +const LAST_USED_WRITE_INTERVAL_SECS: i64 = 60; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ExternalMcpListenerState { + Disabled, + Starting, + Listening, + PortConflict, + AuthFailure, + Paused, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ExternalMcpStatus { + pub(crate) revision: u64, + pub(crate) enabled: bool, + pub(crate) state: ExternalMcpListenerState, + pub(crate) endpoint: String, + pub(crate) clients: Vec, + pub(crate) error: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub(crate) struct ExternalMcpClientSummary { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) token_digest: String, + pub(crate) created_at: i64, + pub(crate) last_used_at: Option, + pub(crate) revoked_at: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ExternalMcpPairingReceipt { + pub(crate) client: ExternalMcpClientSummary, + pub(crate) endpoint: String, + pub(crate) bearer_token: String, +} + +impl std::fmt::Debug for ExternalMcpPairingReceipt { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ExternalMcpPairingReceipt") + .field("client", &self.client) + .field("endpoint", &self.endpoint) + .field("bearer_token", &"") + .finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct PersistedCatalog { + version: u32, + clients: Vec, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct ExternalMcpPreferences { + enabled: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +enum PendingSecretState { + Present { token_digest: String }, + Absent, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PendingCatalogCommit { + client_id: String, + target: PersistedCatalog, + secret_state: PendingSecretState, +} + +#[derive(Debug)] +struct PublishError { + error: String, + published: bool, +} + +impl Default for PersistedCatalog { + fn default() -> Self { + Self { + version: CATALOG_VERSION, + clients: Vec::new(), + } + } +} + +pub(crate) struct ExternalMcpCatalog { + root: PathBuf, + clients: Vec, + secrets: Arc, + pending: bool, + last_used_published_at: HashMap, + #[cfg(test)] + fail_next_rename: std::sync::atomic::AtomicBool, + #[cfg(test)] + fail_parent_sync_on_call: std::sync::atomic::AtomicUsize, + #[cfg(test)] + publish_count: std::sync::atomic::AtomicUsize, +} + +struct ExternalMcpCatalogAuthorizer { + credentials: RwLock>>, + last_use: Arc, +} + +struct CachedCredential { + token: String, + client: AuthenticatedMcpClient, +} + +#[derive(Default)] +struct LastUseTracker { + entries: std::sync::Mutex>, + changed: tokio::sync::Notify, +} + +#[derive(Clone)] +struct LastUseEntry { + latest: i64, + dirty: bool, + last_flushed: Option, +} + +struct LastUseWorker { + shutdown: tokio::sync::oneshot::Sender<()>, + join: tokio::task::JoinHandle>, +} + +struct ExternalMcpStatusBroadcaster { + revision: AtomicU64, + sink: RwLock>, + latest: RwLock>, +} + +impl BearerAuthorizer for ExternalMcpCatalogAuthorizer { + fn authorize(&self, candidate: &str) -> Option { + let credentials = self.credentials.read().ok()?.clone(); + let client = credentials.iter().find_map(|credential| { + constant_time_eq(credential.token.as_bytes(), candidate.as_bytes()) + .then(|| credential.client.clone()) + })?; + if let Ok(now) = unix_timestamp() { + self.last_use.record(&client.client_id, now); + } + Some(client) + } +} + +impl LastUseTracker { + fn record(&self, client_id: &str, now: i64) { + let mut entries = self + .entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let entry = entries.entry(client_id.to_owned()).or_insert(LastUseEntry { + latest: now, + dirty: true, + last_flushed: None, + }); + entry.latest = entry.latest.max(now); + entry.dirty = true; + self.changed.notify_one(); + } + + fn due(&self, force: bool) -> HashMap { + let now = tokio::time::Instant::now(); + self.entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .filter(|(_, entry)| { + entry.dirty + && (force + || entry.last_flushed.is_none_or(|flushed| { + now.duration_since(flushed) + >= Duration::from_secs(LAST_USED_WRITE_INTERVAL_SECS as u64) + })) + }) + .map(|(client_id, entry)| (client_id.clone(), entry.latest)) + .collect() + } + + fn next_deadline(&self) -> Option { + let now = tokio::time::Instant::now(); + self.entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .values() + .filter(|entry| entry.dirty) + .map(|entry| { + entry.last_flushed.map_or(now, |flushed| { + flushed + Duration::from_secs(LAST_USED_WRITE_INTERVAL_SECS as u64) + }) + }) + .min() + } + + fn mark_flushed(&self, persisted: &HashMap) { + let now = tokio::time::Instant::now(); + let mut entries = self + .entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for (client_id, timestamp) in persisted { + if let Some(entry) = entries.get_mut(client_id) { + if entry.latest <= *timestamp { + entry.dirty = false; + } + entry.last_flushed = Some(now); + } + } + } +} + +impl ExternalMcpStatusBroadcaster { + fn new() -> Arc { + Arc::new(Self { + revision: AtomicU64::new(0), + sink: RwLock::new(None), + latest: RwLock::new(None), + }) + } + + fn publish(&self, mut status: ExternalMcpStatus) { + status.revision = self + .revision + .fetch_add(1, std::sync::atomic::Ordering::AcqRel) + + 1; + *self + .latest + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(status.clone()); + let sink = self + .sink + .read() + .ok() + .and_then(|sink| sink.as_ref().cloned()); + if let Some(sink) = sink { + sink(status); + } + } +} + +pub(crate) struct ExternalMcpState { + components: ExternalMcpComponents, + catalog: Arc>, + gate: Arc, + authorizer: Arc, + last_use_worker: tokio::sync::Mutex>, + lifecycle: Arc>, + status: Arc, + preference_parent_sync_on_call: std::sync::atomic::AtomicUsize, +} + +/// Narrow construction seam for the opt-in real-keychain integration test. +/// +/// This is not a Tauri command and grants no remote capability. It only lets an +/// external Rust test build the production lifecycle with an isolated keychain +/// service and a cancellation-aware media bridge, while all network admission +/// still passes through the normal bearer, Host/Origin, and live-project gates. +#[doc(hidden)] +#[cfg(feature = "external-mcp-integration")] +pub struct ExternalMcpIntegrationHarness { + state: ExternalMcpState, + core: AppCore, + cancel_probe: Arc, +} + +#[doc(hidden)] +#[cfg(feature = "external-mcp-integration")] +pub struct ExternalMcpIntegrationReceipt { + pub client_id: String, + pub bearer_token: String, +} + +#[derive(Default)] +#[cfg(feature = "external-mcp-integration")] +struct IntegrationCancelProbe { + entered: std::sync::atomic::AtomicBool, + cancelled: std::sync::atomic::AtomicBool, + entered_changed: tokio::sync::Notify, +} + +#[cfg(feature = "external-mcp-integration")] +impl IntegrationCancelProbe { + fn mark_entered(&self) { + self.entered + .store(true, std::sync::atomic::Ordering::Release); + self.entered_changed.notify_one(); + } + + async fn wait_entered(&self) { + while !self.entered.load(std::sync::atomic::Ordering::Acquire) { + self.entered_changed.notified().await; + } + } +} + +#[cfg(feature = "external-mcp-integration")] +impl MediaBridge for IntegrationCancelProbe { + fn import_media_cancellable( + &self, + _source: ImportSource, + _name: Option, + _folder_id: Option, + cancel: &opentake_media::MediaCancelToken, + ) -> Result { + self.mark_entered(); + while !cancel.is_cancelled() { + std::thread::park_timeout(Duration::from_millis(5)); + } + self.cancelled + .store(true, std::sync::atomic::Ordering::Release); + Err(BridgeError::new("integration import cancelled")) + } +} + +#[cfg(feature = "external-mcp-integration")] +impl ExternalMcpIntegrationHarness { + /// Build against a caller-owned application-data directory and unique OS + /// keychain service. The caller remains responsible for deleting only the + /// exact accounts created by returned pairing receipts. + pub fn new(app_data_dir: &Path, keychain_service: &str) -> Result { + if keychain_service.trim().is_empty() { + return Err("integration keychain service must not be empty".to_string()); + } + let core = AppCore::new(); + let registry = Arc::new(RwLock::new(crate::mcp::build_registry( + &app_data_dir.join("integration-workflows"), + ))); + let cancel_probe = Arc::new(IntegrationCancelProbe::default()); + let dispatcher = Arc::new(Dispatcher::with_bridge( + Arc::new(AppCoreHandle::new(core.clone())), + registry.clone(), + Some(cancel_probe.clone()), + )); + let components = ExternalMcpComponents { + dispatcher, + registry, + }; + let state = ExternalMcpState::load( + core.clone(), + components, + app_data_dir, + Arc::new(opentake_gen::KeyringStore::with_service(keychain_service)), + ); + Ok(Self { + state, + core, + cancel_probe, + }) + } + + pub fn core(&self) -> AppCore { + self.core.clone() + } + + pub async fn initialize(&self) { + self.state.initialize().await; + } + + pub async fn listener_state(&self) -> ExternalMcpListenerState { + self.state.status().await.state + } + + pub async fn set_enabled(&self, enabled: bool) -> Result<(), String> { + self.state.set_enabled(enabled).await.map(drop) + } + + pub async fn pair(&self, name: &str) -> Result { + self.state + .pair(name) + .await + .map(|receipt| ExternalMcpIntegrationReceipt { + client_id: receipt.client.id, + bearer_token: receipt.bearer_token, + }) + } + + pub async fn revoke(&self, client_id: &str) -> Result<(), String> { + self.state.revoke(client_id).await.map(drop) + } + + pub async fn shutdown(&self) -> Result<(), String> { + self.state.shutdown().await + } + + pub async fn wait_for_cancel_probe(&self) { + self.cancel_probe.wait_entered().await; + } + + pub fn cancel_probe_observed(&self) -> bool { + self.cancel_probe + .cancelled + .load(std::sync::atomic::Ordering::Acquire) + } +} + +type ExternalMcpStatusSink = Arc; + +struct ExternalMcpLifecycle { + admission: ExternalMcpAdmission, + enabled: bool, + state: ExternalMcpListenerState, + error: Option, + auth_failure: Option, + endpoint: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ExternalMcpAdmission { + Running, + ShuttingDown, + Stopped, +} + +impl ExternalMcpState { + pub(crate) fn new( + core: AppCore, + components: ExternalMcpComponents, + catalog: ExternalMcpCatalog, + ) -> Self { + let credentials = catalog.load_active_credentials().unwrap_or_default(); + let catalog = Arc::new(RwLock::new(catalog)); + let last_use = Arc::new(LastUseTracker::default()); + let authorizer = Arc::new(ExternalMcpCatalogAuthorizer { + credentials: RwLock::new(Arc::new(credentials)), + last_use, + }); + let gate = LiveProjectMcpGate::new(core.clone()); + Self { + components, + catalog, + gate, + authorizer, + last_use_worker: tokio::sync::Mutex::new(None), + lifecycle: Arc::new(tokio::sync::Mutex::new(ExternalMcpLifecycle { + admission: ExternalMcpAdmission::Running, + enabled: false, + state: ExternalMcpListenerState::Disabled, + error: None, + auth_failure: None, + endpoint: None, + })), + status: ExternalMcpStatusBroadcaster::new(), + preference_parent_sync_on_call: std::sync::atomic::AtomicUsize::new(0), + } + } + + pub(crate) fn load( + core: AppCore, + components: ExternalMcpComponents, + app_data_dir: &Path, + secrets: Arc, + ) -> Self { + let root = app_data_dir.join(CATALOG_DIRECTORY); + let preferences = read_preferences(&root); + let enabled = preferences + .as_ref() + .map(|value| value.enabled) + .unwrap_or(false); + let preference_error = preferences.as_ref().err().cloned(); + match ExternalMcpCatalog::load(app_data_dir, secrets.clone()) { + Ok(catalog) if preferences.is_ok() => { + let preferences = preferences.expect("checked external MCP preferences"); + let mut state = Self::new(core, components, catalog); + state.lifecycle = Arc::new(tokio::sync::Mutex::new(ExternalMcpLifecycle { + admission: ExternalMcpAdmission::Running, + enabled: preferences.enabled, + state: if preferences.enabled { + ExternalMcpListenerState::Paused + } else { + ExternalMcpListenerState::Disabled + }, + error: None, + auth_failure: None, + endpoint: None, + })); + state + } + catalog => { + let error = match (catalog.err(), preference_error) { + (Some(catalog), _) => catalog, + (None, Some(preferences)) => preferences, + (None, None) => "external MCP authentication is unavailable".to_string(), + }; + let catalog = ExternalMcpCatalog::unavailable(app_data_dir, secrets); + let mut state = Self::new(core, components, catalog); + state.lifecycle = Arc::new(tokio::sync::Mutex::new(ExternalMcpLifecycle { + admission: ExternalMcpAdmission::Running, + enabled, + state: ExternalMcpListenerState::AuthFailure, + error: Some(sanitize_auth_failure(&error)), + auth_failure: Some(error), + endpoint: None, + })); + state + } + } + } + + pub(crate) fn auth_failure( + core: AppCore, + components: ExternalMcpComponents, + error: String, + ) -> Self { + let root = std::env::temp_dir().join("opentake-unavailable-app-data"); + let catalog = + ExternalMcpCatalog::unavailable(&root, Arc::new(opentake_gen::KeyringStore::new())); + let mut state = Self::new(core, components, catalog); + state.lifecycle = Arc::new(tokio::sync::Mutex::new(ExternalMcpLifecycle { + admission: ExternalMcpAdmission::Running, + enabled: false, + state: ExternalMcpListenerState::AuthFailure, + error: Some(sanitize_auth_failure(&error)), + auth_failure: Some(error), + endpoint: None, + })); + state + } + + pub(crate) async fn initialize(&self) { + let mut lifecycle = self.lifecycle.lock().await; + if lifecycle.admission != ExternalMcpAdmission::Running { + return; + } + self.ensure_last_use_worker().await; + self.reconcile_listener(&mut lifecycle).await; + } + + pub(crate) async fn status(&self) -> ExternalMcpStatus { + let lifecycle = self.lifecycle.lock().await; + self.status_for(&lifecycle) + } + + pub(crate) async fn set_enabled(&self, enabled: bool) -> Result { + let mut lifecycle = self.lifecycle.lock().await; + self.ensure_running(&lifecycle)?; + self.ensure_last_use_worker().await; + if enabled { + self.ensure_auth_ready(&lifecycle)?; + } + match persist_preferences( + &self.catalog_root(), + ExternalMcpPreferences { enabled }, + &self.preference_parent_sync_on_call, + ) { + Ok(()) => lifecycle.enabled = enabled, + Err(error) if error.published => { + lifecycle.enabled = enabled; + self.reconcile_listener(&mut lifecycle).await; + return Err(error.error); + } + Err(error) => return Err(error.error), + } + self.reconcile_listener(&mut lifecycle).await; + Ok(self.status_for(&lifecycle)) + } + + pub(crate) async fn pair(&self, name: &str) -> Result { + let mut lifecycle = self.lifecycle.lock().await; + self.ensure_running(&lifecycle)?; + self.ensure_auth_ready(&lifecycle)?; + let receipt = match self.with_catalog_write(|catalog| catalog.pair(name)) { + Ok(receipt) => receipt, + Err(error) => { + self.handle_catalog_failure(&mut lifecycle, &error).await; + return Err(error); + } + }; + if let Err(error) = self.refresh_credentials() { + lifecycle.auth_failure = Some(error.clone()); + self.reconcile_listener(&mut lifecycle).await; + return Err(error); + } + self.reconcile_listener(&mut lifecycle).await; + Ok(receipt) + } + + pub(crate) async fn regenerate( + &self, + client_id: &str, + ) -> Result { + let mut lifecycle = self.lifecycle.lock().await; + self.ensure_running(&lifecycle)?; + self.ensure_auth_ready(&lifecycle)?; + let previous = self.active_client(client_id)?; + if let Some(endpoint) = lifecycle.endpoint.as_ref() { + endpoint + .cancel_client(&previous) + .await + .map_err(|error| error.to_string())?; + } + let receipt = match self.with_catalog_write(|catalog| catalog.regenerate(client_id)) { + Ok(receipt) => receipt, + Err(error) => { + if self.catalog.read().is_ok_and(|catalog| !catalog.pending) { + if let Some(endpoint) = lifecycle.endpoint.as_ref() { + endpoint.restore_client(&previous); + } + } + self.handle_catalog_failure(&mut lifecycle, &error).await; + return Err(error); + } + }; + self.refresh_credentials()?; + self.reconcile_listener(&mut lifecycle).await; + Ok(receipt) + } + + pub(crate) async fn revoke(&self, client_id: &str) -> Result { + let mut lifecycle = self.lifecycle.lock().await; + self.ensure_running(&lifecycle)?; + self.ensure_auth_ready(&lifecycle)?; + let previous = self.active_client(client_id)?; + if let Some(endpoint) = lifecycle.endpoint.as_ref() { + endpoint + .cancel_client(&previous) + .await + .map_err(|error| error.to_string())?; + } + if let Err(error) = self.with_catalog_write(|catalog| catalog.revoke(client_id)) { + if self.catalog.read().is_ok_and(|catalog| !catalog.pending) { + if let Some(endpoint) = lifecycle.endpoint.as_ref() { + endpoint.restore_client(&previous); + } + } + self.handle_catalog_failure(&mut lifecycle, &error).await; + return Err(error); + } + self.refresh_credentials()?; + self.reconcile_listener(&mut lifecycle).await; + Ok(self.status_for(&lifecycle)) + } + + pub(crate) async fn shutdown(&self) -> Result<(), String> { + let mut lifecycle = self.lifecycle.lock().await; + if lifecycle.admission == ExternalMcpAdmission::Stopped { + return Ok(()); + } + if lifecycle.admission == ExternalMcpAdmission::ShuttingDown { + return Err("external MCP shutdown is already in progress".to_string()); + } + lifecycle.admission = ExternalMcpAdmission::ShuttingDown; + let listener_result = self.stop_listener(&mut lifecycle).await; + self.set_terminal_listener_state(&mut lifecycle, listener_result.is_ok()); + drop(lifecycle); + let worker_result = self.stop_last_use_worker().await; + let mut lifecycle = self.lifecycle.lock().await; + lifecycle.admission = ExternalMcpAdmission::Stopped; + self.emit_status(&lifecycle); + listener_result.and(worker_result) + } + + async fn reconcile_listener(&self, lifecycle: &mut ExternalMcpLifecycle) { + if lifecycle.auth_failure.is_some() { + let _ = self.stop_listener(lifecycle).await; + lifecycle.state = ExternalMcpListenerState::AuthFailure; + lifecycle.error = lifecycle.auth_failure.as_deref().map(sanitize_auth_failure); + self.emit_status(lifecycle); + return; + } + if !lifecycle.enabled { + let stop_error = self.stop_listener(lifecycle).await.err(); + lifecycle.state = ExternalMcpListenerState::Disabled; + lifecycle.error = stop_error; + self.emit_status(lifecycle); + return; + } + if !self.has_active_clients() { + let stop_error = self.stop_listener(lifecycle).await.err(); + lifecycle.state = ExternalMcpListenerState::Paused; + lifecycle.error = stop_error; + self.emit_status(lifecycle); + return; + } + if let Err(error) = self.refresh_credentials() { + let _ = self.stop_listener(lifecycle).await; + lifecycle.auth_failure = Some(error); + lifecycle.state = ExternalMcpListenerState::AuthFailure; + lifecycle.error = Some("external MCP authentication is unavailable".to_string()); + self.emit_status(lifecycle); + return; + } + if lifecycle.endpoint.is_some() { + lifecycle.state = ExternalMcpListenerState::Listening; + lifecycle.error = None; + self.emit_status(lifecycle); + return; + } + lifecycle.state = ExternalMcpListenerState::Starting; + lifecycle.error = None; + self.emit_status(lifecycle); + let listener = + match tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, EXTERNAL_MCP_PORT)).await { + Ok(listener) => listener, + Err(error) => { + lifecycle.state = ExternalMcpListenerState::PortConflict; + lifecycle.error = Some(format!( + "external MCP port {EXTERNAL_MCP_PORT} is unavailable: {error}" + )); + self.emit_status(lifecycle); + return; + } + }; + match bind_managed_gated_on( + listener, + self.components.dispatcher.clone(), + self.components.registry.clone(), + self.gate.clone(), + self.authorizer.clone(), + ) + .await + { + Ok(endpoint) => { + lifecycle.endpoint = Some(endpoint); + lifecycle.state = ExternalMcpListenerState::Listening; + lifecycle.error = None; + } + Err(error) => { + lifecycle.state = ExternalMcpListenerState::PortConflict; + lifecycle.error = Some(error.to_string()); + } + } + self.emit_status(lifecycle); + } + + async fn handle_catalog_failure(&self, lifecycle: &mut ExternalMcpLifecycle, error: &str) { + let ready = self.catalog.read().is_ok_and(|catalog| !catalog.pending); + if !ready { + lifecycle.auth_failure = Some(error.to_string()); + } + self.reconcile_listener(lifecycle).await; + } + + async fn stop_listener(&self, lifecycle: &mut ExternalMcpLifecycle) -> Result<(), String> { + let Some(endpoint) = lifecycle.endpoint.take() else { + return Ok(()); + }; + endpoint.shutdown(); + let result = endpoint.wait().await.map_err(|error| error.to_string()); + if let Err(error) = &result { + lifecycle.state = ExternalMcpListenerState::Paused; + lifecycle.error = Some(error.clone()); + self.emit_status(lifecycle); + } + result + } + + fn status_for(&self, lifecycle: &ExternalMcpLifecycle) -> ExternalMcpStatus { + let clients = self + .catalog + .read() + .map(|catalog| catalog.clients().to_vec()) + .unwrap_or_default(); + status_from_parts(lifecycle, clients, &self.status) + } + + fn emit_status(&self, lifecycle: &ExternalMcpLifecycle) { + self.status.publish(self.status_for(lifecycle)); + } + + fn ensure_auth_ready(&self, lifecycle: &ExternalMcpLifecycle) -> Result<(), String> { + lifecycle.auth_failure.as_ref().map_or(Ok(()), |_| { + Err("external MCP authentication is unavailable".to_string()) + }) + } + + fn ensure_running(&self, lifecycle: &ExternalMcpLifecycle) -> Result<(), String> { + (lifecycle.admission == ExternalMcpAdmission::Running) + .then_some(()) + .ok_or_else(|| "external MCP lifecycle has stopped".to_string()) + } + + fn set_terminal_listener_state( + &self, + lifecycle: &mut ExternalMcpLifecycle, + listener_stopped_cleanly: bool, + ) { + if lifecycle.auth_failure.is_some() { + lifecycle.state = ExternalMcpListenerState::AuthFailure; + } else if lifecycle.enabled { + lifecycle.state = ExternalMcpListenerState::Paused; + } else { + lifecycle.state = ExternalMcpListenerState::Disabled; + } + if listener_stopped_cleanly { + lifecycle.error = None; + } + } + + fn with_catalog_write( + &self, + operation: impl FnOnce(&mut ExternalMcpCatalog) -> Result, + ) -> Result { + let mut catalog = self + .catalog + .write() + .map_err(|_| "external MCP catalog lock is unavailable".to_string())?; + operation(&mut catalog) + } + + fn catalog_root(&self) -> PathBuf { + self.catalog + .read() + .map(|catalog| catalog.root.clone()) + .unwrap_or_default() + } + + fn has_active_clients(&self) -> bool { + self.catalog.read().is_ok_and(|catalog| { + catalog + .clients() + .iter() + .any(|client| client.revoked_at.is_none()) + }) + } + + fn refresh_credentials(&self) -> Result<(), String> { + let credentials = self + .catalog + .read() + .map_err(|_| "external MCP catalog lock is unavailable".to_string())? + .load_active_credentials()?; + *self + .authorizer + .credentials + .write() + .map_err(|_| "external MCP credential snapshot lock is unavailable".to_string())? = + Arc::new(credentials); + Ok(()) + } + + fn active_client(&self, client_id: &str) -> Result { + self.authorizer + .credentials + .read() + .map_err(|_| "external MCP credential snapshot lock is unavailable".to_string())? + .iter() + .find(|credential| credential.client.client_id.as_ref() == client_id) + .map(|credential| credential.client.clone()) + .ok_or_else(|| "external MCP client is unavailable".to_string()) + } + + async fn ensure_last_use_worker(&self) { + let mut worker = self.last_use_worker.lock().await; + if worker.is_some() { + return; + } + let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel(); + let join = spawn_last_use_worker( + self.catalog.clone(), + self.authorizer.last_use.clone(), + self.lifecycle.clone(), + self.status.clone(), + shutdown_rx, + ); + *worker = Some(LastUseWorker { shutdown, join }); + } + + async fn stop_last_use_worker(&self) -> Result<(), String> { + let Some(worker) = self.last_use_worker.lock().await.take() else { + return self.flush_last_use(true).await; + }; + let _ = worker.shutdown.send(()); + worker + .join + .await + .map_err(|error| format!("join external MCP last-use worker: {error}"))??; + Ok(()) + } + + async fn flush_last_use(&self, force: bool) -> Result<(), String> { + flush_last_use_blocking( + self.catalog.clone(), + self.authorizer.last_use.clone(), + self.lifecycle.clone(), + self.status.clone(), + force, + ) + .await + } + + #[cfg(test)] + fn set_status_sink(&self, sink: ExternalMcpStatusSink) { + *self.status.sink.write().expect("external MCP status sink") = Some(sink); + } + + #[cfg(test)] + fn catalog_publish_count_for_test(&self) -> usize { + self.catalog + .read() + .expect("external MCP catalog") + .publish_count + .load(std::sync::atomic::Ordering::SeqCst) + } + + #[cfg(test)] + async fn record_last_used_at_for_test( + &self, + client_id: &str, + timestamp: i64, + ) -> Result<(), String> { + self.authorizer.last_use.record(client_id, timestamp); + self.flush_last_use(false).await + } + + #[cfg(test)] + fn fail_preference_parent_sync_after_publish_for_test(&self) { + self.preference_parent_sync_on_call + .store(2, std::sync::atomic::Ordering::SeqCst); + } +} + +fn status_from_parts( + lifecycle: &ExternalMcpLifecycle, + clients: Vec, + status: &ExternalMcpStatusBroadcaster, +) -> ExternalMcpStatus { + ExternalMcpStatus { + revision: status.revision.load(std::sync::atomic::Ordering::Acquire), + enabled: lifecycle.enabled, + state: lifecycle.state, + endpoint: EXTERNAL_MCP_ENDPOINT.to_string(), + clients, + error: lifecycle.error.clone(), + } +} + +fn spawn_last_use_worker( + catalog: Arc>, + tracker: Arc, + lifecycle: Arc>, + status: Arc, + mut shutdown: tokio::sync::oneshot::Receiver<()>, +) -> tokio::task::JoinHandle> { + tokio::spawn(async move { + loop { + let deadline = tracker.next_deadline(); + match deadline { + Some(deadline) => tokio::select! { + _ = &mut shutdown => { + return flush_last_use_blocking( + catalog, tracker, lifecycle, status, true + ).await; + } + () = tracker.changed.notified() => {} + _ = tokio::time::sleep_until(deadline) => {} + }, + None => tokio::select! { + _ = &mut shutdown => { + return flush_last_use_blocking( + catalog, tracker, lifecycle, status, true + ).await; + } + () = tracker.changed.notified() => {} + }, + } + flush_last_use_blocking( + catalog.clone(), + tracker.clone(), + lifecycle.clone(), + status.clone(), + false, + ) + .await?; + } + }) +} + +async fn flush_last_use_blocking( + catalog: Arc>, + tracker: Arc, + lifecycle: Arc>, + status: Arc, + force: bool, +) -> Result<(), String> { + let due = tracker.due(force); + if due.is_empty() { + return Ok(()); + } + let persisted = due.clone(); + let catalog_for_write = catalog.clone(); + tokio::task::spawn_blocking(move || { + let mut catalog = catalog_for_write + .write() + .map_err(|_| "external MCP catalog lock is unavailable".to_string())?; + catalog.persist_last_used(&due)?; + Ok::<_, String>(()) + }) + .await + .map_err(|error| format!("join external MCP last-use publication: {error}"))??; + tracker.mark_flushed(&persisted); + let lifecycle = lifecycle.lock().await; + let clients = catalog + .read() + .map_err(|_| "external MCP catalog lock is unavailable".to_string())? + .clients() + .to_vec(); + status.publish(status_from_parts(&lifecycle, clients, &status)); + Ok(()) +} + +impl ExternalMcpCatalog { + fn unavailable(app_data_dir: &Path, secrets: Arc) -> Self { + Self { + root: app_data_dir.join(CATALOG_DIRECTORY), + clients: Vec::new(), + secrets, + pending: true, + last_used_published_at: HashMap::new(), + #[cfg(test)] + fail_next_rename: std::sync::atomic::AtomicBool::new(false), + #[cfg(test)] + fail_parent_sync_on_call: std::sync::atomic::AtomicUsize::new(0), + #[cfg(test)] + publish_count: std::sync::atomic::AtomicUsize::new(0), + } + } + + pub(crate) fn load( + app_data_dir: &Path, + secrets: Arc, + ) -> Result { + let root = app_data_dir.join(CATALOG_DIRECTORY); + let path = root.join(CATALOG_FILE); + let clients = read_catalog(&path)?; + let mut catalog = Self { + root, + last_used_published_at: clients + .iter() + .filter_map(|client| { + client + .last_used_at + .map(|timestamp| (client.id.clone(), timestamp)) + }) + .collect(), + clients, + secrets, + pending: false, + #[cfg(test)] + fail_next_rename: std::sync::atomic::AtomicBool::new(false), + #[cfg(test)] + fail_parent_sync_on_call: std::sync::atomic::AtomicUsize::new(0), + #[cfg(test)] + publish_count: std::sync::atomic::AtomicUsize::new(0), + }; + catalog.recover_pending_commit()?; + Ok(catalog) + } + + pub(crate) fn clients(&self) -> &[ExternalMcpClientSummary] { + &self.clients + } + + pub(crate) fn metadata_path(&self) -> PathBuf { + self.root.join(CATALOG_FILE) + } + + fn pending_path(&self) -> PathBuf { + self.root.join(PENDING_FILE) + } + + fn recover_pending_commit(&mut self) -> Result<(), String> { + let path = self.pending_path(); + let pending = match fs::read(&path) { + Ok(bytes) => serde_json::from_slice::(&bytes) + .map_err(|error| format!("read external MCP pending commit: {error}"))?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(format!("read external MCP pending commit: {error}")), + }; + validate_pending(&pending)?; + let secret = self + .secrets + .load_mcp_secret(&secret_account(&pending.client_id))?; + if pending_matches_secret(&pending.secret_state, secret.as_deref()) { + self.publish_clients(&pending.target.clients) + .map_err(|error| error.error)?; + self.clients = pending.target.clients; + } else if self.clients == pending.target.clients { + return Err("external MCP pending commit has inconsistent secret state".to_string()); + } + self.clear_pending()?; + Ok(()) + } + + pub(crate) fn pair(&mut self, name: &str) -> Result { + self.ensure_ready()?; + let name = validate_name(name)?; + let token = generate_token()?; + let client = ExternalMcpClientSummary { + id: uuid::Uuid::new_v4().to_string(), + name, + token_digest: token_digest(&token), + created_at: unix_timestamp()?, + last_used_at: None, + revoked_at: None, + }; + let account = secret_account(&client.id); + let mut next = self.clients.clone(); + next.push(client.clone()); + if let Err(error) = self.prepare_pending( + &client.id, + &next, + PendingSecretState::Present { + token_digest: client.token_digest.clone(), + }, + ) { + if self.pending { + return Err(error); + } + self.clear_pending()?; + return Err(error); + } + self.pending = true; + self.secrets.save_mcp_secret(&account, &token)?; + match self.publish_clients(&next) { + Ok(()) => { + self.clients = next; + self.clear_pending()?; + } + Err(error) if error.published => { + self.clients = next; + self.pending = true; + return Err(error.error); + } + Err(error) => { + if let Err(rollback_error) = self.secrets.delete_mcp_secret(&account) { + return Err(format!( + "{}; external MCP credential cleanup failed: {rollback_error}", + error.error + )); + } + self.clear_pending()?; + return Err(error.error); + } + } + Ok(receipt(client, token)) + } + + pub(crate) fn regenerate( + &mut self, + client_id: &str, + ) -> Result { + self.ensure_ready()?; + let index = self.client_index(client_id)?; + if self.clients[index].revoked_at.is_some() { + return Err("external MCP client is revoked".to_string()); + } + let account = secret_account(client_id); + let previous_token = self + .secrets + .load_mcp_secret(&account)? + .ok_or_else(|| "external MCP client credential is unavailable".to_string())?; + let token = generate_token()?; + let mut next = self.clients.clone(); + next[index].token_digest = token_digest(&token); + if let Err(error) = self.prepare_pending( + client_id, + &next, + PendingSecretState::Present { + token_digest: next[index].token_digest.clone(), + }, + ) { + if self.pending { + return Err(error); + } + self.clear_pending()?; + return Err(error); + } + self.pending = true; + self.secrets.save_mcp_secret(&account, &token)?; + match self.publish_clients(&next) { + Ok(()) => { + self.clients = next; + self.clear_pending()?; + } + Err(error) if error.published => { + self.clients = next; + self.pending = true; + return Err(error.error); + } + Err(error) => { + if let Err(rollback_error) = self.secrets.save_mcp_secret(&account, &previous_token) + { + return Err(format!( + "{}; external MCP credential rollback failed: {rollback_error}", + error.error + )); + } + self.clear_pending()?; + return Err(error.error); + } + } + Ok(receipt(self.clients[index].clone(), token)) + } + + pub(crate) fn revoke(&mut self, client_id: &str) -> Result<(), String> { + self.ensure_ready()?; + let index = self.client_index(client_id)?; + if self.clients[index].revoked_at.is_some() { + return Ok(()); + } + let account = secret_account(client_id); + let previous_token = self.secrets.load_mcp_secret(&account)?; + let revoked_at = unix_timestamp()?; + let mut next = self.clients.clone(); + next[index].revoked_at = Some(revoked_at); + if let Err(error) = self.prepare_pending(client_id, &next, PendingSecretState::Absent) { + if self.pending { + return Err(error); + } + self.clear_pending()?; + return Err(error); + } + self.pending = true; + self.secrets.delete_mcp_secret(&account)?; + match self.publish_clients(&next) { + Ok(()) => { + self.clients = next; + self.clear_pending()?; + } + Err(error) if error.published => { + self.clients = next; + self.pending = true; + return Err(error.error); + } + Err(error) => { + if let Some(token) = previous_token { + if let Err(rollback_error) = self.secrets.save_mcp_secret(&account, &token) { + return Err(format!( + "{}; external MCP credential rollback failed: {rollback_error}", + error.error + )); + } + } + self.clear_pending()?; + return Err(error.error); + } + } + Ok(()) + } + + #[cfg(test)] + pub(crate) fn verify_candidate(&self, candidate: &str) -> Result, String> { + self.ensure_ready()?; + let mut match_id = None; + for client in self + .clients + .iter() + .filter(|client| client.revoked_at.is_none()) + { + let secret = self + .secrets + .load_mcp_secret(&secret_account(&client.id))? + .ok_or_else(|| "external MCP client credential is unavailable".to_string())?; + if token_digest(&secret) != client.token_digest { + return Err("external MCP client credential is invalid".to_string()); + } + if constant_time_eq(secret.as_bytes(), candidate.as_bytes()) { + match_id = Some(client.id.clone()); + } + } + Ok(match_id) + } + + fn load_active_credentials(&self) -> Result, String> { + self.ensure_ready()?; + self.clients + .iter() + .filter(|client| client.revoked_at.is_none()) + .map(|client| { + let token = self + .secrets + .load_mcp_secret(&secret_account(&client.id))? + .ok_or_else(|| "external MCP client credential is unavailable".to_string())?; + if token_digest(&token) != client.token_digest { + return Err("external MCP client credential is invalid".to_string()); + } + Ok(CachedCredential { + client: AuthenticatedMcpClient { + client_id: client.id.clone().into(), + credential_generation: credential_generation(&token), + }, + token, + }) + }) + .collect() + } + + fn persist_last_used(&mut self, updates: &HashMap) -> Result<(), String> { + self.ensure_ready()?; + let previous = self.clients.clone(); + for (client_id, timestamp) in updates { + let index = self.client_index(client_id)?; + self.clients[index].last_used_at = Some( + self.clients[index] + .last_used_at + .unwrap_or(*timestamp) + .max(*timestamp), + ); + } + let next = self.clients.clone(); + if let Err(error) = self.publish_clients(&next) { + self.clients = previous; + return Err(error.error); + } + for (client_id, timestamp) in updates { + self.last_used_published_at + .insert(client_id.clone(), *timestamp); + } + Ok(()) + } + + fn client_index(&self, client_id: &str) -> Result { + self.clients + .iter() + .position(|client| client.id == client_id) + .ok_or_else(|| "external MCP client not found".to_string()) + } + + fn ensure_ready(&self) -> Result<(), String> { + if self.pending { + Err("external MCP catalog recovery is pending".to_string()) + } else { + Ok(()) + } + } + + fn prepare_pending( + &mut self, + client_id: &str, + target_clients: &[ExternalMcpClientSummary], + secret_state: PendingSecretState, + ) -> Result<(), String> { + match self.write_json_atomically( + &self.pending_path(), + &PendingCatalogCommit { + client_id: client_id.to_string(), + target: PersistedCatalog { + version: CATALOG_VERSION, + clients: target_clients.to_vec(), + }, + secret_state, + }, + ) { + Ok(()) => Ok(()), + Err(error) => { + self.pending = error.published; + Err(error.error) + } + } + } + + fn clear_pending(&mut self) -> Result<(), String> { + fs::remove_file(self.pending_path()) + .or_else(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + Ok(()) + } else { + Err(error) + } + }) + .map_err(|error| format!("clear external MCP pending commit: {error}"))?; + if let Err(error) = self.sync_parent_directory() { + self.pending = true; + return Err(format!("sync external MCP catalog directory: {error}")); + } + self.pending = false; + Ok(()) + } + + fn publish_clients(&self, clients: &[ExternalMcpClientSummary]) -> Result<(), PublishError> { + let result = self.write_json_atomically( + &self.metadata_path(), + &PersistedCatalog { + version: CATALOG_VERSION, + clients: clients.to_vec(), + }, + ); + #[cfg(test)] + if result.is_ok() { + self.publish_count + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + result + } + + fn write_json_atomically( + &self, + destination: &Path, + value: &T, + ) -> Result<(), PublishError> { + fs::create_dir_all(&self.root).map_err(|error| PublishError { + error: format!("create external MCP catalog directory: {error}"), + published: false, + })?; + let bytes = serde_json::to_vec_pretty(value).map_err(|error| PublishError { + error: format!("encode external MCP catalog: {error}"), + published: false, + })?; + let temp = self + .root + .join(format!(".clients.{}.tmp", uuid::Uuid::new_v4())); + let result: Result<(), PublishError> = (|| { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp) + .map_err(|error| PublishError { + error: format!("create external MCP catalog staging file: {error}"), + published: false, + })?; + file.write_all(&bytes) + .and_then(|()| file.sync_all()) + .map_err(|error| PublishError { + error: format!("write external MCP catalog staging file: {error}"), + published: false, + })?; + self.rename_atomically(&temp, destination) + .map_err(|error| PublishError { + error, + published: false, + })?; + self.sync_parent_directory().map_err(|error| PublishError { + error: format!("sync external MCP catalog directory: {error}"), + published: true, + })?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(temp); + } + result + } + + #[cfg(test)] + fn fail_next_atomic_rename_for_test(&self) { + self.fail_next_rename + .store(true, std::sync::atomic::Ordering::SeqCst); + } + + #[cfg(test)] + fn fail_parent_sync_during_publish_for_test(&self) { + // One sync seals the pending journal; the second seals clients.json. + self.fail_parent_sync_on_call + .store(2, std::sync::atomic::Ordering::SeqCst); + } + + fn rename_atomically(&self, temp: &Path, destination: &Path) -> Result<(), String> { + #[cfg(test)] + if self + .fail_next_rename + .swap(false, std::sync::atomic::Ordering::SeqCst) + { + return Err("publish external MCP catalog: injected rename failure".to_string()); + } + replace_file_atomically(temp, destination) + .map_err(|error| format!("publish external MCP catalog: {error}")) + } + + fn sync_parent_directory(&self) -> std::io::Result<()> { + #[cfg(test)] + { + let remaining = self + .fail_parent_sync_on_call + .load(std::sync::atomic::Ordering::SeqCst); + if remaining != 0 { + self.fail_parent_sync_on_call + .store(remaining - 1, std::sync::atomic::Ordering::SeqCst); + if remaining == 1 { + return Err(std::io::Error::other("injected parent sync failure")); + } + } + } + sync_parent_directory(&self.root) + } +} + +#[cfg(not(windows))] +fn replace_file_atomically(staging: &Path, destination: &Path) -> std::io::Result<()> { + fs::rename(staging, destination) +} + +#[cfg(windows)] +fn replace_file_atomically(staging: &Path, destination: &Path) -> std::io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + + let staging = staging + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let destination = destination + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + // SAFETY: both buffers are owned, NUL-terminated UTF-16 paths and remain + // alive for the duration of this synchronous Win32 call. + let replaced = unsafe { + MoveFileExW( + staging.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if replaced == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } +} + +#[cfg(unix)] +fn sync_parent_directory(parent: &Path) -> std::io::Result<()> { + fs::File::open(parent)?.sync_all() +} + +#[cfg(not(unix))] +fn sync_parent_directory(_parent: &Path) -> std::io::Result<()> { + // Windows does not support opening a directory as a synchronizable File. + // `rename` remains atomic; the OS owns the corresponding directory flush. + Ok(()) +} + +fn read_catalog(path: &Path) -> Result, String> { + match fs::read(path) { + Ok(bytes) => { + let persisted: PersistedCatalog = serde_json::from_slice(&bytes) + .map_err(|error| format!("read external MCP catalog: {error}"))?; + if persisted.version != CATALOG_VERSION { + return Err("unsupported external MCP catalog version".to_string()); + } + for client in &persisted.clients { + validate_client(client)?; + } + Ok(persisted.clients) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), + Err(error) => Err(format!("read external MCP catalog: {error}")), + } +} + +fn read_preferences(root: &Path) -> Result { + let pending = root.join(PREFERENCES_PENDING_FILE); + match fs::read(&pending) { + Ok(bytes) => { + let target: ExternalMcpPreferences = serde_json::from_slice(&bytes) + .map_err(|error| format!("read external MCP pending preferences: {error}"))?; + let current = read_preferences_file(root)?; + if current != target { + persist_preferences(root, target, &std::sync::atomic::AtomicUsize::new(0)) + .map_err(|error| error.error)?; + } + fs::remove_file(&pending) + .map_err(|error| format!("clear external MCP pending preferences: {error}"))?; + sync_parent_directory(root) + .map_err(|error| format!("sync external MCP preferences directory: {error}"))?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("read external MCP pending preferences: {error}")), + } + read_preferences_file(root) +} + +fn read_preferences_file(root: &Path) -> Result { + match fs::read(root.join(PREFERENCES_FILE)) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map_err(|error| format!("read external MCP preferences: {error}")), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(ExternalMcpPreferences::default()) + } + Err(error) => Err(format!("read external MCP preferences: {error}")), + } +} + +fn persist_preferences( + root: &Path, + preferences: ExternalMcpPreferences, + fail_parent_sync_on_call: &std::sync::atomic::AtomicUsize, +) -> Result<(), PublishError> { + fs::create_dir_all(root).map_err(|error| PublishError { + error: format!("create external MCP preferences directory: {error}"), + published: false, + })?; + let bytes = serde_json::to_vec_pretty(&preferences).map_err(|error| PublishError { + error: format!("encode external MCP preferences: {error}"), + published: false, + })?; + let pending = root.join(PREFERENCES_PENDING_FILE); + write_preference_file(root, &pending, &bytes, fail_parent_sync_on_call, false)?; + let staging = root.join(format!(".preferences.{}.tmp", uuid::Uuid::new_v4())); + let result: Result<(), PublishError> = (|| { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&staging) + .map_err(|error| PublishError { + error: format!("create external MCP preferences staging file: {error}"), + published: false, + })?; + file.write_all(&bytes) + .and_then(|()| file.sync_all()) + .map_err(|error| PublishError { + error: format!("write external MCP preferences staging file: {error}"), + published: false, + })?; + replace_file_atomically(&staging, &root.join(PREFERENCES_FILE)).map_err(|error| { + PublishError { + error: format!("publish external MCP preferences: {error}"), + published: false, + } + })?; + sync_preference_parent(root, fail_parent_sync_on_call).map_err(|error| PublishError { + error: format!("sync external MCP preferences directory: {error}"), + published: true, + })?; + fs::remove_file(&pending).map_err(|error| PublishError { + error: format!("clear external MCP pending preferences: {error}"), + published: true, + })?; + sync_preference_parent(root, fail_parent_sync_on_call).map_err(|error| PublishError { + error: format!("sync external MCP preferences directory: {error}"), + published: true, + }) + })(); + if result.is_err() { + let _ = fs::remove_file(staging); + } + result +} + +fn write_preference_file( + root: &Path, + destination: &Path, + bytes: &[u8], + fail_parent_sync_on_call: &std::sync::atomic::AtomicUsize, + published: bool, +) -> Result<(), PublishError> { + let staging = root.join(format!(".preferences.{}.tmp", uuid::Uuid::new_v4())); + let result = (|| { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&staging) + .map_err(|error| PublishError { + error: format!("create external MCP preferences staging file: {error}"), + published, + })?; + file.write_all(bytes) + .and_then(|()| file.sync_all()) + .map_err(|error| PublishError { + error: format!("write external MCP preferences staging file: {error}"), + published, + })?; + replace_file_atomically(&staging, destination).map_err(|error| PublishError { + error: format!("publish external MCP preferences: {error}"), + published, + })?; + sync_preference_parent(root, fail_parent_sync_on_call).map_err(|error| PublishError { + error: format!("sync external MCP preferences directory: {error}"), + published: true, + }) + })(); + if result.is_err() { + let _ = fs::remove_file(staging); + } + result +} + +fn sync_preference_parent( + root: &Path, + _fail_parent_sync_on_call: &std::sync::atomic::AtomicUsize, +) -> std::io::Result<()> { + #[cfg(test)] + if _fail_parent_sync_on_call + .fetch_update( + std::sync::atomic::Ordering::SeqCst, + std::sync::atomic::Ordering::SeqCst, + |remaining| remaining.checked_sub(1), + ) + .is_ok_and(|remaining| remaining == 1) + { + return Err(std::io::Error::other( + "injected preference parent sync failure", + )); + } + sync_parent_directory(root) +} + +fn sanitize_auth_failure(_error: &str) -> String { + "external MCP authentication is unavailable".to_string() +} + +fn validate_pending(pending: &PendingCatalogCommit) -> Result<(), String> { + if pending.target.version != CATALOG_VERSION { + return Err("unsupported external MCP pending catalog version".to_string()); + } + let client = pending + .target + .clients + .iter() + .find(|client| client.id == pending.client_id) + .ok_or_else(|| "external MCP pending commit has no target client".to_string())?; + for candidate in &pending.target.clients { + validate_client(candidate)?; + } + match &pending.secret_state { + PendingSecretState::Present { token_digest } if token_digest == &client.token_digest => { + Ok(()) + } + PendingSecretState::Absent if client.revoked_at.is_some() => Ok(()), + _ => Err("external MCP pending commit has inconsistent target state".to_string()), + } +} + +fn pending_matches_secret(state: &PendingSecretState, secret: Option<&str>) -> bool { + match (state, secret) { + (PendingSecretState::Absent, None) => true, + ( + PendingSecretState::Present { + token_digest: digest, + }, + Some(secret), + ) => digest == &token_digest(secret), + _ => false, + } +} + +fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + let mut difference = left.len() ^ right.len(); + let width = left.len().max(right.len()); + for index in 0..width { + let a = left.get(index).copied().unwrap_or(0); + let b = right.get(index).copied().unwrap_or(0); + difference |= usize::from(a ^ b); + } + difference == 0 +} + +fn receipt(client: ExternalMcpClientSummary, bearer_token: String) -> ExternalMcpPairingReceipt { + ExternalMcpPairingReceipt { + client, + endpoint: EXTERNAL_MCP_ENDPOINT.to_string(), + bearer_token, + } +} + +fn validate_name(name: &str) -> Result { + let name = name.trim(); + if name.is_empty() + || name.chars().count() > MAX_CLIENT_NAME_CHARS + || name.chars().any(char::is_control) + { + return Err( + "external MCP client name must contain 1 to 128 non-control characters".to_string(), + ); + } + Ok(name.to_string()) +} + +fn validate_client(client: &ExternalMcpClientSummary) -> Result<(), String> { + uuid::Uuid::parse_str(&client.id) + .map_err(|_| "external MCP catalog has an invalid client id".to_string())?; + validate_name(&client.name)?; + if client.token_digest.len() != TOKEN_DIGEST_HEX_CHARS + || !client + .token_digest + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + || client.created_at < 0 + || client.last_used_at.is_some_and(|timestamp| timestamp < 0) + || client.revoked_at.is_some_and(|timestamp| timestamp < 0) + { + return Err("external MCP catalog has invalid client metadata".to_string()); + } + Ok(()) +} + +fn generate_token() -> Result { + let mut bytes = [0_u8; TOKEN_BYTES]; + getrandom::fill(&mut bytes) + .map_err(|error| format!("generate external MCP credential: {error}"))?; + Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect()) +} + +fn token_digest(token: &str) -> String { + let digest = Sha256::digest(token.as_bytes()); + digest + .iter() + .take(TOKEN_DIGEST_HEX_CHARS / 2) + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn credential_generation(token: &str) -> u64 { + let digest = Sha256::digest(token.as_bytes()); + u64::from_be_bytes( + digest[..std::mem::size_of::()] + .try_into() + .expect("SHA-256 contains a u64 credential generation"), + ) +} + +fn secret_account(client_id: &str) -> String { + format!("external-mcp:{client_id}") +} + +fn unix_timestamp() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("read external MCP clock: {error}")) + .and_then(|duration| { + i64::try_from(duration.as_secs()) + .map_err(|_| "external MCP clock is out of range".to_string()) + }) +} + +pub(crate) fn install_status_emitter( + app: &tauri::AppHandle, + state: &ExternalMcpState, +) { + let handle = app.clone(); + *state + .status + .sink + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::new(move |status| { + if let Err(error) = handle.emit(EXTERNAL_MCP_STATUS_CHANGED, status) { + eprintln!("[mcp] could not emit external MCP status: {error}"); + } + })); +} + +#[tauri::command] +pub(crate) async fn external_mcp_status( + state: tauri::State<'_, ExternalMcpState>, +) -> Result { + Ok(state.status().await) +} + +#[tauri::command] +pub(crate) async fn external_mcp_set_enabled( + enabled: bool, + state: tauri::State<'_, ExternalMcpState>, + admission: tauri::State<'_, crate::updater::InstallAdmissionGate>, +) -> Result { + let _activity = crate::updater::begin_mutating_activity(&admission)?; + state.set_enabled(enabled).await +} + +#[tauri::command] +pub(crate) async fn external_mcp_pair( + name: String, + state: tauri::State<'_, ExternalMcpState>, + admission: tauri::State<'_, crate::updater::InstallAdmissionGate>, +) -> Result { + let _activity = crate::updater::begin_mutating_activity(&admission)?; + state.pair(&name).await +} + +#[tauri::command] +pub(crate) async fn external_mcp_regenerate( + client_id: String, + state: tauri::State<'_, ExternalMcpState>, + admission: tauri::State<'_, crate::updater::InstallAdmissionGate>, +) -> Result { + let _activity = crate::updater::begin_mutating_activity(&admission)?; + state.regenerate(&client_id).await +} + +#[tauri::command] +pub(crate) async fn external_mcp_revoke( + client_id: String, + state: tauri::State<'_, ExternalMcpState>, + admission: tauri::State<'_, crate::updater::InstallAdmissionGate>, +) -> Result { + let _activity = crate::updater::begin_mutating_activity(&admission)?; + state.revoke(&client_id).await +} + +pub(crate) fn shutdown_on_exit(app: &tauri::AppHandle) { + let Some(state) = app.try_state::() else { + return; + }; + let result = tauri::async_runtime::block_on(state.shutdown()); + if let Err(error) = result { + eprintln!("[mcp] external endpoint did not drain cleanly on application exit: {error}"); + } +} + +#[cfg(test)] +mod tests { + use std::sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, Mutex, + }; + + use super::*; + use crate::chat::ChatState; + use crate::secret::{McpSecretStore, MemoryMcpSecretStore}; + use opentake_agent::chat::ChatTurnGate; + + static LIFECYCLE_PORT: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + + #[cfg(feature = "external-mcp-integration")] + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn integration_cancel_probe_never_loses_a_concurrent_entry_signal() { + for _ in 0..2_048 { + let probe = Arc::new(IntegrationCancelProbe::default()); + let start = Arc::new(tokio::sync::Barrier::new(3)); + let waiter_probe = probe.clone(); + let waiter_start = start.clone(); + let waiter = tokio::spawn(async move { + waiter_start.wait().await; + waiter_probe.wait_entered().await; + }); + let signal_probe = probe.clone(); + let signal_start = start.clone(); + let signal = tokio::spawn(async move { + signal_start.wait().await; + signal_probe.mark_entered(); + }); + start.wait().await; + tokio::time::timeout(Duration::from_secs(1), waiter) + .await + .expect("concurrent integration probe signal was not retained") + .expect("join integration probe waiter"); + signal.await.expect("join integration probe signaler"); + } + } + + struct TwoClientBlockingGate { + entered: tokio::sync::mpsc::UnboundedSender, + release_survivor: AtomicBool, + survivor_cancelled: AtomicBool, + } + + impl TwoClientBlockingGate { + fn new(entered: tokio::sync::mpsc::UnboundedSender) -> Self { + Self { + entered, + release_survivor: AtomicBool::new(false), + survivor_cancelled: AtomicBool::new(false), + } + } + + fn release_survivor(&self) { + self.release_survivor.store(true, Ordering::SeqCst); + } + } + + impl ChatTurnGate for TwoClientBlockingGate { + fn timeline( + &self, + dispatcher: &opentake_agent::mcp::dispatch::Dispatcher, + ) -> Option { + Some(dispatcher.timeline()) + } + + fn dispatch( + &self, + _dispatcher: &opentake_agent::mcp::dispatch::Dispatcher, + _name: &str, + _args: serde_json::Value, + ) -> Option { + panic!("managed blocking test must use request-local cancellation") + } + + fn dispatch_cancellable( + &self, + _dispatcher: &opentake_agent::mcp::dispatch::Dispatcher, + name: &str, + _args: serde_json::Value, + request_cancel: &opentake_media::MediaCancelToken, + ) -> Option { + let _ = self.entered.send(name.to_owned()); + if name == "get_media" { + while !self.release_survivor.load(Ordering::SeqCst) + && !request_cancel.is_cancelled() + { + std::thread::yield_now(); + } + self.survivor_cancelled + .store(request_cancel.is_cancelled(), Ordering::SeqCst); + } else { + while !request_cancel.is_cancelled() { + std::thread::yield_now(); + } + } + Some(opentake_agent::tools::result::ToolResult::ok("released")) + } + } + + #[derive(Default)] + struct InstrumentedSecretStore { + values: Mutex>, + loads: AtomicUsize, + fail_loads: AtomicBool, + } + + impl InstrumentedSecretStore { + fn reset_loads(&self) { + self.loads.store(0, Ordering::SeqCst); + } + + fn load_count(&self) -> usize { + self.loads.load(Ordering::SeqCst) + } + + fn fail_loads(&self) { + self.fail_loads.store(true, Ordering::SeqCst); + } + } + + impl McpSecretStore for InstrumentedSecretStore { + fn save_mcp_secret(&self, account: &str, value: &str) -> Result<(), String> { + self.values + .lock() + .expect("instrumented secret values") + .insert(account.to_owned(), value.to_owned()); + Ok(()) + } + + fn load_mcp_secret(&self, account: &str) -> Result, String> { + self.loads.fetch_add(1, Ordering::SeqCst); + if self.fail_loads.load(Ordering::SeqCst) { + return Err("injected secret-store load failure".to_string()); + } + Ok(self + .values + .lock() + .expect("instrumented secret values") + .get(account) + .cloned()) + } + + fn delete_mcp_secret(&self, account: &str) -> Result<(), String> { + self.values + .lock() + .expect("instrumented secret values") + .remove(account); + Ok(()) + } + } + + fn catalog_root() -> tempfile::TempDir { + tempfile::tempdir().expect("create temporary application data directory") + } + + fn load_catalog( + root: &tempfile::TempDir, + secrets: Arc, + ) -> ExternalMcpCatalog { + ExternalMcpCatalog::load(root.path(), secrets) + .expect("load catalog against the in-memory secret store") + } + + fn shared_state( + root: &tempfile::TempDir, + core: opentake_core::AppCore, + ) -> (ChatState, ExternalMcpState) { + let chat = ChatState::new( + core.clone(), + root.path().join("no-workflows"), + root.path().join("chat-cache"), + root.path().join("chat-models"), + ); + let catalog = load_catalog(root, Arc::new(MemoryMcpSecretStore::default())); + let external = ExternalMcpState::new(core, chat.external_mcp_components(), catalog); + (chat, external) + } + + fn lifecycle_state( + root: &tempfile::TempDir, + secrets: Arc, + ) -> ExternalMcpState { + let core = opentake_core::AppCore::new(); + let chat = ChatState::new( + core.clone(), + root.path().join("no-workflows"), + root.path().join("chat-cache"), + root.path().join("chat-models"), + ); + ExternalMcpState::load(core, chat.external_mcp_components(), root.path(), secrets) + } + + fn lifecycle_state_with_gate( + root: &tempfile::TempDir, + secrets: Arc, + gate: Arc, + ) -> ExternalMcpState { + let mut state = lifecycle_state(root, secrets); + state.gate = gate; + state + } + + async fn wait_for_publish_count(state: &ExternalMcpState, expected: usize) { + for _ in 0..100 { + if state.catalog_publish_count_for_test() >= expected { + return; + } + tokio::task::yield_now().await; + } + assert_eq!(state.catalog_publish_count_for_test(), expected); + } + + async fn assert_fixed_port_available() { + let listener = + tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, EXTERNAL_MCP_PORT)) + .await + .expect("fixed external MCP port is available"); + drop(listener); + } + + #[tokio::test] + async fn lifecycle_disabled_startup_never_binds() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let state = lifecycle_state(&root, Arc::new(MemoryMcpSecretStore::default())); + + state.initialize().await; + + let status = state.status().await; + assert!(!status.enabled); + assert_eq!(status.state, ExternalMcpListenerState::Disabled); + assert!(status.clients.is_empty()); + assert_fixed_port_available().await; + } + + #[tokio::test] + async fn lifecycle_disable_stops_an_active_listener_and_persists() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let state = lifecycle_state(&root, secrets.clone()); + state.set_enabled(true).await.expect("enable endpoint"); + state.pair("Cursor").await.expect("pair client"); + + let status = state.set_enabled(false).await.expect("disable endpoint"); + + assert_eq!(status.state, ExternalMcpListenerState::Disabled); + assert_fixed_port_available().await; + let restarted = lifecycle_state(&root, secrets); + restarted.initialize().await; + assert_eq!( + restarted.status().await.state, + ExternalMcpListenerState::Disabled + ); + } + + #[tokio::test] + async fn lifecycle_catalog_recovery_failure_is_a_fail_closed_status() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let metadata = root.path().join(CATALOG_DIRECTORY).join(CATALOG_FILE); + std::fs::create_dir_all(metadata.parent().expect("catalog parent")) + .expect("create corrupt catalog directory"); + std::fs::write(&metadata, b"not-json").expect("write corrupt catalog"); + + let state = lifecycle_state(&root, Arc::new(MemoryMcpSecretStore::default())); + state.initialize().await; + + let status = state.status().await; + assert_eq!(status.state, ExternalMcpListenerState::AuthFailure); + assert_eq!( + status.error.as_deref(), + Some("external MCP authentication is unavailable") + ); + assert!(status.clients.is_empty()); + assert_fixed_port_available().await; + assert!(state.pair("must fail closed").await.is_err()); + } + + #[tokio::test] + async fn lifecycle_durability_failure_transitions_to_auth_failure() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let state = lifecycle_state(&root, Arc::new(MemoryMcpSecretStore::default())); + state.set_enabled(true).await.expect("enable endpoint"); + let first = state.pair("Cursor").await.expect("pair first client"); + state + .catalog + .read() + .expect("external MCP catalog") + .fail_parent_sync_during_publish_for_test(); + + let error = state + .regenerate(&first.client.id) + .await + .expect_err("injected durability failure is reported"); + + assert!(error.contains("sync external MCP catalog directory")); + let status = state.status().await; + assert_eq!(status.state, ExternalMcpListenerState::AuthFailure); + assert_eq!( + status.error.as_deref(), + Some("external MCP authentication is unavailable") + ); + assert_fixed_port_available().await; + } + + #[tokio::test] + async fn lifecycle_enabled_restart_recovers_the_fixed_listener() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let first = lifecycle_state(&root, secrets.clone()); + first.set_enabled(true).await.expect("persist enablement"); + first.pair("Claude Desktop").await.expect("pair client"); + assert_eq!( + first.status().await.state, + ExternalMcpListenerState::Listening + ); + first.shutdown().await.expect("drain first listener"); + + let restarted = lifecycle_state(&root, secrets); + restarted.initialize().await; + + let status = restarted.status().await; + assert!(status.enabled); + assert_eq!(status.state, ExternalMcpListenerState::Listening); + restarted + .shutdown() + .await + .expect("drain restarted listener"); + } + + #[tokio::test] + async fn lifecycle_missing_active_credential_fails_closed_before_bind() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let first = lifecycle_state(&root, secrets.clone()); + first.set_enabled(true).await.expect("enable endpoint"); + let paired = first.pair("Cursor").await.expect("pair client"); + first.shutdown().await.expect("stop first endpoint"); + secrets + .delete_mcp_secret(&secret_account(&paired.client.id)) + .expect("remove active credential"); + + let restarted = lifecycle_state(&root, secrets); + restarted.initialize().await; + + let status = restarted.status().await; + assert!(status.enabled); + assert_eq!(status.state, ExternalMcpListenerState::AuthFailure); + assert_fixed_port_available().await; + } + + #[tokio::test] + async fn lifecycle_secret_store_failure_drains_a_previously_listening_endpoint() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let secrets = Arc::new(InstrumentedSecretStore::default()); + let state = lifecycle_state(&root, secrets.clone()); + state.set_enabled(true).await.expect("enable endpoint"); + state.pair("Cursor").await.expect("pair client"); + assert_eq!( + state.status().await.state, + ExternalMcpListenerState::Listening + ); + secrets.fail_loads(); + + state.initialize().await; + + assert_eq!( + state.status().await.state, + ExternalMcpListenerState::AuthFailure + ); + assert_fixed_port_available().await; + } + + #[tokio::test] + async fn lifecycle_enabled_without_clients_stays_paused_and_unbound() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let state = lifecycle_state(&root, Arc::new(MemoryMcpSecretStore::default())); + + let status = state.set_enabled(true).await.expect("enable endpoint"); + + assert_eq!(status.state, ExternalMcpListenerState::Paused); + assert_fixed_port_available().await; + } + + #[tokio::test] + async fn lifecycle_port_conflict_is_reported_without_a_fallback_listener() { + let _port = LIFECYCLE_PORT.lock().await; + let occupied = + tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, EXTERNAL_MCP_PORT)) + .await + .expect("occupy fixed port"); + let root = catalog_root(); + let state = lifecycle_state(&root, Arc::new(MemoryMcpSecretStore::default())); + state.set_enabled(true).await.expect("enable endpoint"); + + state + .pair("Claude Desktop") + .await + .expect("pair despite bind conflict"); + + let status = state.status().await; + assert_eq!(status.state, ExternalMcpListenerState::PortConflict); + assert_eq!(status.endpoint, EXTERNAL_MCP_ENDPOINT); + drop(occupied); + state.shutdown().await.expect("shutdown conflicted state"); + } + + #[tokio::test] + async fn lifecycle_emits_starting_before_listening_without_a_token() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let state = lifecycle_state(&root, Arc::new(MemoryMcpSecretStore::default())); + let events = Arc::new(Mutex::new(Vec::::new())); + let captured = events.clone(); + state.set_status_sink(Arc::new(move |status| { + captured.lock().expect("record status").push(status); + })); + state.set_enabled(true).await.expect("enable endpoint"); + + let receipt = state.pair("Claude Desktop").await.expect("pair and start"); + + { + let events = events.lock().expect("read statuses"); + let transitions = events.iter().map(|status| status.state).collect::>(); + assert!(transitions.windows(2).any(|states| { + states + == [ + ExternalMcpListenerState::Starting, + ExternalMcpListenerState::Listening, + ] + })); + let serialized = serde_json::to_string(&*events).expect("serialize status events"); + assert!(!serialized.contains(&receipt.bearer_token)); + assert!(events.windows(2).all(|statuses| { + statuses[1].revision == statuses[0].revision.saturating_add(1) + })); + } + state.shutdown().await.expect("drain listener"); + } + + #[test] + fn lifecycle_pairing_receipt_is_the_only_dto_that_serializes_a_token() { + let root = catalog_root(); + let core = opentake_core::AppCore::new(); + let chat = ChatState::new( + core.clone(), + root.path().join("no-workflows"), + root.path().join("chat-cache"), + root.path().join("chat-models"), + ); + let mut catalog = load_catalog(&root, Arc::new(MemoryMcpSecretStore::default())); + let receipt = catalog.pair("Cursor").expect("pair client"); + let state = ExternalMcpState::new(core, chat.external_mcp_components(), catalog); + let lifecycle = state.lifecycle.blocking_lock(); + + let receipt_json = serde_json::to_string(&receipt).expect("serialize receipt"); + let status_json = serde_json::to_string(&state.status_for(&lifecycle)) + .expect("serialize sanitized status"); + + assert!(receipt_json.contains(&receipt.bearer_token)); + assert!(!status_json.contains(&receipt.bearer_token)); + assert!(!status_json.contains("bearerToken")); + } + + #[tokio::test] + async fn lifecycle_pair_while_enabled_starts_the_listener() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let state = lifecycle_state(&root, Arc::new(MemoryMcpSecretStore::default())); + state.set_enabled(true).await.expect("enable endpoint"); + + let receipt = state.pair("Cursor").await.expect("pair client"); + + assert_eq!(receipt.endpoint, EXTERNAL_MCP_ENDPOINT); + assert_eq!( + state.status().await.state, + ExternalMcpListenerState::Listening + ); + state.shutdown().await.expect("drain listener"); + } + + #[tokio::test] + async fn lifecycle_revoking_the_final_client_stops_the_listener() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let state = lifecycle_state(&root, Arc::new(MemoryMcpSecretStore::default())); + state.set_enabled(true).await.expect("enable endpoint"); + let receipt = state.pair("Cursor").await.expect("pair client"); + + let status = state + .revoke(&receipt.client.id) + .await + .expect("revoke client"); + + assert_eq!(status.state, ExternalMcpListenerState::Paused); + assert_fixed_port_available().await; + } + + #[tokio::test] + async fn lifecycle_revoke_cancels_the_revoked_rmcp_session() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let state = lifecycle_state(&root, Arc::new(MemoryMcpSecretStore::default())); + state.set_enabled(true).await.expect("enable endpoint"); + let first = state.pair("Cursor").await.expect("pair first client"); + let survivor = state.pair("Claude").await.expect("pair survivor"); + let client = reqwest::Client::new(); + let revoked_session = initialize_managed_session( + &client, + EXTERNAL_MCP_ENDPOINT, + &first.bearer_token, + "revoked-session", + ) + .await; + let survivor_session = initialize_managed_session( + &client, + EXTERNAL_MCP_ENDPOINT, + &survivor.bearer_token, + "survivor-session", + ) + .await; + state.revoke(&first.client.id).await.expect("revoke client"); + + let stale = client + .post(EXTERNAL_MCP_ENDPOINT) + .bearer_auth(&survivor.bearer_token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", revoked_session) + .header("mcp-protocol-version", "2025-06-18") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + })) + .send() + .await + .expect("send request with revoked rmcp session"); + assert!(!stale.status().is_success()); + assert_managed_session_usable( + &client, + EXTERNAL_MCP_ENDPOINT, + &survivor.bearer_token, + &survivor_session, + 3, + ) + .await; + state.shutdown().await.expect("drain listener"); + } + + #[tokio::test] + async fn lifecycle_regeneration_cancels_the_old_rmcp_session() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let state = lifecycle_state(&root, Arc::new(MemoryMcpSecretStore::default())); + state.set_enabled(true).await.expect("enable endpoint"); + let first = state.pair("Cursor").await.expect("pair client"); + let survivor = state.pair("Claude").await.expect("pair survivor"); + let client = reqwest::Client::new(); + let old_session = initialize_managed_session( + &client, + EXTERNAL_MCP_ENDPOINT, + &first.bearer_token, + "old-session", + ) + .await; + let survivor_session = initialize_managed_session( + &client, + EXTERNAL_MCP_ENDPOINT, + &survivor.bearer_token, + "survivor-session", + ) + .await; + + let regenerated = state + .regenerate(&first.client.id) + .await + .expect("regenerate credential"); + + let stale = client + .post(EXTERNAL_MCP_ENDPOINT) + .bearer_auth(®enerated.bearer_token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", old_session) + .header("mcp-protocol-version", "2025-06-18") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + })) + .send() + .await + .expect("send request with stale rmcp session"); + assert!(!stale.status().is_success()); + let _new_session = initialize_managed_session( + &client, + EXTERNAL_MCP_ENDPOINT, + ®enerated.bearer_token, + "new-session", + ) + .await; + assert_managed_session_usable( + &client, + EXTERNAL_MCP_ENDPOINT, + &survivor.bearer_token, + &survivor_session, + 3, + ) + .await; + state.shutdown().await.expect("drain regenerated listener"); + } + + async fn exercise_two_active_request_mutation(regenerate: bool) { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let (entered_tx, mut entered_rx) = tokio::sync::mpsc::unbounded_channel(); + let gate = Arc::new(TwoClientBlockingGate::new(entered_tx)); + let state = lifecycle_state_with_gate( + &root, + Arc::new(MemoryMcpSecretStore::default()), + gate.clone(), + ); + state.set_enabled(true).await.expect("enable endpoint"); + let target = state.pair("target").await.expect("pair target"); + let survivor = state.pair("survivor").await.expect("pair survivor"); + let client = reqwest::Client::new(); + let target_session = initialize_managed_session( + &client, + EXTERNAL_MCP_ENDPOINT, + &target.bearer_token, + "target-session", + ) + .await; + let survivor_session = initialize_managed_session( + &client, + EXTERNAL_MCP_ENDPOINT, + &survivor.bearer_token, + "survivor-session", + ) + .await; + + let spawn_call = + |token: String, session: reqwest::header::HeaderValue, name: &'static str, id: u64| { + let client = client.clone(); + tokio::spawn(async move { + let response = client + .post(EXTERNAL_MCP_ENDPOINT) + .bearer_auth(token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", session) + .header("mcp-protocol-version", "2025-06-18") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": { "name": name, "arguments": {} } + })) + .send() + .await + .expect("send blocking tool request"); + let status = response.status(); + let body = response.text().await.expect("consume blocking SSE body"); + (status, body) + }) + }; + let target_call = spawn_call( + target.bearer_token.clone(), + target_session, + "get_timeline", + 2, + ); + let mut survivor_call = spawn_call( + survivor.bearer_token.clone(), + survivor_session.clone(), + "get_media", + 3, + ); + let mut entered = vec![ + tokio::time::timeout(Duration::from_secs(2), entered_rx.recv()) + .await + .expect("first request entry timed out") + .expect("first request entered"), + tokio::time::timeout(Duration::from_secs(2), entered_rx.recv()) + .await + .expect("second request entry timed out") + .expect("second request entered"), + ]; + entered.sort(); + assert_eq!(entered, ["get_media", "get_timeline"]); + + if regenerate { + tokio::time::timeout(Duration::from_secs(2), state.regenerate(&target.client.id)) + .await + .expect("regenerate must not wait for survivor") + .expect("regenerate target"); + } else { + tokio::time::timeout(Duration::from_secs(2), state.revoke(&target.client.id)) + .await + .expect("revoke must not wait for survivor") + .expect("revoke target"); + } + let (target_status, _) = tokio::time::timeout(Duration::from_secs(2), target_call) + .await + .expect("target request must terminate") + .expect("join target request"); + assert!(target_status.is_success()); + assert!( + !survivor_call.is_finished(), + "survivor request was terminated with target" + ); + assert!(!gate.survivor_cancelled.load(Ordering::SeqCst)); + + gate.release_survivor(); + let (survivor_status, _) = tokio::time::timeout(Duration::from_secs(2), &mut survivor_call) + .await + .expect("survivor request must finish after release") + .expect("join survivor request"); + assert!(survivor_status.is_success()); + assert_managed_session_usable( + &client, + EXTERNAL_MCP_ENDPOINT, + &survivor.bearer_token, + &survivor_session, + 4, + ) + .await; + state.shutdown().await.expect("stop endpoint"); + } + + #[tokio::test] + async fn lifecycle_regenerate_cancels_target_active_request_and_preserves_survivor_session() { + tokio::time::timeout( + Duration::from_secs(10), + exercise_two_active_request_mutation(true), + ) + .await + .expect("regenerate transport regression timed out"); + } + + #[tokio::test] + async fn lifecycle_revoke_cancels_target_active_request_and_preserves_survivor_session() { + tokio::time::timeout( + Duration::from_secs(10), + exercise_two_active_request_mutation(false), + ) + .await + .expect("revoke transport regression timed out"); + } + + #[tokio::test] + async fn lifecycle_application_shutdown_drains_and_releases_the_port() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let state = lifecycle_state(&root, Arc::new(MemoryMcpSecretStore::default())); + state.set_enabled(true).await.expect("enable endpoint"); + state.pair("Cursor").await.expect("pair client"); + + state.shutdown().await.expect("application exit drain"); + + assert_fixed_port_available().await; + } + + #[tokio::test] + async fn lifecycle_last_used_updates_are_persisted_once_per_coalescing_window() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let state = lifecycle_state(&root, secrets.clone()); + state.set_enabled(true).await.expect("enable endpoint"); + let receipt = state.pair("Cursor").await.expect("pair client"); + let writes_before = state.catalog_publish_count_for_test(); + let client = reqwest::Client::new(); + + for name in ["first", "second", "third"] { + let _session = initialize_managed_session( + &client, + EXTERNAL_MCP_ENDPOINT, + &receipt.bearer_token, + name, + ) + .await; + } + + assert!(state.status().await.clients[0].last_used_at.is_some()); + assert_eq!(state.catalog_publish_count_for_test(), writes_before + 1); + let reloaded = load_catalog(&root, secrets); + let persisted = reloaded.clients()[0] + .last_used_at + .expect("last-used timestamp persisted"); + let in_memory = state.status().await.clients[0] + .last_used_at + .expect("last-used timestamp visible"); + assert!(in_memory >= persisted); + state.shutdown().await.expect("drain listener"); + } + + #[tokio::test] + async fn lifecycle_shutdown_flushes_the_latest_coalesced_last_used_value() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let state = lifecycle_state(&root, secrets.clone()); + let paired = state.pair("Cursor").await.expect("pair client"); + let t0 = 1_800_000_000; + state + .record_last_used_at_for_test(&paired.client.id, t0) + .await + .expect("record leading last-used value"); + state + .record_last_used_at_for_test(&paired.client.id, t0 + 30) + .await + .expect("record trailing last-used value"); + + state.shutdown().await.expect("flush and stop lifecycle"); + + let reloaded = load_catalog(&root, secrets); + assert_eq!(reloaded.clients()[0].last_used_at, Some(t0 + 30)); + } + + #[tokio::test(start_paused = true)] + async fn lifecycle_dirty_last_used_flushes_at_its_exact_deadline() { + let root = catalog_root(); + let state = lifecycle_state(&root, Arc::new(MemoryMcpSecretStore::default())); + let paired = state.pair("Cursor").await.expect("pair client"); + tokio::time::advance(Duration::from_secs(20)).await; + let before = state.catalog_publish_count_for_test(); + + state + .authorizer + .last_use + .record(&paired.client.id, 1_800_000_000); + state + .flush_last_use(false) + .await + .expect("flush leading value"); + state + .authorizer + .last_use + .record(&paired.client.id, 1_800_000_030); + state.initialize().await; + + tokio::time::advance(Duration::from_secs(59)).await; + tokio::task::yield_now().await; + assert_eq!(state.catalog_publish_count_for_test(), before + 1); + tokio::time::advance(Duration::from_secs(1)).await; + wait_for_publish_count(&state, before + 2).await; + + state.shutdown().await.expect("stop last-use worker"); + } + + #[tokio::test] + async fn lifecycle_shutdown_is_terminal_for_initialize_enable_and_pair_races() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let state = Arc::new(lifecycle_state( + &root, + Arc::new(MemoryMcpSecretStore::default()), + )); + state.set_enabled(true).await.expect("enable endpoint"); + let paired = state.pair("Cursor").await.expect("pair first client"); + let (held_tx, held_rx) = std::sync::mpsc::sync_channel(1); + let (release_tx, release_rx) = std::sync::mpsc::sync_channel(1); + let catalog = state.catalog.clone(); + let catalog_barrier = std::thread::spawn(move || { + let _catalog = catalog.write().expect("hold catalog publication"); + held_tx.send(()).expect("signal held catalog"); + release_rx.recv().expect("release catalog publication"); + }); + held_rx.recv().expect("catalog publication is held"); + state + .authorizer + .last_use + .record(&paired.client.id, 1_800_000_000); + let shutting_down = { + let state = state.clone(); + tokio::spawn(async move { state.shutdown().await }) + }; + loop { + let lifecycle = state.lifecycle.lock().await; + let admission = lifecycle.admission; + if admission == ExternalMcpAdmission::ShuttingDown { + assert_ne!( + lifecycle.state, + ExternalMcpListenerState::Listening, + "shutdown retained listening after the endpoint was drained" + ); + break; + } + drop(lifecycle); + tokio::task::yield_now().await; + } + let initialize = state.initialize(); + let enable = state.set_enabled(true); + let pair = state.pair("late client"); + let ((), enable, pair) = tokio::join!(initialize, enable, pair); + + assert!( + enable.is_err(), + "enable was admitted after terminal shutdown" + ); + assert!(pair.is_err(), "pair was admitted after terminal shutdown"); + release_tx.send(()).expect("release held catalog"); + catalog_barrier.join().expect("join catalog barrier"); + shutting_down + .await + .expect("join shutdown") + .expect("shutdown endpoint"); + assert_eq!( + state.lifecycle.lock().await.admission, + ExternalMcpAdmission::Stopped + ); + assert_fixed_port_available().await; + } + + #[tokio::test] + async fn lifecycle_last_use_status_publish_merges_current_lifecycle_under_barrier() { + let root = catalog_root(); + let state = Arc::new(lifecycle_state( + &root, + Arc::new(MemoryMcpSecretStore::default()), + )); + let paired = state.pair("Cursor").await.expect("pair client"); + state.initialize().await; + let before = state.catalog_publish_count_for_test(); + let mut lifecycle = state.lifecycle.lock().await; + lifecycle.enabled = true; + lifecycle.state = ExternalMcpListenerState::Paused; + state + .authorizer + .last_use + .record(&paired.client.id, 1_800_000_000); + let flushing = { + let state = state.clone(); + tokio::spawn(async move { state.flush_last_use(true).await }) + }; + wait_for_publish_count(&state, before + 1).await; + assert!( + !flushing.is_finished(), + "last-use status publication bypassed the lifecycle serialization barrier" + ); + drop(lifecycle); + flushing + .await + .expect("join last-use flush") + .expect("flush last-use status"); + + let latest = state + .status + .latest + .read() + .expect("latest status") + .clone() + .expect("published status"); + assert!(latest.enabled); + assert_eq!(latest.state, ExternalMcpListenerState::Paused); + state.shutdown().await.expect("stop lifecycle"); + } + + #[test] + fn lifecycle_authorizer_uses_only_the_validated_in_memory_snapshot() { + let root = catalog_root(); + let secrets = Arc::new(InstrumentedSecretStore::default()); + let mut catalog = load_catalog(&root, secrets.clone()); + let paired = catalog.pair("Cursor").expect("pair client"); + let core = opentake_core::AppCore::new(); + let chat = ChatState::new( + core.clone(), + root.path().join("no-workflows"), + root.path().join("chat-cache"), + root.path().join("chat-models"), + ); + let state = ExternalMcpState::new(core, chat.external_mcp_components(), catalog); + secrets.reset_loads(); + let writes_before = state.catalog_publish_count_for_test(); + + for _ in 0..3 { + assert!(state.authorizer.authorize(&paired.bearer_token).is_some()); + } + + assert_eq!(secrets.load_count(), 0, "authorization read the keychain"); + assert_eq!( + state.catalog_publish_count_for_test(), + writes_before, + "authorization synchronously published the catalog" + ); + } + + #[tokio::test] + async fn lifecycle_auth_failure_preserves_enabled_and_allows_persisted_disable() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let catalog_dir = root.path().join(CATALOG_DIRECTORY); + std::fs::create_dir_all(&catalog_dir).expect("create external MCP directory"); + persist_preferences( + &catalog_dir, + ExternalMcpPreferences { enabled: true }, + &std::sync::atomic::AtomicUsize::new(0), + ) + .expect("persist enabled preference"); + std::fs::write(catalog_dir.join(CATALOG_FILE), b"not-json").expect("write corrupt catalog"); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + + let failed = lifecycle_state(&root, secrets.clone()); + failed.initialize().await; + assert!(failed.status().await.enabled); + assert_eq!( + failed.status().await.state, + ExternalMcpListenerState::AuthFailure + ); + assert!(failed.set_enabled(true).await.is_err()); + assert!(failed.pair("blocked").await.is_err()); + + let disabled = failed + .set_enabled(false) + .await + .expect("disable remains available while authentication is failed"); + assert!(!disabled.enabled); + assert_eq!(disabled.state, ExternalMcpListenerState::AuthFailure); + + let restarted = lifecycle_state(&root, secrets); + assert!(!restarted.status().await.enabled); + assert_eq!( + restarted.status().await.state, + ExternalMcpListenerState::AuthFailure + ); + } + + #[tokio::test] + async fn lifecycle_preference_post_rename_sync_failure_converges_now_and_on_restart() { + let _port = LIFECYCLE_PORT.lock().await; + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let state = lifecycle_state(&root, secrets.clone()); + state.fail_preference_parent_sync_after_publish_for_test(); + + let result = state.set_enabled(true).await; + assert!(result.is_err(), "injected durability failure is reported"); + assert!( + state.status().await.enabled, + "runtime follows published file" + ); + + let restarted = lifecycle_state(&root, secrets); + assert!( + restarted.status().await.enabled, + "restart recovers published target" + ); + } + + async fn initialize_managed_session( + client: &reqwest::Client, + url: &str, + token: &str, + name: &str, + ) -> reqwest::header::HeaderValue { + let response = client + .post(url) + .bearer_auth(token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": { "name": name, "version": "0" } + } + })) + .send() + .await + .expect("initialize managed MCP session"); + assert!(response.status().is_success()); + response + .headers() + .get("mcp-session-id") + .expect("managed rmcp session id") + .clone() + } + + async fn assert_managed_session_usable( + client: &reqwest::Client, + url: &str, + token: &str, + session: &reqwest::header::HeaderValue, + id: u64, + ) { + let response = client + .post(url) + .bearer_auth(token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", session.clone()) + .header("mcp-protocol-version", "2025-06-18") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/list", + "params": {} + })) + .send() + .await + .expect("send request through surviving rmcp session"); + assert!( + response.status().is_success(), + "surviving session was cancelled" + ); + } + + async fn call_managed_tool( + client: &reqwest::Client, + url: &str, + token: &str, + session: &reqwest::header::HeaderValue, + id: u64, + name: &str, + arguments: serde_json::Value, + ) -> String { + let response = client + .post(url) + .bearer_auth(token) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", session.clone()) + .header("mcp-protocol-version", "2025-06-18") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": { "name": name, "arguments": arguments } + })) + .send() + .await + .expect("call managed MCP tool"); + assert!(response.status().is_success()); + response.text().await.expect("read managed MCP result") + } + + #[test] + fn shared_state_reuses_chat_dispatcher_and_registry_arcs() { + let root = catalog_root(); + let core = opentake_core::AppCore::new(); + let chat = ChatState::new( + core.clone(), + root.path().join("no-workflows"), + root.path().join("chat-cache"), + root.path().join("chat-models"), + ); + let expected = chat.external_mcp_components(); + let catalog = load_catalog(&root, Arc::new(MemoryMcpSecretStore::default())); + + let external = ExternalMcpState::new(core, chat.external_mcp_components(), catalog); + + assert!(Arc::ptr_eq( + &external.components.dispatcher, + &expected.dispatcher + )); + assert!(Arc::ptr_eq( + &external.components.registry, + &expected.registry + )); + } + + #[test] + fn shared_live_gate_refuses_mutation_without_a_saved_project() { + let root = catalog_root(); + let core = opentake_core::AppCore::new(); + let (_chat, external) = shared_state(&root, core.clone()); + + let refused = external.gate.dispatch_cancellable_scoped( + &external.components.dispatcher, + "create_folder", + serde_json::json!({ "name": "must-not-exist" }), + "opentake:mcp:unsaved", + &opentake_media::MediaCancelToken::new(), + ); + + assert!(refused.is_none()); + assert!(core.media().folders.is_empty()); + } + + #[tokio::test] + async fn shared_catalog_authorizer_tracks_regenerated_credentials_without_exporting_them() { + let root = catalog_root(); + let core = opentake_core::AppCore::new(); + let chat = ChatState::new( + core.clone(), + root.path().join("no-workflows"), + root.path().join("chat-cache"), + root.path().join("chat-models"), + ); + let mut catalog = load_catalog(&root, Arc::new(MemoryMcpSecretStore::default())); + let first = catalog.pair("Claude Desktop").expect("pair client"); + let external = ExternalMcpState::new(core, chat.external_mcp_components(), catalog); + + let first_client = external + .authorizer + .authorize(&first.bearer_token) + .expect("authorize original credential"); + assert_eq!(first_client.client_id.as_ref(), first.client.id); + assert!(external.authorizer.authorize("wrong credential").is_none()); + + let regenerated = external + .regenerate(&first.client.id) + .await + .expect("regenerate credential"); + assert!(external.authorizer.authorize(&first.bearer_token).is_none()); + let regenerated_client = external + .authorizer + .authorize(®enerated.bearer_token) + .expect("authorize regenerated credential"); + assert_eq!(regenerated_client.client_id, first_client.client_id); + assert_ne!( + regenerated_client.credential_generation, + first_client.credential_generation + ); + } + + #[tokio::test] + async fn shared_transport_scopes_isolate_two_rmcp_sessions_and_in_app_chat_undo() { + let root = catalog_root(); + let core = opentake_core::AppCore::new(); + core.save_project(Some(root.path().join("Shared.opentake"))) + .expect("save shared project"); + let chat = ChatState::new( + core.clone(), + root.path().join("no-workflows"), + root.path().join("chat-cache"), + root.path().join("chat-models"), + ); + let chat_gate = chat.project_turn_gate_for_test("chat-session"); + let mut catalog = load_catalog(&root, Arc::new(MemoryMcpSecretStore::default())); + let paired = catalog.pair("Claude Desktop").expect("pair test client"); + let external = ExternalMcpState::new(core.clone(), chat.external_mcp_components(), catalog); + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind managed MCP test listener"); + let endpoint = opentake_agent::mcp::server::bind_managed_gated_on( + listener, + external.components.dispatcher.clone(), + external.components.registry.clone(), + external.gate.clone(), + external.authorizer.clone(), + ) + .await + .expect("start managed MCP endpoint"); + let client = reqwest::Client::new(); + let url = format!("http://{}/mcp", endpoint.addr()); + let session_a = + initialize_managed_session(&client, &url, &paired.bearer_token, "rmcp-session-a").await; + let session_b = + initialize_managed_session(&client, &url, &paired.bearer_token, "rmcp-session-b").await; + + let created = call_managed_tool( + &client, + &url, + &paired.bearer_token, + &session_a, + 2, + "create_folder", + serde_json::json!({ "name": "MCP A" }), + ) + .await; + assert!(!created.contains("\"isError\":true"), "{created}"); + let foreign = call_managed_tool( + &client, + &url, + &paired.bearer_token, + &session_b, + 3, + "undo", + serde_json::json!({}), + ) + .await; + assert!(foreign.contains("\"isError\":true"), "{foreign}"); + let chat_undo = chat_gate + .dispatch( + &external.components.dispatcher, + "undo", + serde_json::json!({}), + ) + .expect("current chat gate remains live"); + assert!(chat_undo.is_error, "chat consumed MCP A's edit"); + let owner = call_managed_tool( + &client, + &url, + &paired.bearer_token, + &session_a, + 4, + "undo", + serde_json::json!({}), + ) + .await; + assert!(!owner.contains("\"isError\":true"), "{owner}"); + assert!(core.media().folders.is_empty()); + + let created = call_managed_tool( + &client, + &url, + &paired.bearer_token, + &session_b, + 5, + "create_folder", + serde_json::json!({ "name": "MCP B" }), + ) + .await; + assert!(!created.contains("\"isError\":true"), "{created}"); + let foreign = call_managed_tool( + &client, + &url, + &paired.bearer_token, + &session_a, + 6, + "undo", + serde_json::json!({}), + ) + .await; + assert!(foreign.contains("\"isError\":true"), "{foreign}"); + let chat_undo = chat_gate + .dispatch( + &external.components.dispatcher, + "undo", + serde_json::json!({}), + ) + .expect("current chat gate remains live"); + assert!(chat_undo.is_error, "chat consumed MCP B's edit"); + let owner = call_managed_tool( + &client, + &url, + &paired.bearer_token, + &session_b, + 7, + "undo", + serde_json::json!({}), + ) + .await; + assert!(!owner.contains("\"isError\":true"), "{owner}"); + assert!(core.media().folders.is_empty()); + + let created = chat_gate + .dispatch( + &external.components.dispatcher, + "create_folder", + serde_json::json!({ "name": "Chat" }), + ) + .expect("current chat gate accepts mutation"); + assert!(!created.is_error, "{}", created.text_joined()); + for (session, id) in [(&session_a, 8), (&session_b, 9)] { + let foreign = call_managed_tool( + &client, + &url, + &paired.bearer_token, + session, + id, + "undo", + serde_json::json!({}), + ) + .await; + assert!(foreign.contains("\"isError\":true"), "{foreign}"); + } + let chat_undo = chat_gate + .dispatch( + &external.components.dispatcher, + "undo", + serde_json::json!({}), + ) + .expect("current chat gate accepts undo"); + assert!(!chat_undo.is_error, "{}", chat_undo.text_joined()); + assert!(core.media().folders.is_empty()); + + endpoint.shutdown(); + endpoint.wait().await.expect("stop managed MCP endpoint"); + } + + #[test] + fn catalog_pair_creates_unique_client_ids_and_32_byte_tokens() { + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let mut catalog = load_catalog(&root, secrets); + let first = catalog.pair("Claude Desktop").expect("pair first client"); + let second = catalog.pair("Cursor").expect("pair second client"); + assert_ne!(first.client.id, second.client.id); + assert_ne!(first.client.token_digest, second.client.token_digest); + for token in [&first.bearer_token, &second.bearer_token] { + assert_eq!(token.len(), 64, "token is 32 bytes encoded as hexadecimal"); + assert!(token.bytes().all(|byte| byte.is_ascii_hexdigit())); + } + assert_eq!(first.endpoint, EXTERNAL_MCP_ENDPOINT); + assert!(!format!("{first:?}").contains(&first.bearer_token)); + } + + #[test] + fn catalog_persisted_json_omits_the_bearer_token() { + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let mut catalog = load_catalog(&root, secrets); + let receipt = catalog.pair("Claude Desktop").expect("pair client"); + let serialized = std::fs::read_to_string(catalog.metadata_path()) + .expect("read persisted client metadata"); + assert!(!serialized.contains(&receipt.bearer_token)); + assert!(!serialized.contains("bearer_token")); + assert!(serialized.contains(&receipt.client.token_digest)); + } + + #[test] + fn atomic_replace_overwrites_existing_catalog_preferences_and_journal_targets() { + let root = catalog_root(); + for name in [CATALOG_FILE, PREFERENCES_FILE, PREFERENCES_PENDING_FILE] { + let destination = root.path().join(name); + let staging = root.path().join(format!("{name}.staging")); + std::fs::write(&destination, b"old").expect("write existing target"); + std::fs::write(&staging, b"new").expect("write replacement"); + + replace_file_atomically(&staging, &destination).expect("replace existing target"); + + assert_eq!(std::fs::read(&destination).expect("read target"), b"new"); + assert!(!staging.exists()); + } + } + + #[test] + fn atomic_replace_failure_keeps_existing_target_recoverable() { + let root = catalog_root(); + let destination = root.path().join(CATALOG_FILE); + let missing_staging = root.path().join("missing.staging"); + std::fs::write(&destination, b"old").expect("write existing target"); + + assert!(replace_file_atomically(&missing_staging, &destination).is_err()); + + assert_eq!( + std::fs::read(destination).expect("read retained target"), + b"old" + ); + } + + #[test] + fn catalog_restart_reloads_metadata_and_retrieves_secret_from_fake_store() { + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let receipt = { + let mut catalog = load_catalog(&root, secrets.clone()); + catalog.pair("Claude Desktop").expect("pair client") + }; + let catalog = load_catalog(&root, secrets); + assert_eq!(catalog.clients(), std::slice::from_ref(&receipt.client)); + assert_eq!( + catalog + .verify_candidate(&receipt.bearer_token) + .expect("verify stored secret"), + Some(receipt.client.id) + ); + } + + #[test] + fn catalog_regeneration_invalidates_the_previous_token() { + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let mut catalog = load_catalog(&root, secrets); + let first = catalog.pair("Claude Desktop").expect("pair client"); + let regenerated = catalog + .regenerate(&first.client.id) + .expect("regenerate credential"); + assert_eq!(regenerated.client.id, first.client.id); + assert_ne!(regenerated.client.token_digest, first.client.token_digest); + assert_eq!( + catalog + .verify_candidate(&first.bearer_token) + .expect("reject prior credential"), + None + ); + assert_eq!( + catalog + .verify_candidate(®enerated.bearer_token) + .expect("verify regenerated credential"), + Some(first.client.id) + ); + } + + #[test] + fn catalog_revoke_removes_the_secret() { + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let mut catalog = load_catalog(&root, secrets.clone()); + let receipt = catalog.pair("Claude Desktop").expect("pair client"); + catalog.revoke(&receipt.client.id).expect("revoke client"); + assert_eq!( + catalog + .verify_candidate(&receipt.bearer_token) + .expect("reject revoked credential"), + None + ); + assert_eq!( + secrets + .load_mcp_secret(&secret_account(&receipt.client.id)) + .expect("read in-memory secret"), + None + ); + assert!(catalog.clients()[0].revoked_at.is_some()); + } + + #[test] + fn catalog_duplicate_display_names_remain_distinguishable() { + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let mut catalog = load_catalog(&root, secrets); + let first = catalog.pair("Claude Desktop").expect("pair first client"); + let second = catalog.pair("Claude Desktop").expect("pair second client"); + assert_eq!(first.client.name, second.client.name); + assert_ne!(first.client.id, second.client.id); + assert_eq!(catalog.clients().len(), 2); + } + + #[test] + fn catalog_failed_atomic_rename_leaves_the_previous_catalog_readable() { + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let mut catalog = load_catalog(&root, secrets.clone()); + let first = catalog.pair("Claude Desktop").expect("pair first client"); + catalog.fail_next_atomic_rename_for_test(); + assert!(catalog.pair("Cursor").is_err()); + let reloaded = load_catalog(&root, secrets.clone()); + assert_eq!(reloaded.clients(), &[first.client]); + } + + #[test] + fn catalog_recovers_after_the_post_rename_parent_sync_fails() { + let root = catalog_root(); + let secrets = Arc::new(MemoryMcpSecretStore::default()); + let mut catalog = load_catalog(&root, secrets.clone()); + + catalog.fail_parent_sync_during_publish_for_test(); + let receipt = match catalog.pair("Claude Desktop") { + Ok(_) => panic!("report sync failure"), + Err(error) => error, + }; + + let reloaded = load_catalog(&root, secrets.clone()); + assert_eq!(reloaded.clients().len(), 1); + assert!(receipt.contains("sync external MCP catalog directory")); + assert!(reloaded + .verify_candidate("not-the-stored-token") + .expect("catalog reconciles before authorization") + .is_none()); + let stored = secrets + .load_mcp_secret(&secret_account(&reloaded.clients()[0].id)) + .expect("read recovered secret") + .expect("recovered secret exists"); + assert_eq!(token_digest(&stored), reloaded.clients()[0].token_digest); + } +} diff --git a/src-tauri/src/home.rs b/src-tauri/src/home.rs index 60ebdce9..c75ef802 100644 --- a/src-tauri/src/home.rs +++ b/src-tauri/src/home.rs @@ -14,13 +14,20 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use cap_fs_ext::{ambient_authority, DirExt}; use cap_std::fs::Dir; -use serde::{Deserialize, Serialize}; +use serde::{ + de::{self, SeqAccess, Visitor}, + Deserialize, Deserializer, Serialize, +}; use tauri::{AppHandle, Manager}; static REGISTRY_LOCK: Mutex<()> = Mutex::new(()); const MAX_RECENT_PROJECTS: usize = 12; const MAX_PROJECT_PATH_BYTES: usize = 32_768; const MAX_REGISTRY_BYTES: u64 = 512 * 1024; +const MAX_PROJECT_PREVIEW_BYTES: u64 = 64 * 1024 * 1024; +const MAX_PROJECT_PREVIEW_TRACKS: usize = 64; +const MAX_HOME_THUMBNAIL_BYTES: u64 = 8 * 1024 * 1024; +const MAX_HOME_THUMBNAIL_DIMENSION: u32 = 16_384; const HOME_PROBE_TIMEOUT: Duration = Duration::from_secs(2); #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -74,10 +81,82 @@ pub struct HomeProjectEntry { opened_at: u64, modified_at: u64, thumbnail_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + preview: Option, missing: bool, offline: bool, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HomeProjectPreview { + canvas_width: i32, + canvas_height: i32, + track_kinds: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct HomeProjectPreviewWire { + width: Option, + height: Option, + #[serde( + default, + rename = "tracks", + deserialize_with = "deserialize_optional_home_track_kinds" + )] + track_kinds: Option>, +} + +#[derive(Debug, Deserialize)] +struct HomeTrackPreviewWire { + #[serde(rename = "type")] + kind: opentake_domain::ClipType, +} + +fn deserialize_optional_home_track_kinds<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + struct TrackKindsVisitor; + + impl<'de> Visitor<'de> for TrackKindsVisitor { + type Value = Vec; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "at most {MAX_PROJECT_PREVIEW_TRACKS} project tracks" + ) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + let mut kinds = Vec::with_capacity( + sequence + .size_hint() + .unwrap_or_default() + .min(MAX_PROJECT_PREVIEW_TRACKS), + ); + while let Some(track) = sequence.next_element::()? { + if kinds.len() == MAX_PROJECT_PREVIEW_TRACKS { + return Err(de::Error::custom(format!( + "project preview exceeds the {MAX_PROJECT_PREVIEW_TRACKS}-track limit" + ))); + } + kinds.push(track.kind); + } + Ok(kinds) + } + } + + deserializer.deserialize_seq(TrackKindsVisitor).map(Some) +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LegacyRecentProject { @@ -312,11 +391,36 @@ fn home_entry( opened_at: entry.last_opened_at, modified_at, thumbnail_path, + preview: None, missing, offline, } } +fn read_project_preview(bundle: &Path) -> Option { + let root = opentake_project::ProjectRoot::open(bundle).ok()?; + let file = root.open_asset_file(Path::new("project.json")).ok()?; + let metadata = file.metadata().ok()?; + if !metadata.is_file() || metadata.len() > MAX_PROJECT_PREVIEW_BYTES { + return None; + } + let wire: HomeProjectPreviewWire = + serde_json::from_reader(file.take(MAX_PROJECT_PREVIEW_BYTES + 1)).ok()?; + let (Some(canvas_width), Some(canvas_height), Some(track_kinds)) = + (wire.width, wire.height, wire.track_kinds) + else { + return None; + }; + if canvas_width <= 0 || canvas_height <= 0 { + return None; + } + Some(HomeProjectPreview { + canvas_width, + canvas_height, + track_kinds, + }) +} + fn stored_modified_at(entry: &ProjectEntry) -> u64 { if entry.modified_at == 0 { entry.last_opened_at @@ -368,8 +472,49 @@ fn probe_project_entry( .or_else(|| modified_millis(&bundle_metadata)) .unwrap_or_else(|| stored_modified_at(entry)); let thumbnail = entry.path.join("thumbnail.jpg"); - let thumbnail_path = authorize_thumbnail(&thumbnail).then_some(thumbnail); - home_entry(entry, modified_at, thumbnail_path, false, false) + let thumbnail_path = + (valid_home_thumbnail(&thumbnail) && authorize_thumbnail(&thumbnail)).then_some(thumbnail); + let mut result = home_entry(entry, modified_at, thumbnail_path, false, false); + result.preview = read_project_preview(&entry.path); + result +} + +fn valid_home_thumbnail(path: &Path) -> bool { + let Ok(metadata) = fs::symlink_metadata(path) else { + return false; + }; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() == 0 + || metadata.len() > MAX_HOME_THUMBNAIL_BYTES + { + return false; + } + let Ok(file) = fs::File::open(path) else { + return false; + }; + let mut bytes = Vec::with_capacity(metadata.len() as usize); + if file + .take(MAX_HOME_THUMBNAIL_BYTES + 1) + .read_to_end(&mut bytes) + .is_err() + || bytes.len() as u64 != metadata.len() + { + return false; + } + let Ok(reader) = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format() + else { + return false; + }; + if reader.format() != Some(image::ImageFormat::Jpeg) { + return false; + } + reader.into_dimensions().is_ok_and(|(width, height)| { + width > 0 + && height > 0 + && width <= MAX_HOME_THUMBNAIL_DIMENSION + && height <= MAX_HOME_THUMBNAIL_DIMENSION + }) } fn probe_project_entries_with( @@ -1123,6 +1268,15 @@ pub async fn home_project_reveal(app: AppHandle, path: String) -> Result<(), Str mod tests { use super::*; + fn write_test_jpeg(path: &Path, color: [u8; 3]) { + let pixels = color.repeat(16 * 9); + let mut bytes = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 80) + .encode(&pixels, 16, 9, image::ExtendedColorType::Rgb8) + .unwrap(); + fs::write(path, bytes).unwrap(); + } + #[test] fn missing_entry_survives_registry_load_and_safe_trash_removes_only_after_success() { let directory = tempfile::tempdir().unwrap(); @@ -1381,7 +1535,7 @@ mod tests { let project = directory.path().join("Metadata.opentake"); fs::create_dir(&project).unwrap(); fs::write(project.join("project.json"), b"{}").unwrap(); - fs::write(project.join("thumbnail.jpg"), b"jpeg").unwrap(); + write_test_jpeg(&project.join("thumbnail.jpg"), [20, 40, 80]); let mut registry = ProjectRegistry::load(ledger).unwrap(); registry @@ -1399,10 +1553,140 @@ mod tests { assert!(entry.modified_at > 0); assert_eq!(entry.thumbnail_path, Some(project.join("thumbnail.jpg"))); + assert!(serde_json::to_value(&entry) + .unwrap() + .get("preview") + .is_none()); assert!(!entry.missing); assert!(!entry.offline); } + #[test] + fn thumbnail_invalid_prior_jpeg_is_retained_but_not_advertised() { + let directory = tempfile::tempdir().unwrap(); + let ledger = directory.path().join("project-registry.json"); + let project = directory.path().join("InvalidCover.opentake"); + fs::create_dir(&project).unwrap(); + fs::write(project.join("project.json"), b"{}").unwrap(); + fs::write(project.join("thumbnail.jpg"), b"not-a-jpeg").unwrap(); + + let mut registry = ProjectRegistry::load(ledger).unwrap(); + registry + .register_at( + project.clone(), + 10, + capture_registered_bundle_identity(&project).unwrap(), + ) + .unwrap(); + let entry = probe_project_entries_with(registry.entries_snapshot(), |_| true) + .pop() + .unwrap(); + + assert_eq!(entry.thumbnail_path, None); + assert_eq!( + fs::read(project.join("thumbnail.jpg")).unwrap(), + b"not-a-jpeg" + ); + } + + #[cfg(unix)] + #[test] + fn thumbnail_atomic_replacement_failure_retains_previous_valid_jpeg() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempfile::tempdir().unwrap(); + let bundle = directory.path().join("AtomicCover.opentake"); + let prior_path = directory.path().join("prior.jpg"); + let replacement_path = directory.path().join("replacement.jpg"); + write_test_jpeg(&prior_path, [20, 40, 80]); + write_test_jpeg(&replacement_path, [200, 10, 10]); + let prior = fs::read(&prior_path).unwrap(); + let replacement = fs::read(&replacement_path).unwrap(); + let mut project = opentake_project::Project::new(&bundle); + project.thumbnail = Some(prior.clone()); + project.save().unwrap(); + let original_mode = fs::metadata(&bundle).unwrap().permissions().mode(); + fs::set_permissions(&bundle, fs::Permissions::from_mode(0o555)).unwrap(); + + project.thumbnail = Some(replacement); + let result = project.save(); + fs::set_permissions(&bundle, fs::Permissions::from_mode(original_mode)).unwrap(); + + assert!(result.is_err(), "read-only atomic replacement must fail"); + assert_eq!(fs::read(bundle.join("thumbnail.jpg")).unwrap(), prior); + } + + #[test] + fn filesystem_probe_reports_explicit_canvas_and_actual_track_kinds() { + let directory = tempfile::tempdir().unwrap(); + let ledger = directory.path().join("project-registry.json"); + let project = directory.path().join("Portrait.opentake"); + fs::create_dir(&project).unwrap(); + fs::write( + project.join("project.json"), + br#"{ + "width": 1080, + "height": 1920, + "tracks": [ + { "type": "video", "clips": [] }, + { "type": "audio", "clips": [] } + ] + }"#, + ) + .unwrap(); + + let mut registry = ProjectRegistry::load(ledger).unwrap(); + registry.register_at(project, 10, None).unwrap(); + let entry = probe_project_entries_with(registry.entries_snapshot(), |_| false) + .pop() + .unwrap(); + let json = serde_json::to_value(entry).unwrap(); + + assert_eq!(json["preview"]["canvasWidth"], 1080); + assert_eq!(json["preview"]["canvasHeight"], 1920); + assert_eq!( + json["preview"]["trackKinds"], + serde_json::json!(["video", "audio"]) + ); + } + + #[test] + fn filesystem_probe_omits_preview_when_tracks_are_not_explicit() { + let directory = tempfile::tempdir().unwrap(); + let ledger = directory.path().join("project-registry.json"); + let project = directory.path().join("Incomplete.opentake"); + fs::create_dir(&project).unwrap(); + fs::write( + project.join("project.json"), + br#"{ "width": 1080, "height": 1920 }"#, + ) + .unwrap(); + + let mut registry = ProjectRegistry::load(ledger).unwrap(); + registry.register_at(project, 10, None).unwrap(); + let entry = probe_project_entries_with(registry.entries_snapshot(), |_| false) + .pop() + .unwrap(); + + assert!(serde_json::to_value(entry) + .unwrap() + .get("preview") + .is_none()); + + fs::write( + directory.path().join("Incomplete.opentake/project.json"), + br#"{ "width": 1080, "height": 1920, "tracks": [] }"#, + ) + .unwrap(); + let entry = probe_project_entries_with(registry.entries_snapshot(), |_| false) + .pop() + .unwrap(); + assert_eq!( + serde_json::to_value(entry).unwrap()["preview"]["trackKinds"], + serde_json::json!([]) + ); + } + #[test] fn home_thumbnail_scope_grant_is_exact_and_excludes_project_data() { let directory = tempfile::Builder::new() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 140da9db..40d5ae31 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,6 +16,10 @@ mod commands; // drive the export orchestrator (`export::run_export`) against the library // target. The Tauri command itself is registered below like the other modules. pub mod export; +#[cfg(not(feature = "external-mcp-integration"))] +mod external_mcp; +#[cfg(feature = "external-mcp-integration")] +pub mod external_mcp; pub mod feedback; mod fs_availability; mod generation; @@ -26,6 +30,7 @@ mod lut; mod mcp; mod media; pub mod motion; +mod motion_documents; // Public for the same reason as `export`: integration acceptance drives the // standalone compositing path against a generated project snapshot. pub mod render; @@ -127,28 +132,18 @@ pub fn run() { .plugin(tauri_plugin_updater::Builder::new().build()) .on_window_event(|window, event| { if let WindowEvent::CloseRequested { api, .. } = event { - // Background-run: don't quit, hide and return to Home. + // Background-run: don't quit. The actual hide waits for the + // bounded off-thread composite-cover save, so CloseRequested + // has parity with explicit Save without blocking the UI event + // thread on ffmpeg/GPU/bundle I/O. api.prevent_close(); - // Flush the open project before hiding so background-run never - // loses edits (autosave is debounced; this is the final write). - // No-op when no project is open (save_project returns an error we - // intentionally ignore). Once an update owns admission, its - // own final save is authoritative and no later close event may - // write across that barrier. - if let Some(core) = window.app_handle().try_state::() { - if let Some(admission) = window - .app_handle() - .try_state::() - { - if let Ok(_activity) = updater::begin_mutating_activity(&admission) { - let _ = core.save_project(None); - } - } else { - let _ = core.save_project(None); - } - } - let _ = window.hide(); - let _ = window.app_handle().emit("go_home", ()); + let window = window.clone(); + let app = window.app_handle().clone(); + tauri::async_runtime::spawn(async move { + let _ = commands::save_current_project_with_composite_cover(app).await; + let _ = window.hide(); + let _ = window.app_handle().emit("go_home", ()); + }); } }) .setup(|app| { @@ -214,6 +209,10 @@ pub fn run() { cache_root.clone(), models_dir.clone(), )); + let motion_document_app = app.handle().clone(); + let motion_document_notify: mcp::MotionDocumentNotifier = Arc::new(move |change| { + let _ = motion_document_app.emit("motion_document_changed", change); + }); let chat_state = chat::ChatState::new_with_capabilities( core.clone(), workflows_dir, @@ -222,11 +221,22 @@ pub fn run() { generation_bridge.clone(), motion_bridge.clone(), advanced_bridge.clone(), + motion_document_notify, install_admission.clone(), ); - // The fixed-port external MCP endpoint is disabled for Beta until - // the product has an authenticated pairing UX. Official Codex - // turns bind their own authenticated per-turn endpoint. + let external_mcp_state = match app.path().app_data_dir() { + Ok(data_dir) => external_mcp::ExternalMcpState::load( + core.clone(), + chat_state.external_mcp_components(), + &data_dir, + Arc::new(opentake_gen::KeyringStore::new()), + ), + Err(error) => external_mcp::ExternalMcpState::auth_failure( + core.clone(), + chat_state.external_mcp_components(), + format!("could not resolve external MCP application data directory: {error}"), + ), + }; // A global favorite must never silently become a temporary file. // Keep the editor usable if app-data resolution fails, but make all @@ -239,8 +249,11 @@ pub fn run() { "global library unavailable: could not resolve app data directory: {error}" )), }; + let motion_document_store = + Arc::new(motion_documents::MotionDocumentStore::new(core.clone())); app.manage(core); + app.manage(motion_document_store); app.manage(commands::ProjectLifecycleCoordinator::default()); app.manage(generation_bridge); let motion_state = @@ -270,6 +283,14 @@ pub fn run() { } }); app.manage(chat_state); + app.manage(external_mcp_state); + external_mcp::install_status_emitter( + app.handle(), + &app.state::(), + ); + tauri::async_runtime::block_on( + app.state::().initialize(), + ); app.manage(codex::CodexAuthState::default()); app.manage(MediaState::new_with_admission( engine, @@ -398,9 +419,16 @@ pub fn run() { generation::generation_cancel, generation::generation_retry, motion::motion_capability, + motion::motion_preview, + motion::motion_preview_cancel, motion::motion_add, motion::motion_edit, motion::motion_cancel, + motion_documents::motion_document_list, + motion_documents::motion_document_create, + motion_documents::motion_document_read, + motion_documents::motion_document_hash, + motion_documents::motion_document_patch, advanced::matting_model_status, advanced::download_matting_model, advanced::cancel_matting_model_download, @@ -428,9 +456,15 @@ pub fn run() { codex::codex_logout, chat::chat_send, chat::chat_history, + chat::chat_history_authoritative, chat::chat_sessions, chat::chat_session_set_open, chat::chat_cancel, + external_mcp::external_mcp_status, + external_mcp::external_mcp_set_enabled, + external_mcp::external_mcp_pair, + external_mcp::external_mcp_regenerate, + external_mcp::external_mcp_revoke, transcribe::transcribe_model_status, transcribe::download_transcribe_model, transcribe::transcribe_media, @@ -468,6 +502,9 @@ pub fn run() { .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|_app, _event| { + if matches!(&_event, RunEvent::Exit) { + external_mcp::shutdown_on_exit(_app); + } // A user-driven Quit must not interrupt bundle replacement. The // updater's own restart has a programmatic exit code and remains // allowed after both save barriers succeed. diff --git a/src-tauri/src/library.rs b/src-tauri/src/library.rs index 5c0f87c8..0553f41a 100644 --- a/src-tauri/src/library.rs +++ b/src-tauri/src/library.rs @@ -727,6 +727,35 @@ impl ProjectMediaCapability { self.matches_handle(&leaf.name, &leaf.handle) } + /// Make a newly-created media leaf's directory entry durable before any + /// project manifest or timeline is allowed to reference it. + pub(crate) fn sync_media_directory(&self) -> Result<(), String> { + #[cfg(unix)] + { + use cap_std::fs::OpenOptionsExt; + + // cap-std retains traversed directories with O_PATH on Linux. + // That handle is suitable for capability traversal but fsync(2) + // rejects it with EBADF, so reopen the exact retained directory + // through the capability before flushing its directory entry. + let mut options = OpenOptions::new(); + options + .read(true) + .follow(FollowSymlinks::No) + .custom_flags(libc::O_DIRECTORY); + self.media + .open_with(".", &options) + .and_then(|directory| directory.sync_all()) + .map_err(|error| format!("project media directory could not be synced: {error}")) + } + #[cfg(not(unix))] + { + // Windows persists the create through the retained file handle; + // opening directory handles for FlushFileBuffers is not portable. + Ok(()) + } + } + fn absolute_path(&self, name: impl AsRef) -> PathBuf { self.project_dir.join("media").join(name) } @@ -1634,6 +1663,27 @@ mod tests { assert!(retained_projects.join("ParentSwap.opentake/media").is_dir()); } + #[cfg(unix)] + #[test] + fn project_media_capability_syncs_through_a_readable_directory_handle() { + let tmp = tempfile::tempdir().unwrap(); + let bundle = tmp.path().join("DurableMedia.opentake"); + std::fs::create_dir(&bundle).unwrap(); + let capability = ProjectMediaCapability::open(&bundle, true).unwrap(); + let mut leaf = capability.create_import(Path::new("motion.mp4")).unwrap(); + leaf.file_mut().write_all(b"motion bytes").unwrap(); + leaf.file().sync_all().unwrap(); + + capability + .sync_media_directory() + .expect("capability-relative directory fsync must accept Linux O_PATH roots"); + + assert_eq!( + std::fs::read(bundle.join("media/motion.mp4")).unwrap(), + b"motion bytes" + ); + } + #[test] fn unavailable_library_returns_the_initialization_error() { let library = LibraryState::unavailable("app data missing"); diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 053a5a1f..ab787743 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -22,9 +22,7 @@ use std::io::{Seek, SeekFrom, Write}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::path::{Path, PathBuf}; use std::rc::Rc; -#[cfg(test)] use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; -#[cfg(test)] use std::sync::Mutex; use std::sync::{Arc, RwLock}; use std::time::Duration; @@ -42,16 +40,26 @@ use opentake_agent::mcp::dispatch::Dispatcher; use opentake_agent::mcp::media_bridge::{ BridgeError, ImportOutcome, ImportSource, InspectMediaRequest, InspectMediaResult, InspectResult, InspectedFrame, InspectedMediaFrame, MediaBridge, SearchCandidate, - SearchIndexState, SearchMediaResult, SearchSpokenHit, SearchVisualHit, TranscriptSource, - TranscriptSourceResult, IMPORT_BYTES_DECODED_MAX, + SearchIndexState, SearchMediaResult, SearchSpokenHit, SearchVisualHit, + TimelineResultCaptureRequest, TranscriptSource, TranscriptSourceResult, + IMPORT_BYTES_DECODED_MAX, TIMELINE_RESULT_IMAGE_BASE64_MAX, +}; +use opentake_agent::mcp::motion::MotionBridge; +use opentake_agent::mcp::motion_documents::{ + AdmittedMotionDocumentOperation, MotionDocument as AgentMotionDocument, MotionDocumentBridge, + MotionDocumentBridgeError, MotionDocumentBridgeErrorKind, + MotionDocumentPreview as AgentMotionDocumentPreview, + MotionDocumentPublish as AgentMotionDocumentPublish, + MotionDocumentReference as AgentMotionDocumentReference, MotionDocumentRequest, + MotionDocumentResponse, MotionDocumentSummary as AgentMotionDocumentSummary, + MotionPreviewDiagnostic as AgentMotionPreviewDiagnostic, }; use opentake_agent::mcp::server::{bind_ephemeral_gated, EphemeralMcpEndpoint, EphemeralMcpError}; use opentake_agent::plugin::registry::PluginRegistry; -#[cfg(test)] -use opentake_agent::tools::result::ToolResult; +use opentake_agent::tools::result::{Block, ToolResult}; use opentake_core::{ importable_clip_type, AppCore, CoreError, DeferredCoreEvents, ProbedMedia, - ProjectRuntimeSnapshot, + ProjectAssetAuthority, ProjectRuntimeSnapshot, }; use opentake_domain::{ClipType, LutReference, MediaSource, TextStyle}; use opentake_media::{decode_frame_at, decode_frames_at, FrameRequest, MediaEngine, RgbaFrame}; @@ -392,8 +400,7 @@ pub(crate) async fn spawn( bind_ephemeral_gated(dispatcher, registry, gate).await } -#[cfg(test)] -struct LiveProjectMcpGate { +pub(crate) struct LiveProjectMcpGate { core: AppCore, transition_depth: Arc, identity_generation: Arc, @@ -401,13 +408,11 @@ struct LiveProjectMcpGate { active_dispatches: Arc>>, } -#[cfg(test)] struct LiveDispatchPermit { id: u64, active_dispatches: Arc>>, } -#[cfg(test)] impl Drop for LiveDispatchPermit { fn drop(&mut self) { self.active_dispatches @@ -417,9 +422,8 @@ impl Drop for LiveDispatchPermit { } } -#[cfg(test)] impl LiveProjectMcpGate { - fn new(core: AppCore) -> Arc { + pub(crate) fn new(core: AppCore) -> Arc { let transition_depth = Arc::new(AtomicUsize::new(0)); let identity_generation = Arc::new(AtomicU64::new(0)); let active_dispatches = Arc::new(Mutex::new(HashMap::< @@ -497,14 +501,24 @@ impl LiveProjectMcpGate { cancel: &opentake_media::MediaCancelToken, operation: impl FnOnce() -> T, ) -> Option { - self.with_live_dispatch_after_admission(cancel, || {}, operation) + self.with_live_dispatch_inner(cancel, || {}, operation) } + #[cfg(test)] fn with_live_dispatch_after_admission( &self, cancel: &opentake_media::MediaCancelToken, after_admission: impl FnOnce(), operation: impl FnOnce() -> T, + ) -> Option { + self.with_live_dispatch_inner(cancel, after_admission, operation) + } + + fn with_live_dispatch_inner( + &self, + cancel: &opentake_media::MediaCancelToken, + after_admission: impl FnOnce(), + operation: impl FnOnce() -> T, ) -> Option { let admitted_generation = self.identity_generation.load(Ordering::Acquire); if self.transition_pending() || cancel.is_cancelled() { @@ -539,7 +553,6 @@ impl LiveProjectMcpGate { } } -#[cfg(test)] impl ChatTurnGate for LiveProjectMcpGate { fn timeline(&self, dispatcher: &Dispatcher) -> Option { self.with_live_project(|| dispatcher.timeline()) @@ -562,9 +575,27 @@ impl ChatTurnGate for LiveProjectMcpGate { args: serde_json::Value, request_cancel: &opentake_media::MediaCancelToken, ) -> Option { - self.with_live_dispatch(request_cancel, || { - dispatcher.dispatch_cancellable(name, args, request_cancel) - }) + let (expected_epoch, expected_dir, receipt) = + self.with_live_dispatch(request_cancel, || { + let snapshot = self.core.runtime_snapshot(); + ( + snapshot.project_epoch, + snapshot.project_dir, + dispatcher.dispatch_cancellable_deferred(name, args, request_cancel), + ) + })?; + // GPU work happens after `with_live_dispatch` releases the project + // identity workflow read lease. + let result = dispatcher.finish_dispatch(receipt, request_cancel); + let still_current = self.with_live_project(|| { + let snapshot = self.core.runtime_snapshot(); + snapshot.project_epoch == expected_epoch && snapshot.project_dir == expected_dir + })?; + if !still_current || request_cancel.is_cancelled() { + request_cancel.cancel(); + return None; + } + Some(result) } fn dispatch_cancellable_scoped( @@ -575,9 +606,30 @@ impl ChatTurnGate for LiveProjectMcpGate { undo_scope: &str, request_cancel: &opentake_media::MediaCancelToken, ) -> Option { - self.with_live_dispatch(request_cancel, || { - dispatcher.dispatch_cancellable_scoped(undo_scope, name, args, request_cancel) - }) + let (expected_epoch, expected_dir, receipt) = + self.with_live_dispatch(request_cancel, || { + let snapshot = self.core.runtime_snapshot(); + ( + snapshot.project_epoch, + snapshot.project_dir, + dispatcher.dispatch_cancellable_scoped_deferred( + undo_scope, + name, + args, + request_cancel, + ), + ) + })?; + let result = dispatcher.finish_dispatch(receipt, request_cancel); + let still_current = self.with_live_project(|| { + let snapshot = self.core.runtime_snapshot(); + snapshot.project_epoch == expected_epoch && snapshot.project_dir == expected_dir + })?; + if !still_current || request_cancel.is_cancelled() { + request_cancel.cancel(); + return None; + } + Some(result) } } @@ -589,6 +641,453 @@ pub(crate) fn build_media_bridge( Arc::new(TauriMediaBridge::new(core, cache_root, models_dir)) } +pub(crate) fn build_motion_document_bridge( + core: AppCore, + cache_root: PathBuf, + notify: Option, +) -> Arc { + let documents = Arc::new(crate::motion_documents::MotionDocumentStore::new( + core.clone(), + )); + let motion = Arc::new(crate::motion::TauriMotionBridge::new( + core.clone(), + cache_root, + )); + let active = Arc::new(Mutex::new( + HashMap::::new(), + )); + let transition_active = active.clone(); + core.subscribe_project_identity_transition(move |pending| { + if pending { + for cancel in transition_active + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .values() + { + cancel.cancel(); + } + } + }); + Arc::new(TauriMotionDocumentBridge { + documents, + motion, + active, + next_operation: Arc::new(AtomicU64::new(1)), + notify, + }) +} + +#[derive(Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MotionDocumentChange { + pub project_epoch: u64, + pub project_path: String, + pub summary: MotionDocumentChangeSummary, +} + +#[derive(Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MotionDocumentChangeSummary { + pub id: String, + pub title: String, + pub revision_hash: String, + pub updated_at: u64, +} + +pub(crate) type MotionDocumentNotifier = Arc; + +struct TauriMotionDocumentBridge { + documents: Arc, + motion: Arc, + active: Arc>>, + next_operation: Arc, + notify: Option, +} + +struct TauriMotionDocumentOperation { + authority: ProjectAssetAuthority, + request: MotionDocumentRequest, + documents: Arc, + motion: Arc, + active: Arc>>, + next_operation: Arc, + notify: Option, +} + +struct ActiveMotionDocumentPermit { + id: u64, + active: Arc>>, +} + +impl Drop for ActiveMotionDocumentPermit { + fn drop(&mut self) { + self.active + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&self.id); + } +} + +impl MotionDocumentBridge for TauriMotionDocumentBridge { + fn can_edit_motion_documents(&self) -> bool { + self.motion.can_render_motion() + } + + fn admit( + &self, + request: MotionDocumentRequest, + ) -> Result, MotionDocumentBridgeError> { + let authority = self + .documents + .capture_authority() + .map_err(map_motion_document_store_error)?; + Ok(Box::new(TauriMotionDocumentOperation { + authority, + request, + documents: self.documents.clone(), + motion: self.motion.clone(), + active: self.active.clone(), + next_operation: self.next_operation.clone(), + notify: self.notify.clone(), + })) + } +} + +impl AdmittedMotionDocumentOperation for TauriMotionDocumentOperation { + fn execute( + self: Box, + cancel: &opentake_media::MediaCancelToken, + ) -> Result { + if cancel.is_cancelled() { + return Err(MotionDocumentBridgeError::new( + MotionDocumentBridgeErrorKind::Cancelled, + "Motion Studio operation was cancelled", + )); + } + let id = self.next_operation.fetch_add(1, Ordering::Relaxed); + self.active + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(id, cancel.clone()); + let _permit = ActiveMotionDocumentPermit { + id, + active: self.active.clone(), + }; + self.execute_inner(cancel) + } +} + +impl TauriMotionDocumentOperation { + fn execute_inner( + &self, + cancel: &opentake_media::MediaCancelToken, + ) -> Result { + match &self.request { + MotionDocumentRequest::List => self + .documents + .list_for_authority(self.authority.clone()) + .map(|items| { + MotionDocumentResponse::Documents( + items.into_iter().map(agent_motion_summary).collect(), + ) + }) + .map_err(map_motion_document_store_error), + MotionDocumentRequest::Read { document_id } => self + .documents + .read_for_authority(self.authority.clone(), document_id) + .map(agent_motion_document) + .map(MotionDocumentResponse::Document) + .map_err(map_motion_document_store_error), + MotionDocumentRequest::Create { title } => self + .documents + .create_for_authority_cancellable( + self.authority.clone(), + crate::motion_documents::MotionDocumentCreateRequest { + title: title.clone(), + }, + cancel, + ) + .map(agent_motion_document) + .map(|document| self.document_changed(document)) + .map(MotionDocumentResponse::Document) + .map_err(map_motion_document_store_error), + MotionDocumentRequest::Patch(request) => self.patch(request, cancel), + MotionDocumentRequest::Preview(request) => self.preview(request, cancel), + MotionDocumentRequest::Publish(request) => self.publish(request, cancel), + } + } + + fn patch( + &self, + request: &opentake_agent::mcp::motion_documents::MotionDocumentPatchRequest, + cancel: &opentake_media::MediaCancelToken, + ) -> Result { + let edits = request + .edits + .iter() + .map(|edit| crate::motion_documents::MotionTextReplacement { + start: edit.start, + end: edit.end, + replacement: edit.replacement.clone(), + }) + .collect::>(); + let expected = match self.documents.hash_patch_for_authority( + self.authority.clone(), + crate::motion_documents::MotionDocumentHashRequest { + document_id: request.document_id.clone(), + file: request.file.clone(), + baseline_hash: request.baseline_hash.clone(), + edits: edits.clone(), + }, + ) { + Ok(hash) => hash, + Err(error) if error.contains("revision conflict") => { + return Err(self.conflict(&request.document_id)) + } + Err(error) => return Err(map_motion_document_store_error(error)), + }; + if cancel.is_cancelled() { + return Err(MotionDocumentBridgeError::new( + MotionDocumentBridgeErrorKind::Cancelled, + "Motion Studio patch was cancelled", + )); + } + match self.documents.save_patch_for_authority_cancellable( + self.authority.clone(), + crate::motion_documents::MotionDocumentPatchRequest { + document_id: request.document_id.clone(), + file: request.file.clone(), + baseline_hash: request.baseline_hash.clone(), + edits, + expected_result_hash: expected, + }, + cancel, + ) { + Ok(document) => Ok(MotionDocumentResponse::Document( + self.document_changed(agent_motion_document(document)), + )), + Err(error) if error.contains("revision conflict") => { + Err(self.conflict(&request.document_id)) + } + Err(error) => Err(map_motion_document_store_error(error)), + } + } + + fn document_changed(&self, document: AgentMotionDocument) -> AgentMotionDocument { + if let Some(notify) = &self.notify { + notify(&MotionDocumentChange { + project_epoch: self.authority.project_epoch, + project_path: self.authority.project_path.to_string_lossy().into_owned(), + summary: MotionDocumentChangeSummary { + id: document.summary.document_id.clone(), + title: document.summary.title.clone(), + revision_hash: document.summary.revision_hash.clone(), + updated_at: document.summary.updated_at, + }, + }); + } + document + } + + fn conflict(&self, document_id: &str) -> MotionDocumentBridgeError { + let current = self + .documents + .read_for_authority(self.authority.clone(), document_id) + .ok() + .map(|document| document.summary.revision_hash); + MotionDocumentBridgeError::conflict(current) + } + + fn preview( + &self, + request: &opentake_agent::mcp::motion_documents::MotionDocumentPreviewRequest, + cancel: &opentake_media::MediaCancelToken, + ) -> Result { + let response = crate::motion::render_document_preview_for_agent( + &self.motion, + &self.documents, + self.authority.clone(), + crate::motion::MotionPreviewRequest { + document_id: request.document_id.clone(), + revision_hash: request.revision_hash.clone(), + width: request.width, + height: request.height, + fps: request.fps, + duration_frames: request.duration_frames, + frame: request.frame, + }, + cancel, + ) + .map_err(map_motion_preview_error)?; + let png_base64 = response + .png_data_url + .strip_prefix("data:image/png;base64,") + .ok_or_else(|| { + MotionDocumentBridgeError::new( + MotionDocumentBridgeErrorKind::RenderFailed, + "Motion Studio preview returned an invalid image", + ) + })? + .to_string(); + Ok(MotionDocumentResponse::Preview( + AgentMotionDocumentPreview { + revision_hash: response.revision_hash, + frame: response.frame, + png_base64, + diagnostics: response + .diagnostics + .into_iter() + .map(|diagnostic| AgentMotionPreviewDiagnostic { + severity: diagnostic.severity.to_string(), + message: diagnostic.message, + line: diagnostic.line, + column: diagnostic.column, + }) + .collect(), + }, + )) + } + + fn publish( + &self, + request: &opentake_agent::mcp::motion_documents::MotionDocumentPublishRequest, + cancel: &opentake_media::MediaCancelToken, + ) -> Result { + let source = crate::motion::resolve_document_motion_source( + &self.documents, + self.authority.clone(), + &request.document_id, + &request.revision_hash, + ) + .map_err(map_motion_bridge_error)?; + let commit = if let Some(clip_id) = &request.clip_id { + self.motion.edit_document( + crate::motion::DocumentMotionEditRequest { + clip_id: clip_id.clone(), + source, + project_authority: self.authority.clone(), + width: request.width, + height: request.height, + fps: request.fps, + duration_frames: request.duration_frames, + }, + cancel, + ) + } else { + self.motion.add_document( + crate::motion::DocumentMotionAddRequest { + source, + project_authority: self.authority.clone(), + width: request.width, + height: request.height, + fps: request.fps, + start_frame: request.start_frame.expect("validated at Agent boundary"), + duration_frames: request.duration_frames, + track_index: request.track_index, + }, + cancel, + ) + } + .map_err(map_motion_bridge_error)?; + let source_document = commit.source_document.ok_or_else(|| { + MotionDocumentBridgeError::new( + MotionDocumentBridgeErrorKind::RenderFailed, + "Motion Studio publish lost its source revision", + ) + })?; + Ok(MotionDocumentResponse::Published( + AgentMotionDocumentPublish { + clip_id: commit.clip_id, + asset_id: commit.asset_id, + duration_frames: commit.output.duration_frames, + duration_seconds: commit.output.duration_seconds, + fps: commit.output.fps, + width: commit.output.width, + height: commit.output.height, + source_document: AgentMotionDocumentReference { + document_id: source_document.document_id, + revision_hash: source_document.revision_hash, + }, + }, + )) + } +} + +fn agent_motion_summary( + summary: crate::motion_documents::MotionDocumentSummary, +) -> AgentMotionDocumentSummary { + AgentMotionDocumentSummary { + document_id: summary.id, + title: summary.title, + revision_hash: summary.revision_hash, + updated_at: summary.updated_at, + } +} + +fn agent_motion_document(document: crate::motion_documents::MotionDocument) -> AgentMotionDocument { + AgentMotionDocument { + summary: agent_motion_summary(document.summary), + html: document.html, + css: document.css, + parameters: document.parameters, + } +} + +fn map_motion_document_store_error(error: String) -> MotionDocumentBridgeError { + let kind = if error.contains("revision conflict") { + MotionDocumentBridgeErrorKind::Conflict + } else if error.contains("not found") { + MotionDocumentBridgeErrorKind::ResourceNotFound + } else if error.contains("changed") || error.contains("cancel") { + MotionDocumentBridgeErrorKind::Cancelled + } else if error.contains("invalid") + || error.contains("must") + || error.contains("requires") + || error.contains("limit") + { + MotionDocumentBridgeErrorKind::InvalidArguments + } else { + MotionDocumentBridgeErrorKind::CapabilityUnavailable + }; + MotionDocumentBridgeError::new(kind, error) +} + +fn map_motion_preview_error(error: crate::motion::MotionPreviewError) -> MotionDocumentBridgeError { + let kind = if error.message.contains("cancel") || error.message.contains("project changed") { + MotionDocumentBridgeErrorKind::Cancelled + } else if error.message.contains("changed; reload") { + MotionDocumentBridgeErrorKind::Conflict + } else if error.message.contains("invalid") || error.message.contains("inside") { + MotionDocumentBridgeErrorKind::InvalidArguments + } else { + MotionDocumentBridgeErrorKind::RenderFailed + }; + MotionDocumentBridgeError::new(kind, error.message) +} + +fn map_motion_bridge_error( + error: opentake_agent::mcp::motion::MotionBridgeError, +) -> MotionDocumentBridgeError { + let kind = match error.kind { + opentake_agent::mcp::motion::MotionBridgeErrorKind::InvalidArguments => { + MotionDocumentBridgeErrorKind::InvalidArguments + } + opentake_agent::mcp::motion::MotionBridgeErrorKind::ResourceNotFound => { + MotionDocumentBridgeErrorKind::ResourceNotFound + } + opentake_agent::mcp::motion::MotionBridgeErrorKind::CapabilityUnavailable => { + MotionDocumentBridgeErrorKind::CapabilityUnavailable + } + opentake_agent::mcp::motion::MotionBridgeErrorKind::Cancelled => { + MotionDocumentBridgeErrorKind::Cancelled + } + opentake_agent::mcp::motion::MotionBridgeErrorKind::RenderFailed => { + MotionDocumentBridgeErrorKind::RenderFailed + } + }; + MotionDocumentBridgeError::new(kind, error.message) +} + /// The production [`MediaBridge`]: composites timeline frames on the GPU and /// imports media through the same path as the media panel. struct TauriMediaBridge { @@ -598,6 +1097,9 @@ struct TauriMediaBridge { /// import go through this, so imported assets are cached exactly like the /// panel's. Built here (the engine is not `Clone`) from the same paths. engine: MediaEngine, + /// Dedicated compositor state for post-commit agent images. It is isolated + /// from UI preview scheduling while still reusing its GPU context per turn. + render: crate::render::RenderState, } struct RetainedExternalSource { @@ -693,6 +1195,7 @@ impl TauriMediaBridge { TauriMediaBridge { core, engine: MediaEngine::new(cache_root, models_dir), + render: crate::render::RenderState::new(), } } } @@ -718,6 +1221,91 @@ fn resolve_transcript_batch( } impl MediaBridge for TauriMediaBridge { + fn visible_timeline_clip_count( + &self, + timeline: &opentake_domain::Timeline, + ) -> Result { + crate::render::authoritative_visible_clip_count(timeline, &self.core.media()) + .map_err(BridgeError::new) + } + + fn capture_timeline_result( + &self, + request: &TimelineResultCaptureRequest, + cancel: &opentake_media::MediaCancelToken, + ) -> Result { + let expected = request + .mutation + .committed_revision + .as_ref() + .ok_or_else(|| BridgeError::new("timeline result capture lacks a project revision"))?; + if request.mutation.visible_clip_count_before == 0 + || request.mutation.visible_clip_count_after != 0 + { + return Err(BridgeError::new( + "timeline result capture receipt is not a visible-to-empty transition", + )); + } + + // Snapshot under the core's internal lock, then release it before GPU + // work. The complete identity and timeline are rechecked before bytes + // cross the bridge boundary. + let snapshot = self.core.runtime_snapshot(); + if snapshot.project_epoch != expected.project_epoch + || snapshot.version != expected.timeline_version + || snapshot.project_dir != expected.project_dir + || snapshot.timeline != request.timeline + { + return Err(BridgeError::new( + "timeline result capture project revision was superseded", + )); + } + if crate::render::authoritative_visible_clip_count(&snapshot.timeline, &snapshot.media) + .map_err(BridgeError::new)? + != 0 + { + return Err(BridgeError::new( + "timeline result capture snapshot is not empty", + )); + } + + let input = crate::render::EmptyTimelineCanvasInput { + project_width: snapshot.timeline.width, + project_height: snapshot.timeline.height, + fps: snapshot.timeline.fps, + playhead_frame: crate::render::root_timeline_playhead(snapshot.project_epoch), + }; + let authority = crate::render::CompositeSourceAuthority::new(HashMap::new()); + let rendered = crate::render::render_timeline_result_png( + &snapshot.timeline, + &snapshot.media, + &snapshot.project_dir, + &self.render, + input, + cancel, + &authority, + ) + .map_err(BridgeError::new)?; + + let current = self.core.runtime_snapshot(); + if current.project_epoch != expected.project_epoch + || current.version != expected.timeline_version + || current.project_dir != expected.project_dir + || current.timeline != request.timeline + { + return Err(BridgeError::new( + "timeline result capture project changed during rendering", + )); + } + let base64 = base64::engine::general_purpose::STANDARD.encode(rendered.bytes); + if base64.is_empty() || base64.len() > TIMELINE_RESULT_IMAGE_BASE64_MAX { + return Err(BridgeError::new( + "timeline result capture exceeded the response limit", + )); + } + Ok(Block::image(base64, rendered.media_type)) + } + fn inspect_media( &self, request: &InspectMediaRequest, @@ -2559,6 +3147,7 @@ fn project_frame_time_secs(source_frame: i64, timeline_fps: i32) -> f64 { mod tests { use super::*; use opentake_agent::mcp::core_handle::{AppCoreHandle, CoreHandle}; + use opentake_agent::mcp::media_bridge::TimelineMutationReceipt; use std::sync::Condvar; #[test] @@ -2566,6 +3155,129 @@ mod tests { let _entrypoint = spawn; } + #[test] + fn motion_document_bridge_is_hash_safe_and_project_bound() { + let fixture = tempfile::tempdir().expect("motion bridge fixture"); + let core = AppCore::new(); + core.save_project(Some(fixture.path().join("A.opentake"))) + .expect("save project A"); + let notifications = Arc::new(Mutex::new(Vec::new())); + let captured = notifications.clone(); + let bridge = build_motion_document_bridge( + core.clone(), + fixture.path().join("motion-cache"), + Some(Arc::new(move |summary| { + captured + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(summary.clone()); + })), + ); + assert!(bridge.can_edit_motion_documents()); + + let created = bridge + .admit(MotionDocumentRequest::Create { + title: Some("Agent co-edit".into()), + }) + .expect("admit create") + .execute(&opentake_media::MediaCancelToken::new()) + .expect("create document"); + let MotionDocumentResponse::Document(created) = created else { + panic!("create returned wrong response"); + }; + let start = created.html.len(); + let patched = bridge + .admit(MotionDocumentRequest::Patch( + opentake_agent::mcp::motion_documents::MotionDocumentPatchRequest { + document_id: created.summary.document_id.clone(), + file: "index.html".into(), + baseline_hash: created.summary.revision_hash.clone(), + edits: vec![ + opentake_agent::mcp::motion_documents::MotionTextReplacement { + start, + end: start, + replacement: "\n".into(), + }, + ], + }, + )) + .expect("admit patch") + .execute(&opentake_media::MediaCancelToken::new()) + .expect("patch document"); + let MotionDocumentResponse::Document(patched) = patched else { + panic!("patch returned wrong response"); + }; + assert!(patched.html.contains("真实字符")); + assert_ne!(patched.summary.revision_hash, created.summary.revision_hash); + let notifications = notifications + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(notifications.len(), 2); + assert_eq!( + notifications + .last() + .map(|change| &change.summary.revision_hash), + Some(&patched.summary.revision_hash) + ); + let project_a = core.runtime_snapshot(); + assert_eq!(notifications[1].project_epoch, project_a.project_epoch); + assert_eq!( + notifications[1].project_path, + project_a + .project_dir + .expect("saved project path") + .to_string_lossy() + ); + drop(notifications); + + let stale = bridge + .admit(MotionDocumentRequest::Patch( + opentake_agent::mcp::motion_documents::MotionDocumentPatchRequest { + document_id: created.summary.document_id.clone(), + file: "styles.css".into(), + baseline_hash: created.summary.revision_hash, + edits: vec![ + opentake_agent::mcp::motion_documents::MotionTextReplacement { + start: 0, + end: 0, + replacement: "/* stale */".into(), + }, + ], + }, + )) + .expect("admit stale patch") + .execute(&opentake_media::MediaCancelToken::new()) + .expect_err("stale patch conflicts"); + assert_eq!(stale.kind, MotionDocumentBridgeErrorKind::Conflict); + assert_eq!( + stale.current_revision_hash.as_deref(), + Some(patched.summary.revision_hash.as_str()) + ); + + let admitted_a = bridge + .admit(MotionDocumentRequest::Read { + document_id: created.summary.document_id.clone(), + }) + .expect("admit project A read"); + core.save_project(Some(fixture.path().join("B.opentake"))) + .expect("switch to project B"); + let switched = admitted_a + .execute(&opentake_media::MediaCancelToken::new()) + .expect_err("A operation cannot enter B"); + assert_eq!(switched.kind, MotionDocumentBridgeErrorKind::Cancelled); + + let listed_b = bridge + .admit(MotionDocumentRequest::List) + .expect("admit B list") + .execute(&opentake_media::MediaCancelToken::new()) + .expect("list B"); + let MotionDocumentResponse::Documents(listed_b) = listed_b else { + panic!("list returned wrong response"); + }; + assert_eq!(listed_b.len(), 1, "Save As preserves the document"); + assert_eq!(listed_b[0].document_id, created.summary.document_id); + } + struct BlockingImportBridge { entered: Mutex>>, released: Mutex, @@ -2629,7 +3341,7 @@ mod tests { } #[test] - fn persistent_mcp_gate_requires_a_saved_nontransitioning_project() { + fn live_project_gate_requires_a_saved_nontransitioning_project() { let fixture = tempfile::tempdir().unwrap(); let core = AppCore::new(); let gate = LiveProjectMcpGate::new(core.clone()); @@ -2649,7 +3361,134 @@ mod tests { } #[test] - fn persistent_mcp_request_admitted_for_old_project_cannot_write_new_project() { + fn live_project_gate_refuses_mutating_calls_without_a_saved_project() { + let core = AppCore::new(); + let gate = LiveProjectMcpGate::new(core.clone()); + let registry = Arc::new(RwLock::new(PluginRegistry::with_builtins())); + let handle: Arc = Arc::new(AppCoreHandle::new(core.clone())); + let dispatcher = Dispatcher::new(handle, registry); + + assert!(gate + .dispatch( + &dispatcher, + "create_folder", + serde_json::json!({ "name": "must-not-exist" }), + ) + .is_none()); + assert!(core.media().folders.is_empty()); + } + + #[test] + fn tauri_bridge_returns_bounded_real_png_for_current_empty_revision() { + let fixture = tempfile::tempdir().unwrap(); + let core = AppCore::new(); + core.save_project(Some(fixture.path().join("Empty.opentake"))) + .unwrap(); + let snapshot = core.runtime_snapshot(); + let bridge = TauriMediaBridge::new( + core, + fixture.path().join("cache"), + fixture.path().join("models"), + ); + let block = bridge + .capture_timeline_result( + &TimelineResultCaptureRequest { + timeline: snapshot.timeline, + mutation: TimelineMutationReceipt { + visible_clip_count_before: 1, + visible_clip_count_after: 0, + committed_revision: Some(opentake_agent::mcp::core_handle::CoreRevision { + project_epoch: snapshot.project_epoch, + project_dir: snapshot.project_dir, + timeline_version: snapshot.version, + }), + }, + }, + &opentake_media::MediaCancelToken::new(), + ) + .expect("capture current empty timeline"); + + let Block::Image { base64, media_type } = block else { + panic!("timeline result must be an image block"); + }; + assert_eq!(media_type, "image/png"); + assert!(base64.len() <= TIMELINE_RESULT_IMAGE_BASE64_MAX); + let bytes = base64::engine::general_purpose::STANDARD + .decode(base64) + .expect("decode returned image"); + assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n"); + } + + #[test] + fn tauri_bridge_rejects_stale_project_before_returning_image_bytes() { + let fixture = tempfile::tempdir().unwrap(); + let core = AppCore::new(); + core.save_project(Some(fixture.path().join("A.opentake"))) + .unwrap(); + let snapshot = core.runtime_snapshot(); + let request = TimelineResultCaptureRequest { + timeline: snapshot.timeline, + mutation: TimelineMutationReceipt { + visible_clip_count_before: 1, + visible_clip_count_after: 0, + committed_revision: Some(opentake_agent::mcp::core_handle::CoreRevision { + project_epoch: snapshot.project_epoch, + project_dir: snapshot.project_dir, + timeline_version: snapshot.version, + }), + }, + }; + core.save_project(Some(fixture.path().join("B.opentake"))) + .unwrap(); + let bridge = TauriMediaBridge::new( + core, + fixture.path().join("cache"), + fixture.path().join("models"), + ); + + bridge + .capture_timeline_result(&request, &opentake_media::MediaCancelToken::new()) + .expect_err("stale project capture must fail closed"); + } + + #[test] + fn tauri_bridge_cancels_real_empty_png_capture_with_the_request_token() { + let fixture = tempfile::tempdir().unwrap(); + let core = AppCore::new(); + core.save_project(Some(fixture.path().join("Cancelled.opentake"))) + .unwrap(); + let snapshot = core.runtime_snapshot(); + let bridge = TauriMediaBridge::new( + core, + fixture.path().join("cache"), + fixture.path().join("models"), + ); + let cancel = opentake_media::MediaCancelToken::new(); + cancel.cancel(); + + let error = bridge + .capture_timeline_result( + &TimelineResultCaptureRequest { + timeline: snapshot.timeline, + mutation: TimelineMutationReceipt { + visible_clip_count_before: 1, + visible_clip_count_after: 0, + committed_revision: Some(opentake_agent::mcp::core_handle::CoreRevision { + project_epoch: snapshot.project_epoch, + project_dir: snapshot.project_dir, + timeline_version: snapshot.version, + }), + }, + }, + &cancel, + ) + .expect_err("the original canceled request token must stop PNG capture"); + + assert!(error.message.contains("cancel"), "{}", error.message); + } + + #[test] + fn live_project_request_admitted_for_old_project_cannot_write_new_project() { let fixture = tempfile::tempdir().unwrap(); let core = AppCore::new(); let project_a = fixture.path().join("A.opentake"); @@ -2701,7 +3540,7 @@ mod tests { } #[test] - fn project_transition_cancels_active_persistent_mcp_before_identity_changes() { + fn live_project_transition_cancels_active_request_before_identity_changes() { let fixture = tempfile::tempdir().unwrap(); let core = AppCore::new(); core.save_project(Some(fixture.path().join("A.opentake"))) diff --git a/src-tauri/src/motion.rs b/src-tauri/src/motion.rs index 8733f881..4973326a 100644 --- a/src-tauri/src/motion.rs +++ b/src-tauri/src/motion.rs @@ -5,21 +5,26 @@ //! PNG frame sequence with the bundled FFmpeg, then asks `AppCore` to register //! and place/replace the video in one durable undo transaction. +use std::collections::BTreeMap; use std::io::Write; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; +use base64::Engine as _; use opentake_agent::mcp::motion::{ AddMotionRequest, EditMotionRequest, MotionBridge, MotionBridgeError, MotionBridgeErrorKind, - MotionCommit, MotionOutputMetadata, MotionSourceRequest, + MotionCommit, MotionDocumentReference, MotionOutputMetadata, MotionSourceRequest, +}; +use opentake_core::{ + AppCore, DeferredCoreEvents, MotionPlacement, ProbedMedia, ProjectAssetAuthority, }; -use opentake_core::{AppCore, MotionPlacement, ProbedMedia}; use opentake_domain::{GenerationInput, GenerationJobStatus}; use opentake_motion::{ - HeadlessChromiumRenderer, MotionCache, MotionCancellationToken, MotionError, - MotionRenderRequest, MotionSource, RenderedClip, SandboxPolicy, + limits, read_single_preview_png, HeadlessChromiumRenderer, MotionCache, + MotionCancellationToken, MotionDocumentSource, MotionError, MotionRenderRequest, MotionSource, + MotionSourceDiagnostic, RenderedClip, SandboxPolicy, }; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -38,10 +43,15 @@ pub struct TauriMotionBridge { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] +#[serde(tag = "phase", rename_all = "camelCase")] pub enum MotionProgress { Validating, - Rendering, + Rendering { + #[serde(rename = "doneFrames")] + done_frames: u32, + #[serde(rename = "totalFrames")] + total_frames: u32, + }, Encoding, Committing, Complete, @@ -50,15 +60,27 @@ pub enum MotionProgress { #[derive(Clone)] pub struct MotionCommandState { bridge: Arc, - active: Arc>>, + operations: Arc>, admission: crate::updater::InstallAdmissionGate, } +#[derive(Default)] +struct MotionOperations { + active_render: Option, + next_preview_generation: u64, + active_previews: BTreeMap, +} + struct ActiveMotionCommand { cancel: opentake_media::MediaCancelToken, _admission: crate::updater::ActivityLease, } +struct ActiveMotionPreview { + cancel: MotionCancellationToken, + _admission: crate::updater::ActivityLease, +} + impl MotionCommandState { pub(crate) fn new( bridge: Arc, @@ -66,22 +88,22 @@ impl MotionCommandState { ) -> Self { Self { bridge, - active: Arc::new(Mutex::new(None)), + operations: Arc::new(Mutex::new(MotionOperations::default())), admission, } } fn begin(&self) -> Result { let admission = self.admission.begin_activity()?; - let mut active = self - .active + let mut operations = self + .operations .lock() .map_err(|_| "motion command state is unavailable".to_string())?; - if active.is_some() { + if operations.active_render.is_some() || !operations.active_previews.is_empty() { return Err("another motion render is already running".into()); } let cancel = opentake_media::MediaCancelToken::new(); - *active = Some(ActiveMotionCommand { + operations.active_render = Some(ActiveMotionCommand { cancel: cancel.clone(), _admission: admission, }); @@ -89,27 +111,78 @@ impl MotionCommandState { } fn finish(&self) { - if let Ok(mut active) = self.active.lock() { - *active = None; + if let Ok(mut operations) = self.operations.lock() { + operations.active_render = None; } } - fn cancel(&self) -> bool { - self.active + fn begin_preview(&self) -> Result<(u64, MotionCancellationToken), String> { + let admission = self.admission.begin_activity()?; + let mut operations = self + .operations .lock() - .ok() - .and_then(|active| active.as_ref().map(|command| command.cancel.clone())) - .map(|cancel| { - cancel.cancel(); - true - }) - .unwrap_or(false) + .map_err(|_| "motion command state is unavailable".to_string())?; + if operations.active_render.is_some() { + return Err("another motion render is already running".into()); + } + for active in operations.active_previews.values() { + active.cancel.cancel(); + } + operations.next_preview_generation = operations.next_preview_generation.wrapping_add(1); + if operations.next_preview_generation == 0 { + operations.next_preview_generation = 1; + } + let generation = operations.next_preview_generation; + let cancel = MotionCancellationToken::new(); + operations.active_previews.insert( + generation, + ActiveMotionPreview { + cancel: cancel.clone(), + _admission: admission, + }, + ); + Ok((generation, cancel)) + } + + fn finish_preview(&self, generation: u64) { + if let Ok(mut operations) = self.operations.lock() { + operations.active_previews.remove(&generation); + } + } + + fn cancel(&self) -> bool { + let Ok(operations) = self.operations.lock() else { + return false; + }; + let mut cancelled = false; + if let Some(active) = &operations.active_render { + active.cancel.cancel(); + cancelled = true; + } + for active in operations.active_previews.values() { + active.cancel.cancel(); + cancelled = true; + } + cancelled + } + + fn cancel_previews(&self) -> bool { + let Ok(operations) = self.operations.lock() else { + return false; + }; + let cancelled = !operations.active_previews.is_empty(); + for active in operations.active_previews.values() { + active.cancel.cancel(); + } + cancelled } pub fn has_active(&self) -> bool { - self.active + self.operations .lock() - .map(|active| active.is_some()) + .map(|operations| { + operations.active_render.is_some() || !operations.active_previews.is_empty() + }) .unwrap_or(true) } @@ -126,10 +199,20 @@ pub struct MotionAddCommand { #[serde(default)] pub template_id: Option, #[serde(default)] + pub document_id: Option, + #[serde(default)] + pub revision_hash: Option, + #[serde(default)] pub params: Map, pub start_frame: i32, pub duration_frames: i32, #[serde(default)] + pub width: Option, + #[serde(default)] + pub height: Option, + #[serde(default)] + pub fps: Option, + #[serde(default)] pub transparent: bool, #[serde(default)] pub track_index: Option, @@ -143,6 +226,88 @@ pub struct MotionEditCommand { pub code: Option, #[serde(default)] pub params: Option>, + #[serde(default)] + pub document_id: Option, + #[serde(default)] + pub revision_hash: Option, + #[serde(default)] + pub duration_frames: Option, + #[serde(default)] + pub width: Option, + #[serde(default)] + pub height: Option, + #[serde(default)] + pub fps: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocumentMotionSource { + pub document_id: String, + pub revision_hash: String, + pub html: String, + pub css: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocumentMotionAddRequest { + pub source: DocumentMotionSource, + pub project_authority: ProjectAssetAuthority, + pub width: u32, + pub height: u32, + pub fps: u32, + pub start_frame: i32, + pub duration_frames: i32, + pub track_index: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocumentMotionEditRequest { + pub clip_id: String, + pub source: DocumentMotionSource, + pub project_authority: ProjectAssetAuthority, + pub width: u32, + pub height: u32, + pub fps: u32, + pub duration_frames: i32, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct MotionPreviewRequest { + pub document_id: String, + pub revision_hash: String, + pub width: u32, + pub height: u32, + pub fps: u32, + pub duration_frames: u32, + pub frame: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MotionPreviewDiagnostic { + pub severity: &'static str, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub line: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub column: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MotionPreviewResponse { + pub revision_hash: String, + pub frame: u32, + pub png_data_url: String, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MotionPreviewError { + pub message: String, + pub diagnostics: Vec, } #[tauri::command] @@ -150,19 +315,85 @@ pub fn motion_capability(state: State<'_, MotionCommandState>) -> bool { state.bridge.can_render_motion() } +#[tauri::command] +pub async fn motion_preview( + state: State<'_, MotionCommandState>, + documents: State<'_, Arc>, + request: MotionPreviewRequest, +) -> Result { + let authority = documents.capture_authority().map_err(|_| { + preview_error("Save the project before previewing a Motion Studio document.") + })?; + let (generation, cancellation) = state + .begin_preview() + .map_err(|message| preview_error(&message))?; + let bridge = Arc::clone(&state.bridge); + let documents = Arc::clone(documents.inner()); + let worker = tauri::async_runtime::spawn_blocking(move || { + render_document_preview(&bridge, &documents, authority, request, &cancellation) + }) + .await; + state.finish_preview(generation); + worker.map_err(|_| preview_error("Motion preview worker failed."))? +} + +#[tauri::command] +pub fn motion_preview_cancel(state: State<'_, MotionCommandState>) -> bool { + state.cancel_previews() +} + #[tauri::command] pub async fn motion_add( app: AppHandle, state: State<'_, MotionCommandState>, + documents: State<'_, Arc>, request: MotionAddCommand, ) -> Result { - let source = match (request.code, request.template_id) { - (Some(code), None) => MotionSourceRequest::Code(code), - (None, Some(template_id)) => MotionSourceRequest::Template { - template_id, - params: request.params, - }, - _ => return Err("provide exactly one of code or templateId".into()), + let document_request = match (request.document_id, request.revision_hash) { + (Some(document_id), Some(revision_hash)) => { + if request.code.is_some() || request.template_id.is_some() || !request.params.is_empty() + { + return Err( + "documentId/revisionHash cannot be combined with code, templateId, or params" + .into(), + ); + } + if request.transparent { + return Err("Motion Studio publishing currently requires opaque MP4 output".into()); + } + Some(( + documents.capture_authority()?, + document_id, + revision_hash, + request + .width + .ok_or_else(|| "document publish requires width".to_string())?, + request + .height + .ok_or_else(|| "document publish requires height".to_string())?, + request + .fps + .ok_or_else(|| "document publish requires fps".to_string())?, + )) + } + (None, None) => None, + _ => return Err("documentId and revisionHash must be provided together".into()), + }; + let legacy_source = if document_request.is_none() { + Some(match (request.code, request.template_id) { + (Some(code), None) => MotionSourceRequest::Code(code), + (None, Some(template_id)) => MotionSourceRequest::Template { + template_id, + params: request.params, + }, + _ => { + return Err( + "provide exactly one of code, templateId, or documentId/revisionHash".into(), + ) + } + }) + } else { + None }; let cancel = state.begin()?; let bridge = state @@ -172,17 +403,41 @@ pub async fn motion_add( .with_progress_callback(Arc::new(move |phase| { let _ = app.emit("motion_progress", phase); })); + let documents = Arc::clone(documents.inner()); let worker = tauri::async_runtime::spawn_blocking(move || { - bridge.add( - AddMotionRequest { - source, - start_frame: request.start_frame, - duration_frames: request.duration_frames, - transparent: request.transparent, - track_index: request.track_index, - }, - &cancel, - ) + if let Some((authority, document_id, revision_hash, width, height, fps)) = document_request + { + let source = resolve_document_motion_source( + &documents, + authority.clone(), + &document_id, + &revision_hash, + )?; + bridge.add_document( + DocumentMotionAddRequest { + source, + project_authority: authority, + width, + height, + fps, + start_frame: request.start_frame, + duration_frames: request.duration_frames, + track_index: request.track_index, + }, + &cancel, + ) + } else { + bridge.add( + AddMotionRequest { + source: legacy_source.expect("legacy source validated before worker"), + start_frame: request.start_frame, + duration_frames: request.duration_frames, + transparent: request.transparent, + track_index: request.track_index, + }, + &cancel, + ) + } }) .await; state.finish(); @@ -194,8 +449,37 @@ pub async fn motion_add( pub async fn motion_edit( app: AppHandle, state: State<'_, MotionCommandState>, + documents: State<'_, Arc>, request: MotionEditCommand, ) -> Result { + let document_request = match (request.document_id, request.revision_hash) { + (Some(document_id), Some(revision_hash)) => { + if request.code.is_some() || request.params.is_some() { + return Err( + "documentId/revisionHash cannot be combined with code or params".into(), + ); + } + Some(( + documents.capture_authority()?, + document_id, + revision_hash, + request + .width + .ok_or_else(|| "document edit requires width".to_string())?, + request + .height + .ok_or_else(|| "document edit requires height".to_string())?, + request + .fps + .ok_or_else(|| "document edit requires fps".to_string())?, + request + .duration_frames + .ok_or_else(|| "document edit requires durationFrames".to_string())?, + )) + } + (None, None) => None, + _ => return Err("documentId and revisionHash must be provided together".into()), + }; let cancel = state.begin()?; let bridge = state .bridge @@ -204,15 +488,39 @@ pub async fn motion_edit( .with_progress_callback(Arc::new(move |phase| { let _ = app.emit("motion_progress", phase); })); + let documents = Arc::clone(documents.inner()); let worker = tauri::async_runtime::spawn_blocking(move || { - bridge.edit( - EditMotionRequest { - clip_id: request.clip_id, - code: request.code, - params: request.params, - }, - &cancel, - ) + if let Some((authority, document_id, revision_hash, width, height, fps, duration_frames)) = + document_request + { + let source = resolve_document_motion_source( + &documents, + authority.clone(), + &document_id, + &revision_hash, + )?; + bridge.edit_document( + DocumentMotionEditRequest { + clip_id: request.clip_id, + source, + project_authority: authority, + width, + height, + fps, + duration_frames, + }, + &cancel, + ) + } else { + bridge.edit( + EditMotionRequest { + clip_id: request.clip_id, + code: request.code, + params: request.params, + }, + &cancel, + ) + } }) .await; state.finish(); @@ -225,6 +533,231 @@ pub fn motion_cancel(state: State<'_, MotionCommandState>) -> bool { state.cancel() } +fn render_document_preview( + bridge: &TauriMotionBridge, + documents: &crate::motion_documents::MotionDocumentStore, + authority: ProjectAssetAuthority, + request: MotionPreviewRequest, + cancellation: &MotionCancellationToken, +) -> Result { + let (render_request, revision_hash) = + prepare_document_preview(documents, authority.clone(), &request)?; + let rendered = bridge + .renderer + .render_with_cancellation(&render_request, cancellation) + .map_err(map_preview_motion_error)?; + finish_document_preview( + documents, + authority, + request.frame, + revision_hash, + rendered, + cancellation, + ) +} + +/// Agent preview adapter. The MCP transport and project lifecycle own a +/// MediaCancelToken, while Chromium uses MotionCancellationToken; this bridge +/// mirrors cancellation for the complete render and joins its short monitor +/// before returning. +pub(crate) fn render_document_preview_for_agent( + bridge: &TauriMotionBridge, + documents: &crate::motion_documents::MotionDocumentStore, + authority: ProjectAssetAuthority, + request: MotionPreviewRequest, + cancel: &opentake_media::MediaCancelToken, +) -> Result { + struct CompletionSignal(Arc); + impl Drop for CompletionSignal { + fn drop(&mut self) { + self.0.store(true, Ordering::Release); + } + } + + let cancellation = MotionCancellationToken::new(); + let complete = Arc::new(AtomicBool::new(false)); + let completion_signal = CompletionSignal(complete.clone()); + let monitor_complete = complete.clone(); + let monitor_cancel = cancel.clone(); + let monitor_motion = cancellation.clone(); + let monitor = std::thread::Builder::new() + .name("motion-document-preview-cancel".into()) + .spawn(move || { + while !monitor_complete.load(Ordering::Acquire) { + if monitor_cancel.is_cancelled() { + monitor_motion.cancel(); + return; + } + std::thread::sleep(Duration::from_millis(5)); + } + }) + .map_err(|_| preview_error("Motion preview cancellation could not be initialized."))?; + let result = render_document_preview(bridge, documents, authority, request, &cancellation); + drop(completion_signal); + let _ = monitor.join(); + if cancel.is_cancelled() { + Err(preview_error("Motion preview was cancelled.")) + } else { + result + } +} + +fn finish_document_preview( + documents: &crate::motion_documents::MotionDocumentStore, + authority: ProjectAssetAuthority, + frame: u32, + revision_hash: String, + rendered: RenderedClip, + cancellation: &MotionCancellationToken, +) -> Result { + ensure_preview_active(cancellation)?; + let png = read_single_preview_png(&rendered).map_err(map_preview_motion_error)?; + ensure_preview_active(cancellation)?; + documents + .ensure_authority(&authority) + .map_err(|_| preview_error("The project changed before the preview completed."))?; + ensure_preview_active(cancellation)?; + let png_data_url = format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(png) + ); + ensure_preview_active(cancellation)?; + Ok(MotionPreviewResponse { + revision_hash, + frame, + png_data_url, + diagnostics: Vec::new(), + }) +} + +fn ensure_preview_active(cancellation: &MotionCancellationToken) -> Result<(), MotionPreviewError> { + if cancellation.is_cancelled() { + Err(preview_error("Motion preview was cancelled.")) + } else { + Ok(()) + } +} + +fn prepare_document_preview( + documents: &crate::motion_documents::MotionDocumentStore, + authority: ProjectAssetAuthority, + request: &MotionPreviewRequest, +) -> Result<(MotionRenderRequest, String), MotionPreviewError> { + if request.duration_frames == 0 + || request.duration_frames > limits::MAX_FRAMES + || request.frame >= request.duration_frames + { + return Err(preview_error( + "Preview frame must be inside the bounded document duration.", + )); + } + let document = documents + .read_for_authority(authority, &request.document_id) + .map_err(|_| preview_error("Motion Studio document could not be read."))?; + if document.summary.revision_hash != request.revision_hash { + return Err(preview_error( + "Motion Studio document changed; reload before previewing.", + )); + } + let source = MotionDocumentSource::new(document.html, document.css) + .inline_document() + .map_err(preview_source_error)?; + let render_request = MotionRenderRequest::new( + MotionSource::code(source), + request.fps, + 1, + request.width, + request.height, + ) + .with_start_frame(request.frame) + .with_transparent(true); + render_request + .validate() + .map_err(map_preview_motion_error)?; + Ok((render_request, document.summary.revision_hash)) +} + +pub(crate) fn resolve_document_motion_source( + documents: &crate::motion_documents::MotionDocumentStore, + authority: ProjectAssetAuthority, + document_id: &str, + revision_hash: &str, +) -> Result { + let document = documents + .read_for_authority(authority, document_id) + .map_err(|_| { + MotionBridgeError::new( + MotionBridgeErrorKind::ResourceNotFound, + "Motion Studio document could not be read", + ) + })?; + if document.summary.revision_hash != revision_hash { + return Err(MotionBridgeError::new( + MotionBridgeErrorKind::InvalidArguments, + "Motion Studio document changed; reload before publishing", + )); + } + MotionDocumentSource::new(document.html.clone(), document.css.clone()) + .inline_document() + .map_err(|diagnostic| { + MotionBridgeError::new( + MotionBridgeErrorKind::InvalidArguments, + format!( + "Motion Studio source is invalid at {}:{}: {}", + diagnostic.line, diagnostic.column, diagnostic.message + ), + ) + })?; + Ok(DocumentMotionSource { + document_id: document.summary.id, + revision_hash: document.summary.revision_hash, + html: document.html, + css: document.css, + }) +} + +fn preview_source_error(error: MotionSourceDiagnostic) -> MotionPreviewError { + MotionPreviewError { + message: "Motion Studio source contains unsupported active content.".to_string(), + diagnostics: vec![MotionPreviewDiagnostic { + severity: "error", + message: error.message, + line: Some(error.line), + column: Some(error.column), + }], + } +} + +fn map_preview_motion_error(error: MotionError) -> MotionPreviewError { + match error { + MotionError::Cancelled => preview_error("Motion preview was cancelled."), + MotionError::RendererUnavailable(_) => { + preview_error("Motion preview requires the packaged Chromium renderer.") + } + MotionError::InvalidSource(_) + | MotionError::InvalidRequest(_) + | MotionError::UnknownTemplate(_) + | MotionError::Manifest(_) + | MotionError::Sandbox(_) => preview_error("Motion preview request is invalid."), + MotionError::Timeout(_) => preview_error("Motion preview exceeded its time budget."), + MotionError::RenderFailed(_) | MotionError::Io(_) => { + preview_error("Motion preview could not be rendered.") + } + } +} + +fn preview_error(message: &str) -> MotionPreviewError { + MotionPreviewError { + message: message.to_string(), + diagnostics: vec![MotionPreviewDiagnostic { + severity: "error", + message: message.to_string(), + line: None, + column: None, + }], + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "camelCase")] enum StoredMotionSource { @@ -236,6 +769,20 @@ enum StoredMotionSource { #[serde(default)] params: Map, }, + Document { + document_id: String, + revision_hash: String, + }, +} + +struct PreparedMotionCommit { + stored_source: StoredMotionSource, + document_source: Option, + expected_authority: Option, + duration_frames: i32, + transparent: bool, + render_dimensions: Option<(u32, u32, u32)>, + placement: MotionPlacement, } impl TauriMotionBridge { @@ -262,11 +809,135 @@ impl TauriMotionBridge { self } + pub fn add_document( + &self, + request: DocumentMotionAddRequest, + cancel: &opentake_media::MediaCancelToken, + ) -> Result { + validate_document_source_identity(&request.source)?; + validate_document_render_dimensions(request.width, request.height)?; + if request.start_frame < 0 { + return Err(MotionBridgeError::new( + MotionBridgeErrorKind::InvalidArguments, + "startFrame must be non-negative", + )); + } + let snapshot = self.core.runtime_snapshot(); + let timeline_duration_frames = + timeline_duration_frames(request.duration_frames, request.fps, snapshot.timeline.fps)?; + if let Some(track_index) = request.track_index { + let Some(track) = snapshot.timeline.tracks.get(track_index) else { + return Err(MotionBridgeError::new( + MotionBridgeErrorKind::InvalidArguments, + "trackIndex is out of range", + )); + }; + if track.kind == opentake_domain::ClipType::Audio { + return Err(MotionBridgeError::new( + MotionBridgeErrorKind::InvalidArguments, + "motion graphics require a visual track", + )); + } + } + let stored = StoredMotionSource::Document { + document_id: request.source.document_id.clone(), + revision_hash: request.source.revision_hash.clone(), + }; + self.commit( + PreparedMotionCommit { + stored_source: stored, + document_source: Some(request.source), + expected_authority: Some(request.project_authority), + duration_frames: request.duration_frames, + transparent: false, + render_dimensions: Some((request.width, request.height, request.fps)), + placement: MotionPlacement::Add { + start_frame: request.start_frame, + duration_frames: timeline_duration_frames, + track_index: request.track_index, + }, + }, + cancel, + ) + } + + pub fn edit_document( + &self, + request: DocumentMotionEditRequest, + cancel: &opentake_media::MediaCancelToken, + ) -> Result { + validate_document_source_identity(&request.source)?; + validate_document_render_dimensions(request.width, request.height)?; + let snapshot = self.core.runtime_snapshot(); + let clip = snapshot + .timeline + .tracks + .iter() + .flat_map(|track| &track.clips) + .find(|clip| clip.id == request.clip_id) + .ok_or_else(|| { + MotionBridgeError::new( + MotionBridgeErrorKind::ResourceNotFound, + "motion clip was not found", + ) + })?; + let entry = snapshot + .media + .entries + .iter() + .find(|entry| entry.id == clip.media_ref) + .ok_or_else(|| { + MotionBridgeError::new( + MotionBridgeErrorKind::ResourceNotFound, + "motion media was not found", + ) + })?; + let timeline_duration_frames = + timeline_duration_frames(request.duration_frames, request.fps, snapshot.timeline.fps)?; + if timeline_duration_frames != clip.duration_frames { + return Err(MotionBridgeError::new( + MotionBridgeErrorKind::InvalidArguments, + "Motion Studio replacement duration must match the existing clip", + )); + } + if entry + .generation_input + .as_ref() + .filter(|input| input.provider.as_deref() == Some(MOTION_PROVIDER)) + .is_none() + { + return Err(MotionBridgeError::new( + MotionBridgeErrorKind::InvalidArguments, + "the selected clip is not an OpenTake motion graphic", + )); + } + let stored = StoredMotionSource::Document { + document_id: request.source.document_id.clone(), + revision_hash: request.source.revision_hash.clone(), + }; + self.commit( + PreparedMotionCommit { + stored_source: stored, + document_source: Some(request.source), + expected_authority: Some(request.project_authority), + duration_frames: request.duration_frames, + transparent: false, + render_dimensions: Some((request.width, request.height, request.fps)), + placement: MotionPlacement::Replace { + clip_id: request.clip_id, + }, + }, + cancel, + ) + } + fn render_and_encode( &self, stored_source: &StoredMotionSource, + document_source: Option<&DocumentMotionSource>, duration_frames: i32, transparent: bool, + render_dimensions: Option<(u32, u32, u32)>, cancel: &opentake_media::MediaCancelToken, ) -> Result<(tempfile::TempDir, std::path::PathBuf, ProbedMedia, String), MotionBridgeError> { @@ -290,29 +961,38 @@ impl TauriMotionBridge { "durationFrames must be at least 1", )); } - let fps = u32::try_from(snapshot.timeline.fps.max(1)).unwrap_or(30); - let width = u32::try_from(snapshot.timeline.width.max(2)).map_err(|_| { - MotionBridgeError::new( - MotionBridgeErrorKind::InvalidArguments, - "timeline width is invalid", + let (width, height, fps) = render_dimensions.unwrap_or_else(|| { + ( + u32::try_from(snapshot.timeline.width.max(2)).unwrap_or(2), + u32::try_from(snapshot.timeline.height.max(2)).unwrap_or(2), + u32::try_from(snapshot.timeline.fps.max(1)).unwrap_or(30), ) - })?; - let height = u32::try_from(snapshot.timeline.height.max(2)).map_err(|_| { - MotionBridgeError::new( - MotionBridgeErrorKind::InvalidArguments, - "timeline height is invalid", - ) - })?; + }); let frames = u32::try_from(duration_frames).map_err(|_| { MotionBridgeError::new( MotionBridgeErrorKind::InvalidArguments, "durationFrames is invalid", ) })?; - let html = source_document(stored_source, fps, width, height, frames)?; + let html = if let Some(document) = document_source { + MotionDocumentSource::new(document.html.clone(), document.css.clone()) + .inline_document() + .map_err(|diagnostic| { + MotionBridgeError::new( + MotionBridgeErrorKind::InvalidArguments, + format!( + "Motion Studio source is invalid at {}:{}: {}", + diagnostic.line, diagnostic.column, diagnostic.message + ), + ) + })? + } else { + source_document(stored_source, fps, width, height, frames)? + }; let request = MotionRenderRequest::new(MotionSource::code(html), fps, frames, width, height) .with_transparent(false); + request.validate().map_err(map_motion_error)?; let render_cancel = MotionCancellationToken::new(); if cancel.is_cancelled() { render_cancel.cancel(); @@ -330,10 +1010,23 @@ impl TauriMotionBridge { std::thread::sleep(Duration::from_millis(10)); } }); - (self.progress)(MotionProgress::Rendering); + (self.progress)(MotionProgress::Rendering { + done_frames: 0, + total_frames: frames, + }); + let progress = Arc::clone(&self.progress); let rendered = self .renderer - .render_with_cancellation(&request, &render_cancel) + .render_with_cancellation_and_progress( + &request, + &render_cancel, + &move |done_frames, total_frames| { + progress(MotionProgress::Rendering { + done_frames, + total_frames, + }); + }, + ) .map_err(map_motion_error); done.store(true, Ordering::Release); let _ = monitor.join(); @@ -384,27 +1077,53 @@ impl TauriMotionBridge { fn commit( &self, - stored_source: StoredMotionSource, - duration_frames: i32, - transparent: bool, - placement: MotionPlacement, + request: PreparedMotionCommit, cancel: &opentake_media::MediaCancelToken, ) -> Result { + let PreparedMotionCommit { + stored_source, + document_source, + expected_authority, + duration_frames, + transparent, + render_dimensions, + placement, + } = request; let snapshot = self.core.runtime_snapshot(); + if expected_authority.as_ref().is_some_and(|authority| { + snapshot.project_epoch != authority.project_epoch + || snapshot.project_dir.as_ref() != Some(&authority.project_path) + || !self.core.project_asset_authority_matches(authority) + }) { + return Err(MotionBridgeError::new( + MotionBridgeErrorKind::RenderFailed, + "project changed before Motion Studio publishing began", + )); + } let project_dir = snapshot.project_dir.clone().ok_or_else(|| { MotionBridgeError::new( MotionBridgeErrorKind::InvalidArguments, "Save the project before rendering a motion graphic.", ) })?; - let (_temporary_output, output, probe, content_hash) = - self.render_and_encode(&stored_source, duration_frames, transparent, cancel)?; + ensure_motion_active(cancel)?; + let (_temporary_output, output, probe, content_hash) = self.render_and_encode( + &stored_source, + document_source.as_ref(), + duration_frames, + transparent, + render_dimensions, + cancel, + )?; let motion_canvas = matches!( &stored_source, StoredMotionSource::Template { template_id, .. } if template_id == "title-card" ); + let motion_document = matches!(&stored_source, StoredMotionSource::Document { .. }); let output_metadata = MotionOutputMetadata { - renderer: if motion_canvas { + renderer: if motion_document { + "opentake-motion-studio".into() + } else if motion_canvas { "motion-canvas".into() } else { "opentake-html-fallback".into() @@ -444,6 +1163,48 @@ impl TauriMotionBridge { &std::fs::read(&result_path).map_err(io_motion_error)?, &output_metadata, )?; + ensure_motion_active(cancel)?; + if expected_authority + .as_ref() + .is_some_and(|authority| !self.core.project_asset_authority_matches(authority)) + { + return Err(MotionBridgeError::new( + MotionBridgeErrorKind::RenderFailed, + "project changed before Motion Studio publishing completed", + )); + } + let source_json = serde_json::to_string(&stored_source).map_err(|_| { + MotionBridgeError::new( + MotionBridgeErrorKind::InvalidArguments, + "motion source could not be persisted", + ) + })?; + let provenance = GenerationInput { + prompt: source_json, + model: MOTION_MODEL.into(), + duration: duration_frames, + aspect_ratio: format!( + "{}:{}", + probe.width.unwrap_or(snapshot.timeline.width), + probe.height.unwrap_or(snapshot.timeline.height) + ), + provider: Some(MOTION_PROVIDER.into()), + status: Some(GenerationJobStatus::Ready), + ..GenerationInput::default() + }; + (self.progress)(MotionProgress::Committing); + let publication = self.core.lock_project_bundle_publication(); + let identity = self.core.lock_project_identity_workflow(); + ensure_motion_active(cancel)?; + if expected_authority + .as_ref() + .is_some_and(|authority| !self.core.project_asset_authority_matches(authority)) + { + return Err(MotionBridgeError::new( + MotionBridgeErrorKind::RenderFailed, + "project changed before Motion Studio publishing committed", + )); + } let project_media = crate::library::ProjectMediaCapability::open_verified( &self.core, snapshot.project_epoch, @@ -468,23 +1229,22 @@ impl TauriMotionBridge { "motion output identity changed before project commit", )); } - let source_json = serde_json::to_string(&stored_source).map_err(|_| { - MotionBridgeError::new( - MotionBridgeErrorKind::InvalidArguments, - "motion source could not be persisted", - ) - })?; - let provenance = GenerationInput { - prompt: source_json, - model: MOTION_MODEL.into(), - duration: duration_frames, - aspect_ratio: format!("{}:{}", snapshot.timeline.width, snapshot.timeline.height), - provider: Some(MOTION_PROVIDER.into()), - status: Some(GenerationJobStatus::Ready), - ..GenerationInput::default() - }; - (self.progress)(MotionProgress::Committing); - let committed = self.core.commit_motion_media_for_project( + project_media + .sync_media_directory() + .map_err(|error| MotionBridgeError::new(MotionBridgeErrorKind::RenderFailed, error))?; + ensure_motion_active(cancel)?; + if expected_authority + .as_ref() + .is_some_and(|authority| !self.core.project_asset_authority_matches(authority)) + { + return Err(MotionBridgeError::new( + MotionBridgeErrorKind::RenderFailed, + "project changed before Motion Studio publishing committed", + )); + } + let mut events = DeferredCoreEvents::default(); + let committed = self.core.commit_motion_media_for_project_deferred( + &publication, snapshot.project_epoch, snapshot.version, &project_dir, @@ -493,6 +1253,7 @@ impl TauriMotionBridge { &probe, provenance, placement, + &mut events, ); let committed = match committed { Ok(committed) => committed, @@ -504,6 +1265,9 @@ impl TauriMotionBridge { } }; published.commit(); + drop(identity); + drop(publication); + self.core.emit_deferred(events); let clip_id = committed .edit .affected_clip_ids @@ -522,10 +1286,92 @@ impl TauriMotionBridge { content_hash, action_name: committed.edit.action_name, output: output_metadata, + source_document: match stored_source { + StoredMotionSource::Document { + document_id, + revision_hash, + } => Some(MotionDocumentReference { + document_id, + revision_hash, + }), + _ => None, + }, }) } } +fn validate_document_source_identity( + source: &DocumentMotionSource, +) -> Result<(), MotionBridgeError> { + if source.document_id.is_empty() + || source.document_id.len() > 128 + || !source + .document_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) + { + return Err(MotionBridgeError::new( + MotionBridgeErrorKind::InvalidArguments, + "Motion Studio document id is invalid", + )); + } + if source.revision_hash.len() != 64 + || !source + .revision_hash + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + return Err(MotionBridgeError::new( + MotionBridgeErrorKind::InvalidArguments, + "Motion Studio revision hash is invalid", + )); + } + Ok(()) +} + +fn validate_document_render_dimensions(width: u32, height: u32) -> Result<(), MotionBridgeError> { + if !width.is_multiple_of(2) || !height.is_multiple_of(2) { + return Err(MotionBridgeError::new( + MotionBridgeErrorKind::InvalidArguments, + "Motion Studio MP4 dimensions must be even numbers", + )); + } + Ok(()) +} + +fn timeline_duration_frames( + source_frames: i32, + source_fps: u32, + timeline_fps: i32, +) -> Result { + if source_frames < 1 || source_fps == 0 || timeline_fps < 1 { + return Err(MotionBridgeError::new( + MotionBridgeErrorKind::InvalidArguments, + "Motion Studio duration and frame rates must be positive", + )); + } + let numerator = u64::try_from(source_frames) + .ok() + .and_then(|frames| frames.checked_mul(u64::try_from(timeline_fps).ok()?)) + .ok_or_else(|| { + MotionBridgeError::new( + MotionBridgeErrorKind::InvalidArguments, + "Motion Studio timeline duration is out of range", + ) + })?; + let rounded = numerator + .checked_add(u64::from(source_fps) / 2) + .map(|value| value / u64::from(source_fps)) + .and_then(|frames| i32::try_from(frames.max(1)).ok()) + .ok_or_else(|| { + MotionBridgeError::new( + MotionBridgeErrorKind::InvalidArguments, + "Motion Studio timeline duration is out of range", + ) + })?; + Ok(rounded) +} + impl MotionBridge for TauriMotionBridge { fn can_render_motion(&self) -> bool { HeadlessChromiumRenderer::find_browser().is_some() @@ -570,13 +1416,18 @@ impl MotionBridge for TauriMotionBridge { }, }; self.commit( - stored_source, - request.duration_frames, - request.transparent, - MotionPlacement::Add { - start_frame: request.start_frame, + PreparedMotionCommit { + stored_source, + document_source: None, + expected_authority: None, duration_frames: request.duration_frames, - track_index: request.track_index, + transparent: request.transparent, + render_dimensions: None, + placement: MotionPlacement::Add { + start_frame: request.start_frame, + duration_frames: request.duration_frames, + track_index: request.track_index, + }, }, cancel, ) @@ -657,13 +1508,24 @@ impl MotionBridge for TauriMotionBridge { "code-authored motion edits do not accept template params", )); } + (StoredMotionSource::Document { .. }, _, _) => { + return Err(MotionBridgeError::new( + MotionBridgeErrorKind::InvalidArguments, + "Motion Studio clips must be edited from an exact document revision", + )); + } } self.commit( - source, - clip.duration_frames, - false, - MotionPlacement::Replace { - clip_id: request.clip_id, + PreparedMotionCommit { + stored_source: source, + document_source: None, + expected_authority: None, + duration_frames: clip.duration_frames, + transparent: false, + render_dimensions: None, + placement: MotionPlacement::Replace { + clip_id: request.clip_id, + }, }, cancel, ) @@ -698,6 +1560,23 @@ fn source_document( template_id, params, } => template_document(template_id, params, fps, width, height, frames), + StoredMotionSource::Document { .. } => Err(MotionBridgeError::new( + MotionBridgeErrorKind::InvalidArguments, + "Motion Studio document source was not resolved", + )), + } +} + +fn ensure_motion_active( + cancel: &opentake_media::MediaCancelToken, +) -> Result<(), MotionBridgeError> { + if cancel.is_cancelled() { + Err(MotionBridgeError::new( + MotionBridgeErrorKind::Cancelled, + "motion render cancelled", + )) + } else { + Ok(()) } } @@ -923,6 +1802,26 @@ fn io_motion_error(error: std::io::Error) -> MotionBridgeError { mod tests { use super::*; + fn saved_document() -> ( + tempfile::TempDir, + AppCore, + crate::motion_documents::MotionDocumentStore, + crate::motion_documents::MotionDocument, + ) { + let temp = tempfile::tempdir().expect("create preview fixture parent"); + let project = temp.path().join("preview.opentake"); + let core = AppCore::new(); + core.save_project(Some(project)) + .expect("save preview fixture project"); + let store = crate::motion_documents::MotionDocumentStore::new(core.clone()); + let document = store + .create(crate::motion_documents::MotionDocumentCreateRequest { + title: Some("片头预览".to_string()), + }) + .expect("create preview fixture document"); + (temp, core, store, document) + } + fn output_metadata() -> MotionOutputMetadata { MotionOutputMetadata { renderer: "motion-canvas".into(), @@ -988,4 +1887,260 @@ mod tests { "app update installation is in progress" ); } + + #[test] + fn preview_preparation_is_bound_to_the_exact_document_revision_and_frame() { + let (_temp, _core, store, document) = saved_document(); + let authority = store + .capture_authority() + .expect("capture project authority"); + let request = MotionPreviewRequest { + document_id: document.summary.id.clone(), + revision_hash: document.summary.revision_hash.clone(), + width: 640, + height: 360, + fps: 30, + duration_frames: 90, + frame: 42, + }; + + let (render, revision) = + prepare_document_preview(&store, authority.clone(), &request).unwrap(); + assert_eq!(revision, document.summary.revision_hash); + assert_eq!(render.start_frame, 42); + assert_eq!(render.duration_frames, 1); + assert_eq!((render.width, render.height, render.fps), (640, 360, 30)); + let MotionSource::Code { html_css_js } = render.source else { + panic!("document preview must compile to a self-contained source"); + }; + assert!(html_css_js.contains("让创意动起来")); + assert!(html_css_js.contains("@keyframes")); + assert!(html_css_js.contains("script-src 'none'")); + assert!(!html_css_js.contains(", +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct MotionDocumentCreateRequest { + #[serde(default)] + pub title: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct MotionTextReplacement { + /// UTF-8 byte offset into the selected source file. + pub start: usize, + /// UTF-8 byte offset into the selected source file. + pub end: usize, + pub replacement: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct MotionDocumentPatchRequest { + pub document_id: String, + pub file: String, + pub baseline_hash: String, + #[serde(default)] + pub edits: Vec, + pub expected_result_hash: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct MotionDocumentHashRequest { + pub document_id: String, + pub file: String, + pub baseline_hash: String, + #[serde(default)] + pub edits: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct MotionCatalog { + schema_version: u32, + #[serde(default)] + documents: BTreeMap, +} + +impl Default for MotionCatalog { + fn default() -> Self { + Self { + schema_version: CATALOG_SCHEMA_VERSION, + documents: BTreeMap::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CatalogEntry { + directory: String, + summary: MotionDocumentSummary, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DocumentManifest { + schema_version: u32, + summary: MotionDocumentSummary, + #[serde(default)] + parameters: BTreeMap, +} + +pub struct MotionDocumentStore { + core: AppCore, + operation: Mutex<()>, + #[cfg(test)] + fail_next_catalog_replace: AtomicBool, + #[cfg(test)] + fail_next_catalog_sync: AtomicBool, +} + +struct AuthorizedProjectRoot { + root: Dir, + identity: Handle, + authority: ProjectAssetAuthority, +} + +#[derive(Clone, Copy)] +enum EditableFile { + Html, + Css, +} + +impl EditableFile { + fn parse(file: &str) -> Result { + match file { + HTML_FILE => Ok(Self::Html), + CSS_FILE => Ok(Self::Css), + _ => Err("editable file must be exactly index.html or styles.css".into()), + } + } +} + +impl MotionDocumentStore { + pub fn new(core: AppCore) -> Self { + Self { + core, + operation: Mutex::new(()), + #[cfg(test)] + fail_next_catalog_replace: AtomicBool::new(false), + #[cfg(test)] + fail_next_catalog_sync: AtomicBool::new(false), + } + } + + pub fn capture_authority(&self) -> Result { + self.core + .project_asset_authority() + .ok_or_else(|| "save the project before editing Motion Studio documents".to_string()) + } + + /// Synchronous embedding API. Tauri commands use the admitted-authority + /// variant so queueing cannot move a request into a replacement project. + #[allow(dead_code)] + pub fn list(&self) -> Result, String> { + let authority = self.capture_authority()?; + self.list_for_authority(authority) + } + + pub(crate) fn list_for_authority( + &self, + authority: ProjectAssetAuthority, + ) -> Result, String> { + let _operation = self.lock_operation()?; + let _bundle_publication = self.core.lock_project_bundle_publication(); + let _identity_lease = self.core.lock_project_identity_workflow(); + let project = AuthorizedProjectRoot::open_expected(&self.core, authority)?; + let Some(root) = motion_root(&project.root, false)? else { + project.ensure_current(&self.core)?; + return Ok(Vec::new()); + }; + let catalog = read_catalog(&root)?; + project.ensure_current(&self.core)?; + let mut summaries = catalog + .documents + .into_values() + .map(|entry| entry.summary) + .collect::>(); + summaries.sort_by(|left, right| { + right + .updated_at + .cmp(&left.updated_at) + .then_with(|| left.id.cmp(&right.id)) + }); + Ok(summaries) + } + + /// Synchronous embedding API; see [`Self::list`]. + #[allow(dead_code)] + pub fn create(&self, request: MotionDocumentCreateRequest) -> Result { + let authority = self.capture_authority()?; + self.create_for_authority(authority, request) + } + + pub(crate) fn create_for_authority( + &self, + authority: ProjectAssetAuthority, + request: MotionDocumentCreateRequest, + ) -> Result { + self.create_for_authority_inner(authority, request, None) + } + + pub(crate) fn create_for_authority_cancellable( + &self, + authority: ProjectAssetAuthority, + request: MotionDocumentCreateRequest, + cancel: &opentake_media::MediaCancelToken, + ) -> Result { + self.create_for_authority_inner(authority, request, Some(cancel)) + } + + fn create_for_authority_inner( + &self, + authority: ProjectAssetAuthority, + request: MotionDocumentCreateRequest, + cancel: Option<&opentake_media::MediaCancelToken>, + ) -> Result { + let _operation = self.lock_operation()?; + let _bundle_publication = self.core.lock_project_bundle_publication(); + let _identity_lease = self.core.lock_project_identity_workflow(); + let project = AuthorizedProjectRoot::open_expected(&self.core, authority)?; + let root = motion_root(&project.root, true)?.expect("create=true returns a root"); + let mut catalog = read_catalog(&root)?; + if catalog.documents.len() >= MAX_DOCUMENTS { + return Err("motion document limit reached".into()); + } + let title = validated_title(request.title.as_deref().unwrap_or("Untitled Motion"))?; + let id = uuid::Uuid::new_v4().to_string(); + let parameters = BTreeMap::new(); + let document = document_with_content(id, title, STARTER_HTML, STARTER_CSS, parameters)?; + let directory = write_revision_directory(&root, &document)?; + catalog.documents.insert( + document.summary.id.clone(), + CatalogEntry { + directory: directory.clone(), + summary: document.summary.clone(), + }, + ); + project.ensure_current(&self.core)?; + if cancel.is_some_and(opentake_media::MediaCancelToken::is_cancelled) { + cleanup_revision_directory(&root, &directory); + return Err("motion document create was cancelled".into()); + } + if let Err(error) = self.write_catalog(&root, &catalog) { + if !error.committed { + cleanup_revision_directory(&root, &directory); + } + return Err(error.message); + } + Ok(document) + } + + /// Synchronous embedding API; see [`Self::list`]. + #[allow(dead_code)] + pub fn read(&self, document_id: &str) -> Result { + let authority = self.capture_authority()?; + self.read_for_authority(authority, document_id) + } + + pub(crate) fn read_for_authority( + &self, + authority: ProjectAssetAuthority, + document_id: &str, + ) -> Result { + validate_document_id(document_id)?; + let _operation = self.lock_operation()?; + let _bundle_publication = self.core.lock_project_bundle_publication(); + let _identity_lease = self.core.lock_project_identity_workflow(); + let project = AuthorizedProjectRoot::open_expected(&self.core, authority)?; + let root = motion_root(&project.root, false)? + .ok_or_else(|| "motion document was not found".to_string())?; + let catalog = read_catalog(&root)?; + let entry = catalog + .documents + .get(document_id) + .ok_or_else(|| "motion document was not found".to_string())?; + let document = read_document(&root, entry)?; + project.ensure_current(&self.core)?; + Ok(document) + } + + pub(crate) fn ensure_authority(&self, authority: &ProjectAssetAuthority) -> Result<(), String> { + if self.core.project_asset_authority_matches(authority) { + Ok(()) + } else { + Err("current project changed before document result".to_string()) + } + } + + /// Computes the exact prospective revision using the stored parameter JSON. + /// The renderer boundary must not reconstruct serde JSON bytes in JavaScript. + #[allow(dead_code)] + pub fn hash_patch(&self, request: MotionDocumentHashRequest) -> Result { + let authority = self.capture_authority()?; + self.hash_patch_for_authority(authority, request) + } + + pub(crate) fn hash_patch_for_authority( + &self, + authority: ProjectAssetAuthority, + request: MotionDocumentHashRequest, + ) -> Result { + validate_document_id(&request.document_id)?; + let _operation = self.lock_operation()?; + let _bundle_publication = self.core.lock_project_bundle_publication(); + let _identity_lease = self.core.lock_project_identity_workflow(); + let project = AuthorizedProjectRoot::open_expected(&self.core, authority)?; + let root = motion_root(&project.root, false)? + .ok_or_else(|| "motion document was not found".to_string())?; + let catalog = read_catalog(&root)?; + let current_entry = catalog + .documents + .get(&request.document_id) + .ok_or_else(|| "motion document was not found".to_string())?; + let current = read_document(&root, current_entry)?; + if current.summary.revision_hash != request.baseline_hash { + return Err("motion document revision conflict".into()); + } + let (html, css) = prospective_sources(¤t, &request.file, request.edits)?; + project.ensure_current(&self.core)?; + revision_hash(&html, &css, ¤t.parameters) + } + + /// Synchronous embedding API; see [`Self::list`]. + #[allow(dead_code)] + pub fn save_patch( + &self, + request: MotionDocumentPatchRequest, + ) -> Result { + let authority = self.capture_authority()?; + self.save_patch_for_authority(authority, request) + } + + pub(crate) fn save_patch_for_authority( + &self, + authority: ProjectAssetAuthority, + request: MotionDocumentPatchRequest, + ) -> Result { + self.save_patch_for_authority_inner(authority, request, None) + } + + pub(crate) fn save_patch_for_authority_cancellable( + &self, + authority: ProjectAssetAuthority, + request: MotionDocumentPatchRequest, + cancel: &opentake_media::MediaCancelToken, + ) -> Result { + self.save_patch_for_authority_inner(authority, request, Some(cancel)) + } + + fn save_patch_for_authority_inner( + &self, + authority: ProjectAssetAuthority, + request: MotionDocumentPatchRequest, + cancel: Option<&opentake_media::MediaCancelToken>, + ) -> Result { + validate_document_id(&request.document_id)?; + let _operation = self.lock_operation()?; + let _bundle_publication = self.core.lock_project_bundle_publication(); + let _identity_lease = self.core.lock_project_identity_workflow(); + let project = AuthorizedProjectRoot::open_expected(&self.core, authority)?; + let root = motion_root(&project.root, false)? + .ok_or_else(|| "motion document was not found".to_string())?; + let mut catalog = read_catalog(&root)?; + let current_entry = catalog + .documents + .get(&request.document_id) + .cloned() + .ok_or_else(|| "motion document was not found".to_string())?; + let current = read_document(&root, ¤t_entry)?; + if current.summary.revision_hash != request.baseline_hash { + return Err("motion document revision conflict".into()); + } + + let (html, css) = prospective_sources(¤t, &request.file, request.edits)?; + let computed = revision_hash(&html, &css, ¤t.parameters)?; + if computed != request.expected_result_hash { + return Err("motion document expected result hash did not match".into()); + } + let next = document_with_content( + current.summary.id.clone(), + current.summary.title.clone(), + &html, + &css, + current.parameters.clone(), + )?; + if next.summary.revision_hash == current.summary.revision_hash { + return Ok(current); + } + let directory = write_revision_directory(&root, &next)?; + catalog.documents.insert( + next.summary.id.clone(), + CatalogEntry { + directory: directory.clone(), + summary: next.summary.clone(), + }, + ); + project.ensure_current(&self.core)?; + if cancel.is_some_and(opentake_media::MediaCancelToken::is_cancelled) { + cleanup_revision_directory(&root, &directory); + return Err("motion document patch was cancelled".into()); + } + if let Err(error) = self.write_catalog(&root, &catalog) { + if !error.committed { + cleanup_revision_directory(&root, &directory); + } + return Err(error.message); + } + cleanup_revision_directory(&root, ¤t_entry.directory); + Ok(next) + } + + fn lock_operation(&self) -> Result, String> { + self.operation + .lock() + .map_err(|_| "motion document store is unavailable".to_string()) + } + + fn write_catalog(&self, root: &Dir, catalog: &MotionCatalog) -> Result<(), CatalogWriteError> { + let bytes = serde_json::to_vec_pretty(catalog).map_err(|_| CatalogWriteError { + message: "motion document catalog could not be encoded".to_string(), + committed: false, + })?; + if bytes.len() > MAX_CATALOG_BYTES { + return Err(CatalogWriteError { + message: "motion document catalog exceeds its byte limit".into(), + committed: false, + }); + } + #[cfg(test)] + let inject_replace_failure = self.fail_next_catalog_replace.swap(false, Ordering::SeqCst); + #[cfg(not(test))] + let inject_replace_failure = false; + #[cfg(test)] + let inject_sync_failure = self.fail_next_catalog_sync.swap(false, Ordering::SeqCst); + #[cfg(not(test))] + let inject_sync_failure = false; + write_catalog_atomic(root, &bytes, inject_replace_failure, inject_sync_failure) + } + + #[cfg(test)] + fn fail_next_catalog_replace_for_test(&self) { + self.fail_next_catalog_replace.store(true, Ordering::SeqCst); + } + + #[cfg(test)] + fn fail_next_catalog_sync_for_test(&self) { + self.fail_next_catalog_sync.store(true, Ordering::SeqCst); + } +} + +impl AuthorizedProjectRoot { + fn open_expected(core: &AppCore, authority: ProjectAssetAuthority) -> Result { + if !core.project_asset_authority_matches(&authority) { + return Err("current project changed before document access".to_string()); + } + let path = &authority.project_path; + let name = path + .file_name() + .ok_or_else(|| "current project path has no bundle name".to_string())?; + let parent_path = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let parent = Dir::open_ambient_dir(parent_path, ambient_authority()) + .map_err(|_| "current project parent could not be opened".to_string())?; + let metadata = parent + .symlink_metadata(name) + .map_err(|_| "current project bundle could not be inspected".to_string())?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err("current project must be a no-follow directory".into()); + } + let root = parent + .open_dir_nofollow(name) + .map_err(|_| "current project must be a no-follow directory".to_string())?; + let identity = Handle::from_file( + root.try_clone() + .map_err(|_| "current project authority could not be cloned".to_string())? + .into_std_file(), + ) + .map_err(|_| "current project identity could not be retained".to_string())?; + core.ensure_project_root_identity_for_project( + authority.project_epoch, + &authority.project_path, + &identity, + ) + .map_err(|_| "current project authority changed before document access".to_string())?; + Ok(Self { + root, + identity, + authority, + }) + } + + fn ensure_current(&self, core: &AppCore) -> Result<(), String> { + core.ensure_project_root_identity_for_project( + self.authority.project_epoch, + &self.authority.project_path, + &self.identity, + ) + .map_err(|_| "current project changed before document commit".to_string()) + } +} + +fn motion_root(project: &Dir, create: bool) -> Result, String> { + match project.symlink_metadata(MOTION_DOCUMENTS_DIR) { + Ok(metadata) => { + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err("motion document root must be a no-follow directory".into()); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound && !create => return Ok(None), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + project + .create_dir(MOTION_DOCUMENTS_DIR) + .map_err(|error| format!("motion document root could not be created: {error}"))?; + sync_directory(project)?; + } + Err(error) => { + return Err(format!( + "motion document root could not be inspected: {error}" + )) + } + } + project + .open_dir_nofollow(MOTION_DOCUMENTS_DIR) + .map(Some) + .map_err(|_| "motion document root must be a no-follow directory".to_string()) +} + +fn read_catalog(root: &Dir) -> Result { + let Some(bytes) = read_bounded_file(root, CATALOG_FILE, MAX_CATALOG_BYTES, "catalog")? else { + return Ok(MotionCatalog::default()); + }; + let catalog: MotionCatalog = serde_json::from_slice(&bytes) + .map_err(|_| "motion document catalog is invalid".to_string())?; + if catalog.schema_version != CATALOG_SCHEMA_VERSION || catalog.documents.len() > MAX_DOCUMENTS { + return Err("motion document catalog is invalid".into()); + } + for (id, entry) in &catalog.documents { + validate_document_id(id)?; + validate_revision_directory(&entry.directory)?; + validate_summary(&entry.summary)?; + if entry.summary.id != *id { + return Err("motion document catalog identity is invalid".into()); + } + } + Ok(catalog) +} + +fn read_document(root: &Dir, entry: &CatalogEntry) -> Result { + validate_revision_directory(&entry.directory)?; + let directory = root + .open_dir_nofollow(&entry.directory) + .map_err(|_| "motion document revision must be a no-follow directory".to_string())?; + let manifest_bytes = read_bounded_file( + &directory, + DOCUMENT_MANIFEST_FILE, + MAX_MANIFEST_BYTES, + "manifest", + )? + .ok_or_else(|| "motion document manifest is missing".to_string())?; + let manifest: DocumentManifest = serde_json::from_slice(&manifest_bytes) + .map_err(|_| "motion document manifest is invalid".to_string())?; + if manifest.schema_version != DOCUMENT_SCHEMA_VERSION || manifest.summary != entry.summary { + return Err("motion document manifest does not match the catalog".into()); + } + validate_summary(&manifest.summary)?; + validate_parameters(&manifest.parameters)?; + let html = read_bounded_utf8(&directory, HTML_FILE, MAX_SOURCE_BYTES)?; + let css = read_bounded_utf8(&directory, CSS_FILE, MAX_SOURCE_BYTES)?; + if revision_hash(&html, &css, &manifest.parameters)? != manifest.summary.revision_hash { + return Err("motion document revision hash is invalid".into()); + } + Ok(MotionDocument { + summary: manifest.summary, + html, + css, + parameters: manifest.parameters, + }) +} + +fn document_with_content( + id: String, + title: String, + html: &str, + css: &str, + parameters: BTreeMap, +) -> Result { + validate_document_id(&id)?; + let title = validated_title(&title)?; + validate_source(html)?; + validate_source(css)?; + let html = normalize_line_endings(html); + let css = normalize_line_endings(css); + validate_parameters(¶meters)?; + let summary = MotionDocumentSummary { + id, + title, + revision_hash: revision_hash(&html, &css, ¶meters)?, + updated_at: updated_at_millis(), + }; + Ok(MotionDocument { + summary, + html, + css, + parameters, + }) +} + +fn write_revision_directory(root: &Dir, document: &MotionDocument) -> Result { + let manifest = DocumentManifest { + schema_version: DOCUMENT_SCHEMA_VERSION, + summary: document.summary.clone(), + parameters: document.parameters.clone(), + }; + let manifest_bytes = serde_json::to_vec_pretty(&manifest) + .map_err(|_| "motion document manifest could not be encoded".to_string())?; + if manifest_bytes.len() > MAX_MANIFEST_BYTES { + return Err("motion document manifest exceeds its byte limit".into()); + } + let directory_name = format!( + "rev-{}-{}-{}", + document.summary.id, + &document.summary.revision_hash[..16], + uuid::Uuid::new_v4() + ); + validate_revision_directory(&directory_name)?; + root.create_dir(&directory_name) + .map_err(|error| format!("motion document revision could not be created: {error}"))?; + let directory = root + .open_dir_nofollow(&directory_name) + .map_err(|_| "motion document revision must be a no-follow directory".to_string())?; + let result = (|| { + drop(write_new_file( + &directory, + DOCUMENT_MANIFEST_FILE, + &manifest_bytes, + )?); + drop(write_new_file( + &directory, + HTML_FILE, + document.html.as_bytes(), + )?); + drop(write_new_file( + &directory, + CSS_FILE, + document.css.as_bytes(), + )?); + sync_directory(&directory) + })(); + if let Err(error) = result { + cleanup_revision_directory(root, &directory_name); + return Err(error); + } + Ok(directory_name) +} + +fn apply_replacements( + source: &str, + mut edits: Vec, + max_bytes: usize, +) -> Result { + edits.sort_by_key(|edit| (edit.start, edit.end)); + let mut previous: Option<(usize, usize)> = None; + for edit in &edits { + if edit.start > edit.end + || edit.end > source.len() + || !source.is_char_boundary(edit.start) + || !source.is_char_boundary(edit.end) + { + return Err("motion document edit range is invalid".into()); + } + if previous.is_some_and(|(start, end)| edit.start < end || edit.start == start) { + return Err("motion document edits overlap".into()); + } + previous = Some((edit.start, edit.end)); + } + let removed = edits + .iter() + .map(|edit| edit.end - edit.start) + .sum::(); + let inserted = edits + .iter() + .try_fold(0usize, |total, edit| { + total.checked_add(edit.replacement.len()) + }) + .ok_or_else(|| "motion document patch exceeds its byte limit".to_string())?; + let result_len = source + .len() + .checked_sub(removed) + .and_then(|length| length.checked_add(inserted)) + .ok_or_else(|| "motion document patch exceeds its byte limit".to_string())?; + if result_len > max_bytes { + return Err("motion document patch exceeds its byte limit".into()); + } + let mut result = source.to_string(); + for edit in edits.into_iter().rev() { + result.replace_range(edit.start..edit.end, &edit.replacement); + } + Ok(result) +} + +fn prospective_sources( + current: &MotionDocument, + file: &str, + edits: Vec, +) -> Result<(String, String), String> { + let editable = EditableFile::parse(file)?; + if edits.is_empty() { + return Err("motion document patch requires at least one edit".into()); + } + if edits.len() > MAX_PATCH_EDITS { + return Err("motion document patch has too many edits".into()); + } + match editable { + EditableFile::Html => Ok(( + normalize_line_endings(&apply_replacements(¤t.html, edits, MAX_SOURCE_BYTES)?), + current.css.clone(), + )), + EditableFile::Css => Ok(( + current.html.clone(), + normalize_line_endings(&apply_replacements(¤t.css, edits, MAX_SOURCE_BYTES)?), + )), + } +} + +fn normalize_line_endings(source: &str) -> String { + source.replace("\r\n", "\n").replace('\r', "\n") +} + +fn revision_hash( + html: &str, + css: &str, + parameters: &BTreeMap, +) -> Result { + let parameters = serde_json::to_vec(parameters) + .map_err(|_| "motion document parameters could not be encoded".to_string())?; + let mut digest = Sha256::new(); + digest.update(b"opentake-motion-document-v1\0"); + for bytes in [html.as_bytes(), css.as_bytes(), parameters.as_slice()] { + digest.update((bytes.len() as u64).to_be_bytes()); + digest.update(bytes); + } + Ok(format!("{:x}", digest.finalize())) +} + +fn validate_document_id(id: &str) -> Result<(), String> { + let parsed = uuid::Uuid::parse_str(id).map_err(|_| "motion document id is invalid")?; + if parsed.to_string() != id { + return Err("motion document id is invalid".into()); + } + Ok(()) +} + +fn validate_revision_directory(name: &str) -> Result<(), String> { + if !name.starts_with("rev-") + || name.len() > 160 + || !name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + return Err("motion document revision directory is invalid".into()); + } + Ok(()) +} + +fn validated_title(title: &str) -> Result { + let title = title.trim(); + if title.is_empty() + || title.chars().count() > MAX_TITLE_CHARS + || title.chars().any(char::is_control) + { + return Err("motion document title is invalid".into()); + } + Ok(title.to_string()) +} + +fn validate_summary(summary: &MotionDocumentSummary) -> Result<(), String> { + validate_document_id(&summary.id)?; + validated_title(&summary.title)?; + if summary.revision_hash.len() != 64 + || !summary + .revision_hash + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + || summary.updated_at == 0 + { + return Err("motion document summary is invalid".into()); + } + Ok(()) +} + +fn validate_source(source: &str) -> Result<(), String> { + if source.len() > MAX_SOURCE_BYTES { + return Err("motion document source exceeds its byte limit".into()); + } + Ok(()) +} + +fn validate_parameters(parameters: &BTreeMap) -> Result<(), String> { + let bytes = serde_json::to_vec(parameters) + .map_err(|_| "motion document parameters are invalid".to_string())?; + if bytes.len() > MAX_PARAMETERS_BYTES { + return Err("motion document parameters exceed their byte limit".into()); + } + Ok(()) +} + +fn updated_at_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX) +} + +#[tauri::command] +pub async fn motion_document_list( + state: State<'_, Arc>, +) -> Result, String> { + let authority = state.capture_authority()?; + let store = Arc::clone(state.inner()); + tauri::async_runtime::spawn_blocking(move || store.list_for_authority(authority)) + .await + .map_err(|error| format!("motion document worker failed: {error}"))? +} + +#[tauri::command] +pub async fn motion_document_create( + state: State<'_, Arc>, + request: MotionDocumentCreateRequest, +) -> Result { + let authority = state.capture_authority()?; + let store = Arc::clone(state.inner()); + tauri::async_runtime::spawn_blocking(move || store.create_for_authority(authority, request)) + .await + .map_err(|error| format!("motion document worker failed: {error}"))? +} + +#[tauri::command] +pub async fn motion_document_read( + state: State<'_, Arc>, + document_id: String, +) -> Result { + let authority = state.capture_authority()?; + let store = Arc::clone(state.inner()); + tauri::async_runtime::spawn_blocking(move || store.read_for_authority(authority, &document_id)) + .await + .map_err(|error| format!("motion document worker failed: {error}"))? +} + +#[tauri::command] +pub async fn motion_document_hash( + state: State<'_, Arc>, + request: MotionDocumentHashRequest, +) -> Result { + let authority = state.capture_authority()?; + let store = Arc::clone(state.inner()); + tauri::async_runtime::spawn_blocking(move || store.hash_patch_for_authority(authority, request)) + .await + .map_err(|error| format!("motion document worker failed: {error}"))? +} + +#[tauri::command] +pub async fn motion_document_patch( + state: State<'_, Arc>, + request: MotionDocumentPatchRequest, +) -> Result { + let authority = state.capture_authority()?; + let store = Arc::clone(state.inner()); + tauri::async_runtime::spawn_blocking(move || store.save_patch_for_authority(authority, request)) + .await + .map_err(|error| format!("motion document worker failed: {error}"))? +} + +#[cfg(test)] +#[path = "motion_documents_tests.rs"] +mod tests; diff --git a/src-tauri/src/motion_documents_fs.rs b/src-tauri/src/motion_documents_fs.rs new file mode 100644 index 00000000..662e1381 --- /dev/null +++ b/src-tauri/src/motion_documents_fs.rs @@ -0,0 +1,247 @@ +//! Capability-relative, crash-safe file primitives for Motion Studio documents. + +use std::io::{Read, Write}; + +use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt}; +use cap_std::fs::{Dir, File, OpenOptions}; +use same_file::Handle; + +use super::{validate_revision_directory, CATALOG_FILE}; + +#[derive(Debug)] +pub(super) struct CatalogWriteError { + pub(super) message: String, + /// The atomic catalog replacement already happened. Callers must retain + /// the newly referenced revision and reconcile by reading the catalog. + pub(super) committed: bool, +} + +impl CatalogWriteError { + fn before_commit(message: impl Into) -> Self { + Self { + message: message.into(), + committed: false, + } + } + + fn after_commit(message: impl Into) -> Self { + Self { + message: message.into(), + committed: true, + } + } +} + +pub(super) fn write_new_file(directory: &Dir, name: &str, bytes: &[u8]) -> Result { + let mut options = OpenOptions::new(); + options + .write(true) + .create_new(true) + .follow(FollowSymlinks::No); + #[cfg(windows)] + { + use cap_std::fs::OpenOptionsExt; + use windows_sys::Win32::Foundation::{GENERIC_READ, GENERIC_WRITE}; + use windows_sys::Win32::Storage::FileSystem::{ + DELETE, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, + }; + options + .access_mode(GENERIC_READ | GENERIC_WRITE | DELETE) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE); + } + let mut file = directory + .open_with(name, &options) + .map_err(|error| format!("motion document file could not be created: {error}"))?; + file.write_all(bytes) + .map_err(|error| format!("motion document file could not be written: {error}"))?; + file.sync_all() + .map_err(|error| format!("motion document file could not be synced: {error}"))?; + Ok(file) +} + +pub(super) fn write_catalog_atomic( + root: &Dir, + bytes: &[u8], + inject_replace_failure: bool, + inject_sync_failure: bool, +) -> Result<(), CatalogWriteError> { + let temp_name = format!(".catalog-{}.tmp", uuid::Uuid::new_v4()); + let temp = write_new_file(root, &temp_name, bytes).map_err(CatalogWriteError::before_commit)?; + let result = (|| { + match root.symlink_metadata(CATALOG_FILE) { + Ok(metadata) if !metadata.is_file() || metadata.file_type().is_symlink() => { + return Err(CatalogWriteError::before_commit( + "motion document catalog must be a no-follow regular file", + )) + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(CatalogWriteError::before_commit(format!( + "motion document catalog could not be inspected: {error}" + ))) + } + } + if !file_matches_name(root, &temp_name, &temp).map_err(CatalogWriteError::before_commit)? { + return Err(CatalogWriteError::before_commit( + "motion document catalog staging identity changed", + )); + } + if inject_replace_failure { + return Err(CatalogWriteError::before_commit( + "injected catalog replace failure", + )); + } + replace_catalog_file(root, &temp, &temp_name, CATALOG_FILE).map_err(|error| { + CatalogWriteError::before_commit(format!( + "motion document catalog could not be replaced: {error}" + )) + })?; + if inject_sync_failure { + return Err(CatalogWriteError::after_commit( + "injected catalog directory sync failure after commit", + )); + } + sync_directory(root).map_err(CatalogWriteError::after_commit)?; + Ok(()) + })(); + if result.as_ref().is_err_and(|error| !error.committed) + && file_matches_name(root, &temp_name, &temp).unwrap_or(false) + { + let _ = root.remove_file(&temp_name); + } + result +} + +fn file_matches_name(root: &Dir, name: &str, expected: &File) -> Result { + let mut options = OpenOptions::new(); + options.read(true).follow(FollowSymlinks::No); + #[cfg(unix)] + { + use cap_std::fs::OpenOptionsExt; + options.custom_flags(libc::O_NONBLOCK); + } + #[cfg(windows)] + { + use cap_std::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, + }; + options.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE); + } + let current = match root.open_with(name, &options) { + Ok(current) => current, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(format!( + "motion document catalog staging could not be inspected: {error}" + )) + } + }; + let expected = expected + .try_clone() + .and_then(|file| Handle::from_file(file.into_std())) + .map_err(|_| "motion document catalog staging identity is unavailable".to_string())?; + let current = Handle::from_file(current.into_std()) + .map_err(|_| "motion document catalog staging identity is unavailable".to_string())?; + Ok(expected == current) +} + +fn replace_catalog_file( + root: &Dir, + _temp: &File, + temp_name: &str, + target: &str, +) -> std::io::Result<()> { + // Keep the replacement capability-relative on every platform. In + // particular, cap-std's Windows implementation resolves the two retained + // directory handles and delegates to `std::fs::rename`, whose Windows + // backend requests replacement of an existing destination. Passing a + // cap-std directory handle as FILE_RENAME_INFO::RootDirectory directly is + // rejected with ERROR_INVALID_PARAMETER on Windows Server 2022. + root.rename(temp_name, root, target) +} + +pub(super) fn read_bounded_file( + directory: &Dir, + name: &str, + max_bytes: usize, + label: &str, +) -> Result>, String> { + let mut options = OpenOptions::new(); + options.read(true).follow(FollowSymlinks::No); + #[cfg(unix)] + { + use cap_std::fs::OpenOptionsExt; + options.custom_flags(libc::O_NONBLOCK); + } + let mut file = match directory.open_with(name, &options) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => { + return Err(format!( + "motion document {label} must be a no-follow regular file" + )) + } + }; + let metadata = file + .metadata() + .map_err(|_| format!("motion document {label} metadata is unavailable"))?; + if !metadata.is_file() || metadata.len() > max_bytes as u64 { + return Err(format!("motion document {label} exceeds its byte limit")); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + Read::by_ref(&mut file) + .take(max_bytes as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| format!("motion document {label} could not be read"))?; + if bytes.len() > max_bytes { + return Err(format!("motion document {label} exceeds its byte limit")); + } + Ok(Some(bytes)) +} + +pub(super) fn read_bounded_utf8( + directory: &Dir, + name: &str, + max_bytes: usize, +) -> Result { + let bytes = read_bounded_file(directory, name, max_bytes, name)? + .ok_or_else(|| format!("motion document {name} is missing"))?; + String::from_utf8(bytes).map_err(|_| format!("motion document {name} must be UTF-8")) +} + +pub(super) fn sync_directory(directory: &Dir) -> Result<(), String> { + #[cfg(unix)] + { + use cap_std::fs::OpenOptionsExt; + + // cap-std intentionally retains ambient and traversed directories with + // O_PATH on Linux. Such a descriptor preserves the capability but + // rejects fsync with EBADF, so reopen the same directory through that + // capability as an ordinary read-only directory descriptor first. + let mut options = OpenOptions::new(); + options + .read(true) + .follow(FollowSymlinks::No) + .custom_flags(libc::O_DIRECTORY); + directory + .open_with(".", &options) + .and_then(|directory| directory.sync_all()) + .map_err(|error| format!("motion document directory could not be synced: {error}")) + } + #[cfg(not(unix))] + { + let _ = directory; + Ok(()) + } +} + +pub(super) fn cleanup_revision_directory(root: &Dir, name: &str) { + if validate_revision_directory(name).is_err() { + return; + } + if let Ok(directory) = root.open_dir_nofollow(name) { + let _ = directory.remove_open_dir_all(); + } +} diff --git a/src-tauri/src/motion_documents_template.rs b/src-tauri/src/motion_documents_template.rs new file mode 100644 index 00000000..a4f89943 --- /dev/null +++ b/src-tauri/src/motion_documents_template.rs @@ -0,0 +1,58 @@ +//! Built-in Motion Studio storage contract and starter sources. + +pub(super) const MOTION_DOCUMENTS_DIR: &str = "motion-documents"; +pub(super) const CATALOG_FILE: &str = "catalog.json"; +pub(super) const DOCUMENT_MANIFEST_FILE: &str = "manifest.json"; +pub(super) const HTML_FILE: &str = "index.html"; +pub(super) const CSS_FILE: &str = "styles.css"; +pub(super) const CATALOG_SCHEMA_VERSION: u32 = 1; +pub(super) const DOCUMENT_SCHEMA_VERSION: u32 = 1; +pub(super) const MAX_DOCUMENTS: usize = 256; +pub(super) const MAX_CATALOG_BYTES: usize = 1024 * 1024; +pub(super) const MAX_MANIFEST_BYTES: usize = 64 * 1024; +pub(super) const MAX_SOURCE_BYTES: usize = 512 * 1024; +pub(super) const MAX_PARAMETERS_BYTES: usize = 64 * 1024; +pub(super) const MAX_TITLE_CHARS: usize = 128; +pub(super) const MAX_PATCH_EDITS: usize = 2048; + +pub(super) const STARTER_HTML: &str = r#"
+

Motion Studio

+

让创意动起来

+

Real HTML · Real CSS · Real motion

+
+"#; + +pub(super) const STARTER_CSS: &str = r#"html, body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + background: #111214; + color: #f7f7f5; + font-family: Inter, "PingFang SC", sans-serif; +} + +.motion-stage { + box-sizing: border-box; + display: grid; + align-content: center; + width: 100%; + height: 100%; + padding: 10%; + background: radial-gradient(circle at 72% 24%, #5f5cff 0, transparent 34%); +} + +.motion-kicker { color: #a9a7ff; letter-spacing: .18em; text-transform: uppercase; } +h1 { margin: .12em 0; font-size: clamp(48px, 8vw, 144px); animation: title-in 1.2s both; } +.motion-subtitle { font-size: clamp(18px, 2.2vw, 42px); opacity: .72; animation: subtitle-in 1.2s .18s both; } + +@keyframes title-in { + from { opacity: 0; transform: translateY(48px) scale(.96); filter: blur(12px); } + to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); } +} + +@keyframes subtitle-in { + from { opacity: 0; transform: translateY(24px); } + to { opacity: .72; transform: translateY(0); } +} +"#; diff --git a/src-tauri/src/motion_documents_tests.rs b/src-tauri/src/motion_documents_tests.rs new file mode 100644 index 00000000..51dee0c4 --- /dev/null +++ b/src-tauri/src/motion_documents_tests.rs @@ -0,0 +1,492 @@ +use super::*; + +use std::fs; +use std::path::{Path, PathBuf}; + +use opentake_core::AppCore; +use serde_json::Value; +use tempfile::TempDir; + +fn saved_core(name: &str) -> (TempDir, AppCore, PathBuf) { + let temp = tempfile::tempdir().expect("create temp project parent"); + let path = temp.path().join(format!("{name}.opentake")); + let core = AppCore::new(); + core.save_project(Some(path.clone())) + .expect("save fixture project"); + (temp, core, path) +} + +fn create_document(store: &MotionDocumentStore) -> MotionDocument { + store + .create(MotionDocumentCreateRequest { + title: Some("片头标题".to_string()), + }) + .expect("create motion document") +} + +fn current_directory(project: &Path, document_id: &str) -> PathBuf { + let catalog: Value = serde_json::from_slice( + &fs::read(project.join(MOTION_DOCUMENTS_DIR).join(CATALOG_FILE)) + .expect("read motion catalog"), + ) + .expect("decode motion catalog"); + let directory = catalog["documents"][document_id]["directory"] + .as_str() + .expect("catalog directory"); + project.join(MOTION_DOCUMENTS_DIR).join(directory) +} + +fn replace_html(document: &MotionDocument, html: &str) -> MotionDocumentPatchRequest { + MotionDocumentPatchRequest { + document_id: document.summary.id.clone(), + file: "index.html".to_string(), + baseline_hash: document.summary.revision_hash.clone(), + edits: vec![MotionTextReplacement { + start: 0, + end: document.html.len(), + replacement: html.to_string(), + }], + expected_result_hash: revision_hash(html, &document.css, &document.parameters) + .expect("hash replacement"), + } +} + +#[test] +fn computes_a_prospective_hash_from_the_authoritative_document_without_mutating_it() { + let (_temp, core, _project) = saved_core("hash-preview"); + let store = MotionDocumentStore::new(core); + let original = create_document(&store); + let replacement = "
hash preview
"; + let request = MotionDocumentHashRequest { + document_id: original.summary.id.clone(), + file: "index.html".to_string(), + baseline_hash: original.summary.revision_hash.clone(), + edits: vec![MotionTextReplacement { + start: 0, + end: original.html.len(), + replacement: replacement.to_string(), + }], + }; + + let hash = store + .hash_patch(request) + .expect("compute prospective hash from stored parameters"); + + assert_eq!( + hash, + revision_hash(replacement, &original.css, &original.parameters).unwrap() + ); + assert_eq!( + store + .read(&original.summary.id) + .expect("document unchanged"), + original + ); +} + +#[test] +fn authoritative_parameter_encoding_preserves_integral_floats_and_unicode_key_order() { + let parameters = BTreeMap::from([ + ("\u{10000}".to_string(), Value::String("astral".to_string())), + ("\u{e000}".to_string(), Value::String("bmp".to_string())), + ("number".to_string(), Value::from(1.0)), + ]); + + assert_eq!( + String::from_utf8(serde_json::to_vec(¶meters).unwrap()).unwrap(), + "{\"number\":1.0,\"\":\"bmp\",\"𐀀\":\"astral\"}" + ); + assert_eq!( + revision_hash("
", "body{}", ¶meters).unwrap(), + "3e4f96493c6a3345ec570f156af652e9a5457d126798c507d8793a7cf74b1c10" + ); +} + +#[test] +fn creates_visible_bilingual_template_and_lists_it() { + let (_temp, core, _project) = saved_core("template"); + let store = MotionDocumentStore::new(core); + let document = create_document(&store); + + assert!(document.html.contains("让创意动起来")); + assert!(document.html.contains("Motion Studio")); + assert!(document.css.contains("@keyframes")); + assert_eq!(document.summary.title, "片头标题"); + assert_eq!( + store.list().expect("list documents"), + vec![document.summary] + ); +} + +#[test] +fn persists_across_a_fresh_core_and_store() { + let (_temp, core, project) = saved_core("restart"); + let store = MotionDocumentStore::new(core); + let document = create_document(&store); + drop(store); + + let reopened_core = AppCore::new(); + reopened_core + .open_project(project) + .expect("reopen fixture project"); + let reopened = MotionDocumentStore::new(reopened_core) + .read(&document.summary.id) + .expect("read persisted document"); + assert_eq!(reopened, document); +} + +#[test] +fn survives_complete_project_save_as_and_reopen() { + let (temp, core, _project) = saved_core("save-as-source"); + let store = MotionDocumentStore::new(core.clone()); + let document = create_document(&store); + let destination = temp.path().join("Save As.opentake"); + + core.save_project(Some(destination.clone())) + .expect("save project under a new name"); + drop(store); + + let reopened_core = AppCore::new(); + reopened_core + .open_project(destination) + .expect("reopen Save As destination"); + assert_eq!( + MotionDocumentStore::new(reopened_core) + .read(&document.summary.id) + .expect("read document after Save As"), + document + ); +} + +#[test] +fn rejects_stale_hash_and_preserves_the_winner() { + let (_temp, core, _project) = saved_core("stale"); + let store = MotionDocumentStore::new(core); + let original = create_document(&store); + let request = replace_html(&original, "
first writer
"); + let winner = store.save_patch(request.clone()).expect("first patch wins"); + + let error = store + .save_patch(request) + .expect_err("stale baseline must fail"); + assert!(error.contains("revision conflict"), "{error}"); + assert_eq!( + store.read(&original.summary.id).expect("read winner"), + winner + ); +} + +#[test] +fn normalizes_crlf_and_lone_cr_before_hashing_and_persistence() { + let (_temp, core, _project) = saved_core("line-endings"); + let store = MotionDocumentStore::new(core); + let original = create_document(&store); + let normalized = "
first\nsecond\nthird
"; + let mut request = replace_html(&original, "
first\r\nsecond\rthird
"); + request.expected_result_hash = revision_hash(normalized, &original.css, &original.parameters) + .expect("hash normalized replacement"); + + let saved = store + .save_patch(request) + .expect("normalized replacement must save"); + + assert_eq!(saved.html, normalized); + assert!(!saved.html.contains('\r')); + assert_eq!(store.read(&saved.summary.id).unwrap(), saved); +} + +#[test] +fn patch_ranges_are_utf8_byte_offsets_and_must_land_on_character_boundaries() { + let source = "

让创意动起来

"; + let start = source.find('让').expect("Chinese text starts"); + let end = start + "让创意动起来".len(); + let patched = apply_replacements( + source, + vec![MotionTextReplacement { + start, + end, + replacement: "Motion Studio".into(), + }], + MAX_SOURCE_BYTES, + ) + .expect("UTF-8 byte range patches cleanly"); + assert_eq!(patched, "

Motion Studio

"); + + let error = apply_replacements( + source, + vec![MotionTextReplacement { + start: start + 1, + end, + replacement: "invalid".into(), + }], + MAX_SOURCE_BYTES, + ) + .expect_err("mid-codepoint byte offset must fail"); + assert!(error.contains("range is invalid"), "{error}"); +} + +#[test] +fn rejects_absolute_traversal_and_overlapping_edits() { + let (_temp, core, _project) = saved_core("paths"); + let store = MotionDocumentStore::new(core); + let original = create_document(&store); + + for file in ["../styles.css", "/tmp/index.html", "nested/index.html"] { + let mut request = replace_html(&original, "safe"); + request.file = file.to_string(); + let error = store + .save_patch(request) + .expect_err("unsafe file must fail"); + assert!(error.contains("editable file"), "{file}: {error}"); + } + for id in ["../escape", "/absolute", "not-a-uuid"] { + let error = store.read(id).expect_err("unsafe id must fail"); + assert!(error.contains("document id"), "{id}: {error}"); + } + + let mut request = replace_html(&original, "unused"); + request.edits = vec![ + MotionTextReplacement { + start: 0, + end: 4, + replacement: "a".into(), + }, + MotionTextReplacement { + start: 3, + end: 5, + replacement: "b".into(), + }, + ]; + request.expected_result_hash = "0".repeat(64); + let error = store + .save_patch(request) + .expect_err("overlapping edits must fail"); + assert!(error.contains("overlap"), "{error}"); +} + +#[cfg(unix)] +#[test] +fn rejects_a_symlinked_motion_root() { + use std::os::unix::fs::symlink; + + let (temp, core, project) = saved_core("symlink"); + let outside = temp.path().join("outside"); + fs::create_dir(&outside).expect("create outside directory"); + symlink(&outside, project.join(MOTION_DOCUMENTS_DIR)).expect("create root symlink"); + + let error = MotionDocumentStore::new(core) + .create(MotionDocumentCreateRequest { title: None }) + .expect_err("symlinked root must fail"); + assert!(error.contains("no-follow directory"), "{error}"); + assert!(fs::read_dir(outside) + .expect("read outside") + .next() + .is_none()); +} + +#[cfg(unix)] +#[test] +fn rejects_symlinked_revision_sources_without_reading_outside_the_project() { + use std::os::unix::fs::symlink; + + let (temp, core, project) = saved_core("source-symlink"); + let store = MotionDocumentStore::new(core); + let document = create_document(&store); + let revision = current_directory(&project, &document.summary.id); + let outside = temp.path().join("outside.html"); + fs::write(&outside, b"outside secret").expect("write outside fixture"); + fs::remove_file(revision.join(HTML_FILE)).expect("remove managed HTML"); + symlink(&outside, revision.join(HTML_FILE)).expect("replace HTML with symlink"); + + let error = store + .read(&document.summary.id) + .expect_err("symlinked source must fail closed"); + + assert!(error.contains("no-follow regular file"), "{error}"); + assert_eq!(fs::read(outside).unwrap(), b"outside secret"); +} + +#[cfg(unix)] +#[test] +fn rejects_fifo_sources_without_blocking_the_store() { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + use std::sync::mpsc; + use std::time::Duration; + + let (_temp, core, project) = saved_core("source-fifo"); + let store = Arc::new(MotionDocumentStore::new(core)); + let document = create_document(&store); + let revision = current_directory(&project, &document.summary.id); + let fifo = revision.join(HTML_FILE); + fs::remove_file(&fifo).expect("remove managed HTML"); + let fifo_path = CString::new(fifo.as_os_str().as_bytes()).expect("FIFO path"); + // SAFETY: the path is a valid, NUL-terminated filesystem path and mode is + // restricted to the test process owner. + assert_eq!(unsafe { libc::mkfifo(fifo_path.as_ptr(), 0o600) }, 0); + + let reader = Arc::clone(&store); + let document_id = document.summary.id.clone(); + let (sent, received) = mpsc::channel(); + let worker = std::thread::spawn(move || { + sent.send(reader.read(&document_id)).unwrap(); + }); + let error = received + .recv_timeout(Duration::from_secs(2)) + .expect("FIFO read must not block") + .expect_err("FIFO must fail closed"); + assert!( + error.contains("byte limit") || error.contains("regular file"), + "{error}" + ); + worker.join().unwrap(); +} + +#[test] +fn rejects_invalid_utf8_oversized_files_and_invalid_manifest() { + let (_temp, core, project) = saved_core("corrupt"); + let store = MotionDocumentStore::new(core); + let first = create_document(&store); + let first_dir = current_directory(&project, &first.summary.id); + fs::write(first_dir.join(HTML_FILE), [0xff, 0xfe]).expect("write invalid UTF-8"); + let error = store + .read(&first.summary.id) + .expect_err("invalid UTF-8 must fail"); + assert!(error.contains("UTF-8"), "{error}"); + + let second = store + .create(MotionDocumentCreateRequest { + title: Some("oversized".into()), + }) + .expect("create second document"); + let second_dir = current_directory(&project, &second.summary.id); + fs::write(second_dir.join(CSS_FILE), vec![b'a'; MAX_SOURCE_BYTES + 1]) + .expect("write oversized CSS"); + let error = store + .read(&second.summary.id) + .expect_err("oversized source must fail"); + assert!(error.contains("byte limit"), "{error}"); + + let third = store + .create(MotionDocumentCreateRequest { + title: Some("manifest".into()), + }) + .expect("create third document"); + let third_dir = current_directory(&project, &third.summary.id); + fs::write(third_dir.join(DOCUMENT_MANIFEST_FILE), b"not-json").expect("write invalid manifest"); + let error = store + .read(&third.summary.id) + .expect_err("invalid manifest must fail"); + assert!(error.contains("manifest"), "{error}"); +} + +#[test] +fn failed_catalog_replace_preserves_the_prior_revision_after_restart() { + let (_temp, core, project) = saved_core("rename-failure"); + let store = MotionDocumentStore::new(core.clone()); + let original = create_document(&store); + store.fail_next_catalog_replace_for_test(); + let error = store + .save_patch(replace_html(&original, "
must not publish
")) + .expect_err("injected catalog replace must fail"); + assert!( + error.contains("injected catalog replace failure"), + "{error}" + ); + assert_eq!(store.read(&original.summary.id).unwrap(), original); + drop(store); + + let reopened_core = AppCore::new(); + reopened_core.open_project(project).expect("reopen project"); + assert_eq!( + MotionDocumentStore::new(reopened_core) + .read(&original.summary.id) + .expect("read original after restart"), + original + ); +} + +#[test] +fn post_commit_sync_failure_reports_error_but_preserves_published_revision() { + let (_temp, core, project) = saved_core("sync-failure"); + let store = MotionDocumentStore::new(core.clone()); + let original = create_document(&store); + let replacement = "
published before directory sync failed
"; + let request = replace_html(&original, replacement); + let expected_hash = request.expected_result_hash.clone(); + store.fail_next_catalog_sync_for_test(); + + let error = store + .save_patch(request) + .expect_err("post-commit durability failure must not report success"); + assert!(error.contains("after commit"), "{error}"); + let published = store + .read(&original.summary.id) + .expect("committed catalog remains readable"); + assert_eq!(published.html, replacement); + assert_eq!(published.summary.revision_hash, expected_hash); + drop(store); + + let reopened_core = AppCore::new(); + reopened_core.open_project(project).expect("reopen project"); + let reopened = MotionDocumentStore::new(reopened_core) + .read(&original.summary.id) + .expect("committed revision survives restart"); + assert_eq!(reopened.html, replacement); + assert_eq!(reopened.summary.revision_hash, expected_hash); +} + +#[test] +fn queued_request_is_bound_to_authority_captured_at_admission() { + let (temp, core, _source) = saved_core("admission-source"); + let store = MotionDocumentStore::new(core.clone()); + let original = create_document(&store); + let authority = store.capture_authority().expect("capture source authority"); + let request = replace_html(&original, "
must not cross projects
"); + let destination = temp.path().join("admission-destination.opentake"); + core.save_project(Some(destination.clone())) + .expect("Save As replacement project"); + + let error = store + .save_patch_for_authority(authority, request) + .expect_err("queued old-project request must fail after project replacement"); + assert!(error.contains("current project changed"), "{error}"); + assert_eq!( + store + .read(&original.summary.id) + .expect("Save As copy remains unchanged"), + original + ); +} + +#[test] +fn rejects_manifest_that_pretty_serialization_cannot_read_back() { + let (_temp, _core, project) = saved_core("manifest-byte-limit"); + let project = Dir::open_ambient_dir(project, ambient_authority()).expect("open project root"); + let root = motion_root(&project, true) + .expect("create motion root") + .expect("motion root exists"); + let mut parameters = BTreeMap::new(); + for index in 0..3_500 { + parameters.insert(format!("key-{index:04}"), Value::from(index)); + } + assert!(serde_json::to_vec(¶meters).unwrap().len() <= MAX_PARAMETERS_BYTES); + let document = document_with_content( + uuid::Uuid::new_v4().to_string(), + "Manifest bound".into(), + STARTER_HTML, + STARTER_CSS, + parameters, + ) + .expect("compact parameters fit their own bound"); + + let error = write_revision_directory(&root, &document) + .expect_err("unreadable oversized pretty manifest must be rejected before publication"); + assert!(error.contains("manifest exceeds its byte limit"), "{error}"); + assert!(root.read_dir(".").unwrap().all(|entry| { + !entry + .ok() + .and_then(|entry| entry.file_name().into_string().ok()) + .is_some_and(|name| name.starts_with("rev-")) + })); +} diff --git a/src-tauri/src/playback/engine.rs b/src-tauri/src/playback/engine.rs index 6f48a870..bc5c3191 100644 --- a/src-tauri/src/playback/engine.rs +++ b/src-tauri/src/playback/engine.rs @@ -934,14 +934,18 @@ mod tests { .expect("pause reaches render thread"), 73 ); - let returned_before_render_release = result_rx.try_recv().is_ok(); + // Give the caller thread a bounded scheduling window. An immediate + // try_recv races the caller's result send against the render fixture's + // pause_seen send and flakes under loaded CI runners even though + // `pause` has already returned without waiting for the render reply. + let returned_before_render_release = result_rx.recv_timeout(Duration::from_secs(1)); release_pause .send(()) .expect("release synthetic inflight render"); caller.join().expect("join pause caller"); assert!( - returned_before_render_release, + matches!(returned_before_render_release, Ok(Ok(()))), "pause must acknowledge after enqueueing, not after the slow render finishes" ); } diff --git a/src-tauri/src/render.rs b/src-tauri/src/render.rs index c71ddc43..f0da1563 100644 --- a/src-tauri/src/render.rs +++ b/src-tauri/src/render.rs @@ -20,11 +20,13 @@ //! (#53) will move this onto a dedicated render thread. use std::collections::{HashMap, HashSet}; +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; use std::num::NonZeroUsize; use std::path::PathBuf; use std::rc::Rc; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Mutex; +use std::sync::{Mutex, OnceLock}; use base64::Engine as _; use serde::{Deserialize, Serialize}; @@ -34,8 +36,8 @@ use tauri::State; use opentake_core::{AppCore, EditCommand, ProjectRevision}; use opentake_domain::{ClipType, LutReference, MediaSource, TextStyle, Timeline}; use opentake_media::{ - decode_frame_at_cancellable, interpolate_frame_pair, FrameInterpolationFallback, - FrameInterpolationMode, FrameRequest, MediaCancelToken, + decode_frame_at_cancellable, decode_frame_file_at_cancellable, interpolate_frame_pair, + FrameInterpolationFallback, FrameInterpolationMode, FrameRequest, MediaCancelToken, }; use opentake_ops::command::RenameEntry; use opentake_project::ProjectRoot; @@ -46,9 +48,9 @@ use opentake_render::gpu::compositor::{ use opentake_render::gpu::texture::upload_rgba; use opentake_render::wgpu; use opentake_render::{ - even, try_build_render_plan, Compositor, CosmicTextRasterizer, DecodedFrame, GpuLutTexture, - GpuTexture, RenderDevice, RenderSize, SourceMetrics, TextRasterRequest, TextRasterizer, - TextureCache, TextureResolver, TextureSource, + even, try_build_render_plan, Compositor, CosmicTextRasterizer, DecodedFrame, FramePlan, + GpuLutTexture, GpuTexture, LayerDraw, RenderDevice, RenderPlan, RenderSize, SourceMetrics, + TextRasterRequest, TextRasterizer, TextureCache, TextureResolver, TextureSource, }; /// Cap (longest canvas side, px) for a composite when the caller passes no @@ -56,6 +58,54 @@ use opentake_render::{ /// looking crisp in the preview pane. const DEFAULT_PREVIEW_CAP: u32 = 1280; +/// Agent result images stay below both the chat display dimension and its +/// 1 MiB base64 payload ceiling (768 KiB raw expands to exactly 1 MiB). +pub(crate) const AGENT_TIMELINE_RESULT_MAX_DIMENSION: u32 = 640; +pub(crate) const AGENT_TIMELINE_RESULT_PNG_BYTES_MAX: usize = 768 * 1024; +const EMPTY_TIMELINE_BACKGROUND_RGBA: [u8; 4] = [22, 24, 29, 255]; +const EMPTY_TIMELINE_MARKER_RGBA: [u8; 4] = [174, 181, 195, 255]; + +static ROOT_TIMELINE_PLAYHEAD: OnceLock>> = OnceLock::new(); + +fn record_root_timeline_playhead(project_epoch: u64, frame: i32) { + *ROOT_TIMELINE_PLAYHEAD + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((project_epoch, frame)); +} + +pub(crate) fn root_timeline_playhead(project_epoch: u64) -> i32 { + let guard = ROOT_TIMELINE_PLAYHEAD + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + guard + .as_ref() + .filter(|(epoch, _)| *epoch == project_epoch) + .map_or(0, |(_, frame)| *frame) +} + +/// Explicit project-owned inputs for the semantic empty-timeline frame. The +/// renderer rejects values that disagree with the committed timeline snapshot. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct EmptyTimelineCanvasInput { + pub project_width: i32, + pub project_height: i32, + pub fps: i32, + pub playhead_frame: i32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TimelineResultPng { + pub bytes: Vec, + pub media_type: &'static str, + pub width: u32, + pub height: u32, + pub playhead_frame: i32, + pub timecode: String, + pub empty_canvas: bool, +} + /// Per-frame texture cache size. Bounds VRAM during scrubbing; video frames are /// keyed per source-frame so adjacent scrub positions reuse nothing, but a small /// cache still helps repeated seeks to the same frame. @@ -257,11 +307,24 @@ impl PreviewCompositeCoordinator { } /// Resolvable info for one media asset, projected from the manifest. -struct MediaInfo { +struct MediaInfo<'a> { path: PathBuf, + retained: Option<&'a File>, source_fps: Option, } +/// Retained, pre-authorized media inputs for strict project-cover capture. +/// Missing entries fail closed; the renderer never reopens their manifest paths. +pub(crate) struct CompositeSourceAuthority { + files: HashMap, +} + +impl CompositeSourceAuthority { + pub(crate) fn new(files: HashMap) -> Self { + Self { files } + } +} + /// A text clip projected from the timeline, keyed by clip id. The box's width / /// height drive the rasterized texture size; position is carried by the layer /// affine (so x/y are kept only for completeness). @@ -271,6 +334,26 @@ struct TextInfo { box_norm: (f64, f64, f64, f64), } +fn text_style_is_finite(style: &TextStyle) -> bool { + let color_is_finite = |color: opentake_domain::Rgba| { + [color.r, color.g, color.b, color.a] + .into_iter() + .all(f64::is_finite) + }; + style.font_size.is_finite() + && style.font_size > 0.0 + && style.font_scale.is_finite() + && style.font_scale > 0.0 + && color_is_finite(style.color) + && color_is_finite(style.shadow.color) + && style.shadow.offset_x.is_finite() + && style.shadow.offset_y.is_finite() + && style.shadow.blur.is_finite() + && style.shadow.blur >= 0.0 + && color_is_finite(style.background.color) + && color_is_finite(style.border.color) +} + /// `SourceMetrics` backed by the media manifest: only intrinsic size is known /// here (orientation/alpha use the documented identity/false defaults; ffmpeg /// auto-rotates on decode in this first cut). @@ -329,6 +412,29 @@ impl LottieMaterializer { fn ensure_document(&mut self, path: &std::path::Path) -> Result<(), String> { let bytes = std::fs::read(path) .map_err(|error| format!("read Lottie document {}: {error}", path.display()))?; + self.ensure_document_bytes(path, &bytes) + } + + fn ensure_document_file(&mut self, path: &std::path::Path, file: &File) -> Result<(), String> { + let mut input = file + .try_clone() + .map_err(|error| format!("clone retained Lottie document: {error}"))?; + input + .seek(SeekFrom::Start(0)) + .map_err(|error| format!("rewind retained Lottie document: {error}"))?; + let mut bytes = Vec::new(); + input + .take((MAX_LOTTIE_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|error| format!("read retained Lottie document: {error}"))?; + self.ensure_document_bytes(path, &bytes) + } + + fn ensure_document_bytes( + &mut self, + path: &std::path::Path, + bytes: &[u8], + ) -> Result<(), String> { if bytes.is_empty() || bytes.len() > MAX_LOTTIE_BYTES { return Err(format!( "Lottie document {} must be 1..={MAX_LOTTIE_BYTES} bytes (got {})", @@ -336,13 +442,13 @@ impl LottieMaterializer { bytes.len() )); } - let content_hash = format!("{:x}", Sha256::digest(&bytes)); + let content_hash = format!("{:x}", Sha256::digest(bytes)); let needs_parse = self .documents .get(path) .is_none_or(|cached| cached.content_hash != content_hash); if needs_parse { - let composition = std::panic::catch_unwind(|| velato::Composition::from_slice(&bytes)) + let composition = std::panic::catch_unwind(|| velato::Composition::from_slice(bytes)) .map_err(|_| { format!( "Lottie document {} uses an unsupported or malformed feature", @@ -391,7 +497,52 @@ impl LottieMaterializer { label: &str, ) -> Result, String> { self.ensure_document(path)?; + self.resolve_loaded( + device, + queue, + textures, + path, + source_frame, + render_box, + label, + ) + } + #[allow(clippy::too_many_arguments)] + fn resolve_file( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + textures: &mut TextureCache, + path: &std::path::Path, + file: &File, + source_frame: i64, + render_box: (u32, u32), + label: &str, + ) -> Result, String> { + self.ensure_document_file(path, file)?; + self.resolve_loaded( + device, + queue, + textures, + path, + source_frame, + render_box, + label, + ) + } + + #[allow(clippy::too_many_arguments)] + fn resolve_loaded( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + textures: &mut TextureCache, + path: &std::path::Path, + source_frame: i64, + render_box: (u32, u32), + label: &str, + ) -> Result, String> { let cached = self .documents .get(path) @@ -544,7 +695,7 @@ struct MediaResolver<'d> { queue: &'d wgpu::Queue, cache: &'d mut TextureCache, lottie: &'d mut LottieMaterializer, - media: &'d HashMap, + media: &'d HashMap>, timeline_fps: i32, /// Text clips by id (content + style + box) for on-demand rasterization. text: &'d HashMap, @@ -556,9 +707,17 @@ struct MediaResolver<'d> { project_root: Option<&'d ProjectRoot>, lut_cache: &'d mut HashMap>, materialization_error: Option, + strict_materialization: bool, } impl MediaResolver<'_> { + fn fail_materialization(&mut self, message: impl Into) -> Option { + if self.strict_materialization && self.materialization_error.is_none() { + self.materialization_error = Some(message.into()); + } + None + } + /// Rasterize a text clip's box to a premultiplied-RGBA texture (composited /// last, like upstream's `CATextLayer`). The box texture is uploaded with /// `srgb = false` so it blends in the same encoded space as video/image, and @@ -569,7 +728,12 @@ impl MediaResolver<'_> { if let Some(tex) = self.cache.get(&key) { return Some(tex); } - let info = self.text.get(clip_id)?; + let Some(info) = self.text.get(clip_id) else { + return self.fail_materialization(format!("text clip {clip_id} has no raster input")); + }; + if !text_style_is_finite(&info.style) { + return self.fail_materialization(format!("text clip {clip_id} has invalid style")); + } let req = TextRasterRequest { clip_id, content: &info.content, @@ -577,7 +741,19 @@ impl MediaResolver<'_> { box_norm: info.box_norm, canvas: self.preview_box, }; - let frame = self.text_rasterizer.rasterize(&req)?; + let frame = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + self.text_rasterizer.rasterize(&req) + })) { + Ok(Some(frame)) => frame, + Ok(None) => { + return self + .fail_materialization(format!("text clip {clip_id} rasterization failed")); + } + Err(_) => { + return self + .fail_materialization(format!("text clip {clip_id} rasterization panicked")); + } + }; let tex = upload_rgba(self.device, self.queue, &frame, false, Some("preview-text")); Some(self.cache.insert(key, tex)) } @@ -595,10 +771,13 @@ impl MediaResolver<'_> { if let Some(tex) = self.cache.get(&key) { return Some(tex); } - let info = self.media.get(media_ref)?; + let Some(info) = self.media.get(media_ref) else { + return self.fail_materialization(format!("video source {media_ref} is unauthorized")); + }; let source_fps = info.source_fps.unwrap_or(interpolation.source_fps); if !source_fps.is_finite() || source_fps <= 0.0 { - return None; + return self + .fail_materialization(format!("video source {media_ref} has invalid frame rate")); } let timestamp = source_frame.max(0) as f64 / interpolation.target_fps; let source_position = timestamp * source_fps; @@ -606,27 +785,37 @@ impl MediaResolver<'_> { let next_index = source_position.ceil().max(0.0) as i64; let alpha = source_position - first_index as f64; let decode = |index: i64| { - decode_frame_at_cancellable( - &info.path, - &FrameRequest { - time_secs: index as f64 / source_fps, - max_size: self.preview_box, - tolerance_secs: 0.0, - apply_rotation: true, - }, - self.cancel, - ) + let request = FrameRequest { + time_secs: index as f64 / source_fps, + max_size: self.preview_box, + tolerance_secs: 0.0, + apply_rotation: true, + }; + match info.retained { + Some(file) => decode_frame_file_at_cancellable(file, &request, self.cancel), + None => decode_frame_at_cancellable(&info.path, &request, self.cancel), + } .ok() .map(|(_, frame)| frame) }; - let first = decode(first_index)?; + let Some(first) = decode(first_index) else { + return self.fail_materialization(format!("video source {media_ref} decode failed")); + }; let last = if next_index == first_index { first.clone() } else { // A half-open media duration may not expose the mathematical next // frame at the tail. Hold the last decodable endpoint instead of // dropping the whole layer to black. - decode(next_index).unwrap_or_else(|| first.clone()) + match decode(next_index) { + Some(frame) => frame, + None if !self.strict_materialization => first.clone(), + None => { + return self.fail_materialization(format!( + "video source {media_ref} interpolation endpoint decode failed" + )); + } + } }; let requested = match interpolation.mode { TextureInterpolationMode::Nearest => FrameInterpolationMode::Nearest, @@ -638,9 +827,14 @@ impl MediaResolver<'_> { TextureInterpolationFallback::Blend => FrameInterpolationFallback::Blend, TextureInterpolationFallback::Error => FrameInterpolationFallback::Error, }; - let frame = interpolate_frame_pair(&first, &last, alpha, requested, fallback, true) - .ok()? - .frame; + let frame = match interpolate_frame_pair(&first, &last, alpha, requested, fallback, true) { + Ok(result) => result.frame, + Err(_) => { + return self.fail_materialization(format!( + "video source {media_ref} interpolation failed" + )); + } + }; let decoded = DecodedFrame::new(frame.width, frame.height, frame.rgba, false); let tex = upload_rgba( self.device, @@ -660,16 +854,33 @@ impl TextureResolver for MediaResolver<'_> { TextureSource::Image { media_ref } => (media_ref, true), TextureSource::Text { clip_id } => return self.resolve_text(clip_id), TextureSource::Lottie { media_ref } => { - let info = self.media.get(media_ref)?; - return match self.lottie.resolve( - self.device, - self.queue, - self.cache, - &info.path, - source_frame, - self.preview_box, - "preview-lottie", - ) { + let Some(info) = self.media.get(media_ref) else { + return self.fail_materialization(format!( + "Lottie source {media_ref} is unauthorized" + )); + }; + let result = match info.retained { + Some(file) => self.lottie.resolve_file( + self.device, + self.queue, + self.cache, + &info.path, + file, + source_frame, + self.preview_box, + "preview-lottie", + ), + None => self.lottie.resolve( + self.device, + self.queue, + self.cache, + &info.path, + source_frame, + self.preview_box, + "preview-lottie", + ), + }; + return match result { Ok(texture) => Some(texture), Err(error) => { eprintln!("[render] {error}"); @@ -680,9 +891,18 @@ impl TextureResolver for MediaResolver<'_> { } }; - let info = self.media.get(media_ref)?; + let Some(info) = self.media.get(media_ref) else { + return self.fail_materialization(format!("media source {media_ref} is unauthorized")); + }; let key = if is_image { - let content_hash = opentake_media::file_sha256(&info.path).ok()?; + let content_hash = match info.retained { + Some(file) => opentake_media::file_sha256_file_cancellable(file, self.cancel), + None => opentake_media::file_sha256(&info.path), + }; + let Ok(content_hash) = content_hash else { + return self + .fail_materialization(format!("image source {media_ref} hashing failed")); + }; format!("i:{content_hash}") } else { format!("v:{media_ref}:{source_frame}") @@ -708,7 +928,13 @@ impl TextureResolver for MediaResolver<'_> { tolerance_secs: 0.1, apply_rotation: true, }; - let (_actual, frame) = decode_frame_at_cancellable(&info.path, &req, self.cancel).ok()?; + let decoded = match info.retained { + Some(file) => decode_frame_file_at_cancellable(file, &req, self.cancel), + None => decode_frame_at_cancellable(&info.path, &req, self.cancel), + }; + let Ok((_actual, frame)) = decoded else { + return self.fail_materialization(format!("media source {media_ref} decode failed")); + }; // ffmpeg emits straight RGBA; the plan's `needs_premultiply` flag (false // for image/video here) drives the shader, so the `premultiplied` marker // on the upload is informational only. @@ -779,6 +1005,91 @@ fn preview_render_size(canvas_w: i32, canvas_h: i32, cap: u32) -> RenderSize { RenderSize::new(even(cw * scale), even(ch * scale)) } +/// Derive cover candidate ordering from the same authoritative render plan used +/// by preview/export. Source materialization is deliberately deferred so an +/// offline or corrupt planned layer becomes `CaptureFailed`, not false +/// `NoVisibleContent`. +pub(crate) fn representative_timeline_frame( + timeline: &Timeline, + manifest: &opentake_domain::MediaManifest, + max_size: u32, +) -> Result, String> { + Ok(authoritative_render_plan(timeline, manifest, max_size)?.representative_frame(timeline)) +} + +fn authoritative_render_plan( + timeline: &Timeline, + manifest: &opentake_domain::MediaManifest, + max_size: u32, +) -> Result { + let mut sizes = HashMap::new(); + let mut straight_alpha = HashSet::new(); + for entry in &manifest.entries { + if entry.carries_straight_alpha() { + straight_alpha.insert(entry.id.clone()); + } + if let (Some(width), Some(height)) = (entry.source_width, entry.source_height) { + if width > 0 && height > 0 { + sizes.insert(entry.id.clone(), (width as u32, height as u32)); + } + } + } + let render_size = preview_render_size(timeline.width, timeline.height, max_size); + let plan = try_build_render_plan( + timeline, + render_size, + &ManifestMetrics { + sizes, + straight_alpha, + }, + ) + .map_err(|error| format!("invalid timeline graph: {error}"))?; + Ok(plan) +} + +/// Count meaningful visual clips through the authoritative flattened render +/// plan. Each plan entry is evaluated in isolation so a transparent or +/// degenerate clip is not made visible merely by a neighboring transition. +pub(crate) fn authoritative_visible_clip_count( + timeline: &Timeline, + manifest: &opentake_domain::MediaManifest, +) -> Result { + let plan = authoritative_render_plan(timeline, manifest, AGENT_TIMELINE_RESULT_MAX_DIMENSION)?; + let clip_count = plan + .clip_plans + .iter() + .filter(|clip| { + RenderPlan { + fps: plan.fps, + render_size: plan.render_size, + total_frames: plan.total_frames, + clip_plans: vec![(*clip).clone()], + text_plans: Vec::new(), + audio_clips: Vec::new(), + } + .representative_frame(timeline) + .is_some() + }) + .count(); + let text_count = plan + .text_plans + .iter() + .filter(|clip| { + RenderPlan { + fps: plan.fps, + render_size: plan.render_size, + total_frames: plan.total_frames, + clip_plans: Vec::new(), + text_plans: vec![(*clip).clone()], + audio_clips: Vec::new(), + } + .representative_frame(timeline) + .is_some() + }) + .count(); + Ok(clip_count + text_count) +} + /// Encode an RGBA composite as PNG bytes. Shared by the preview data-URL path /// and the capture-to-media on-disk path. fn encode_png_bytes(frame: &DecodedFrame) -> Result, String> { @@ -802,6 +1113,239 @@ fn encode_png_data_url(frame: &DecodedFrame) -> Result { Ok(format!("data:image/png;base64,{b64}")) } +fn timeline_timecode(frame: i32, fps: i32) -> String { + let fps = fps.max(1); + let frame = frame.max(0); + let frames = frame % fps; + let total_seconds = frame / fps; + let seconds = total_seconds % 60; + let total_minutes = total_seconds / 60; + let minutes = total_minutes % 60; + let hours = total_minutes / 60; + format!("{hours:02}:{minutes:02}:{seconds:02}:{frames:02}") +} + +fn paint_empty_timeline_overlay(size: RenderSize, timecode: &str) -> DecodedFrame { + let width = size.width as usize; + let height = size.height as usize; + let mut rgba = vec![0_u8; width.saturating_mul(height).saturating_mul(4)]; + for pixel in rgba.chunks_exact_mut(4) { + pixel.copy_from_slice(&EMPTY_TIMELINE_BACKGROUND_RGBA); + } + + let mut paint = |x: i32, y: i32| { + if x < 0 || y < 0 || x >= width as i32 || y >= height as i32 { + return; + } + let offset = (y as usize * width + x as usize) * 4; + rgba[offset..offset + 4].copy_from_slice(&EMPTY_TIMELINE_MARKER_RGBA); + }; + + // A language-neutral empty-set marker: outlined circle plus diagonal slash. + let center_x = width as i32 / 2; + let center_y = height as i32 * 2 / 5; + let radius = (width.min(height) as i32 / 9).max(3); + let thickness = (radius / 7).max(1); + for y in center_y - radius - thickness..=center_y + radius + thickness { + for x in center_x - radius - thickness..=center_x + radius + thickness { + let dx = x - center_x; + let dy = y - center_y; + let distance_squared = dx * dx + dy * dy; + let outer = radius + thickness; + let inner = (radius - thickness).max(0); + let on_ring = distance_squared <= outer * outer && distance_squared >= inner * inner; + let on_slash = (dx + dy).abs() <= thickness && dx.abs().max(dy.abs()) <= radius; + if on_ring || on_slash { + paint(x, y); + } + } + } + + // Render the clamped playhead timecode with a deterministic 3x5 bitmap, + // avoiding locale/system-font dependencies in agent results. + fn glyph(character: char) -> [u8; 5] { + match character { + '0' => [0b111, 0b101, 0b101, 0b101, 0b111], + '1' => [0b010, 0b110, 0b010, 0b010, 0b111], + '2' => [0b111, 0b001, 0b111, 0b100, 0b111], + '3' => [0b111, 0b001, 0b111, 0b001, 0b111], + '4' => [0b101, 0b101, 0b111, 0b001, 0b001], + '5' => [0b111, 0b100, 0b111, 0b001, 0b111], + '6' => [0b111, 0b100, 0b111, 0b101, 0b111], + '7' => [0b111, 0b001, 0b010, 0b010, 0b010], + '8' => [0b111, 0b101, 0b111, 0b101, 0b111], + '9' => [0b111, 0b101, 0b111, 0b001, 0b111], + ':' => [0, 0b010, 0, 0b010, 0], + _ => [0; 5], + } + } + let scale = ((width / (timecode.len() * 4)).min(height / 24)).clamp(1, 4) as i32; + let advance = 4 * scale; + let text_width = advance * timecode.chars().count() as i32 - scale; + let origin_x = (width as i32 - text_width) / 2; + let origin_y = height as i32 * 7 / 10; + for (index, character) in timecode.chars().enumerate() { + for (row, bits) in glyph(character).into_iter().enumerate() { + for column in 0..3 { + if bits & (1 << (2 - column)) == 0 { + continue; + } + for dy in 0..scale { + for dx in 0..scale { + paint( + origin_x + index as i32 * advance + column * scale + dx, + origin_y + row as i32 * scale + dy, + ); + } + } + } + } + } + + DecodedFrame::new(size.width, size.height, rgba, true) +} + +struct TimelineResultTextureResolver { + texture: Rc, +} + +impl TextureResolver for TimelineResultTextureResolver { + fn resolve(&mut self, _source: &TextureSource, _source_frame: i64) -> Option> { + Some(self.texture.clone()) + } +} + +fn composite_empty_timeline_canvas( + render: &RenderState, + size: RenderSize, + timecode: &str, +) -> Result { + let canvas = paint_empty_timeline_overlay(size, timecode); + let mut guard = render + .ctx + .lock() + .map_err(|_| "render state lock poisoned".to_string())?; + if guard.is_none() { + let dev = RenderDevice::try_new().map_err(|error| format!("no GPU device: {error}"))?; + *guard = Some(GpuContext { + compositor: Compositor::new(&dev.device), + text_rasterizer: CosmicTextRasterizer::new(), + lottie: LottieMaterializer::new(), + device: dev.device, + queue: dev.queue, + }); + } + let ctx = guard.as_ref().expect("GPU context initialized above"); + let texture = Rc::new(upload_rgba( + &ctx.device, + &ctx.queue, + &canvas, + false, + Some("agent-empty-timeline"), + )); + let source = TextureSource::Image { + media_ref: "agent-empty-timeline".to_string(), + }; + let frame_plan = FramePlan { + clear_rgba: [ + EMPTY_TIMELINE_BACKGROUND_RGBA[0] as f64 / 255.0, + EMPTY_TIMELINE_BACKGROUND_RGBA[1] as f64 / 255.0, + EMPTY_TIMELINE_BACKGROUND_RGBA[2] as f64 / 255.0, + 1.0, + ], + draws: vec![LayerDraw { + source: &source, + source_frame: 0, + affine: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], + nat_size: (size.width as f64, size.height as f64), + crop_uv: (0.0, 0.0, 1.0, 1.0), + opacity: 1.0, + needs_premultiply: false, + clip_id: "agent-empty-timeline", + color_grade: None, + lut: None, + chroma_key: None, + masks: &[], + effects: &[], + }], + }; + let mut resolver = TimelineResultTextureResolver { texture }; + ctx.compositor + .render_to_rgba(&ctx.device, &ctx.queue, size, &frame_plan, &mut resolver) + .map_err(|error| format!("compose empty timeline: {error}")) +} + +/// Render the post-commit agent result through the Rust compositor. A +/// non-empty timeline delegates to the same strict authoritative compositor as +/// project capture; a genuinely empty render plan gets the explicit semantic +/// project canvas. +#[allow(clippy::too_many_arguments)] +pub(crate) fn render_timeline_result_png( + timeline: &Timeline, + manifest: &opentake_domain::MediaManifest, + project_dir: &Option, + render: &RenderState, + input: EmptyTimelineCanvasInput, + cancel: &MediaCancelToken, + authority: &CompositeSourceAuthority, +) -> Result { + if input.project_width != timeline.width + || input.project_height != timeline.height + || input.fps != timeline.fps + || input.project_width <= 0 + || input.project_height <= 0 + || input.fps <= 0 + { + return Err("empty timeline canvas does not match project snapshot".to_string()); + } + if cancel.is_cancelled() { + return Err("timeline result capture cancelled".to_string()); + } + let total_frames = timeline.total_frames(); + let playhead_frame = if total_frames <= 0 { + 0 + } else { + input.playhead_frame.clamp(0, total_frames - 1) + }; + let timecode = timeline_timecode(playhead_frame, input.fps); + let size = preview_render_size( + input.project_width, + input.project_height, + AGENT_TIMELINE_RESULT_MAX_DIMENSION, + ); + let empty_canvas = authoritative_visible_clip_count(timeline, manifest)? == 0; + let frame = if empty_canvas { + composite_empty_timeline_canvas(render, size, &timecode)? + } else { + composite_timeline_frame_authorized( + timeline, + manifest, + project_dir, + render, + playhead_frame, + AGENT_TIMELINE_RESULT_MAX_DIMENSION, + cancel, + authority, + )? + }; + if cancel.is_cancelled() { + return Err("timeline result capture cancelled".to_string()); + } + let bytes = encode_png_bytes(&frame)?; + if bytes.is_empty() || bytes.len() > AGENT_TIMELINE_RESULT_PNG_BYTES_MAX { + return Err("timeline result PNG exceeded the bounded payload".to_string()); + } + Ok(TimelineResultPng { + bytes, + media_type: "image/png", + width: frame.width, + height: frame.height, + playhead_frame, + timecode, + empty_canvas, + }) +} + /// Composite the timeline at `frame` into an RGBA frame at a size capped by /// `max_size` (longest side). Shared by [`composite_frame`] (which PNG-encodes it /// for the preview) and [`capture_frame_to_media`] (which writes it to disk and @@ -815,6 +1359,58 @@ pub fn composite_timeline_frame( frame: i32, max_size: u32, cancel: &MediaCancelToken, +) -> Result { + composite_timeline_frame_with_authority( + timeline, + manifest, + project_dir, + render, + frame, + max_size, + cancel, + None, + false, + ) +} + +/// Strict cover compositor: every planned draw must materialize from a retained +/// pre-authorized source handle. Missing/failed image, video, text, or Lottie +/// materialization is an error rather than a silently omitted layer. +#[allow(clippy::too_many_arguments)] +pub(crate) fn composite_timeline_frame_authorized( + timeline: &Timeline, + manifest: &opentake_domain::MediaManifest, + project_dir: &Option, + render: &RenderState, + frame: i32, + max_size: u32, + cancel: &MediaCancelToken, + authority: &CompositeSourceAuthority, +) -> Result { + composite_timeline_frame_with_authority( + timeline, + manifest, + project_dir, + render, + frame, + max_size, + cancel, + Some(authority), + true, + ) +} + +#[allow(clippy::too_many_arguments)] +fn composite_timeline_frame_with_authority( + timeline: &Timeline, + manifest: &opentake_domain::MediaManifest, + project_dir: &Option, + render: &RenderState, + frame: i32, + max_size: u32, + cancel: &MediaCancelToken, + authority: Option<&CompositeSourceAuthority>, + strict_materialization: bool, ) -> Result { // Project text clips (content + style + box) so the resolver can rasterize // them on demand. Keyed by clip id, matching `TextureSource::Text { clip_id }`. @@ -866,10 +1462,15 @@ pub fn composite_timeline_frame( sizes.insert(entry.id.clone(), (w as u32, h as u32)); } } + let retained = authority.and_then(|authority| authority.files.get(&entry.id)); + if strict_materialization && retained.is_none() { + continue; + } media.insert( entry.id.clone(), MediaInfo { path, + retained, source_fps: entry.source_fps, }, ); @@ -884,11 +1485,15 @@ pub fn composite_timeline_frame( let plan = try_build_render_plan(timeline, render_size, &metrics) .map_err(|error| format!("invalid timeline graph: {error}"))?; let frame_plan = plan.frame(timeline, frame); - let project_root = project_dir - .as_deref() - .map(ProjectRoot::open) - .transpose() - .map_err(|error| format!("open project LUT storage: {error}"))?; + let project_root = if strict_materialization { + None + } else { + project_dir + .as_deref() + .map(ProjectRoot::open) + .transpose() + .map_err(|error| format!("open project LUT storage: {error}"))? + }; // Acquire (or reuse) the GPU context, then composite + read back. The lock is // held across the render so the `Rc`-based texture cache never crosses threads. @@ -929,6 +1534,7 @@ pub fn composite_timeline_frame( project_root: project_root.as_ref(), lut_cache: &mut lut_cache, materialization_error: None, + strict_materialization, }; let interpolation = TextureInterpolationConfig::new( plan.fps as f64, @@ -949,7 +1555,7 @@ pub fn composite_timeline_frame( ) .map_err(|e| format!("composite render failed: {e}")); match resolver.materialization_error.take() { - Some(error) => Err(format!("Lottie materialization failed: {error}")), + Some(error) => Err(format!("layer materialization failed: {error}")), None => composite, } }; @@ -1061,6 +1667,9 @@ pub fn composite_frame( { return Err("preview composite was superseded".to_string()); } + if request.source_media_id.is_none() && request.sequence_id.is_none() { + record_root_timeline_playhead(request.project_epoch, request.frame); + } Ok(CompositeFrameDto { width: composite.width, height: composite.height, @@ -1595,6 +2204,147 @@ mod tests { assert_eq!(&bytes[..4], &[0x89, b'P', b'N', b'G']); } + #[test] + fn empty_timeline_result_png_is_bounded_and_contains_background_and_semantic_overlay() { + let timeline = Timeline { + width: 320, + height: 180, + fps: 24, + ..Timeline::new() + }; + + let rendered = render_timeline_result_png( + &timeline, + &MediaManifest::new(), + &None, + &RenderState::new(), + EmptyTimelineCanvasInput { + project_width: timeline.width, + project_height: timeline.height, + fps: timeline.fps, + playhead_frame: 10_000, + }, + &MediaCancelToken::new(), + &CompositeSourceAuthority::new(HashMap::new()), + ) + .expect("render deterministic empty canvas"); + + assert_eq!(rendered.media_type, "image/png"); + assert!(rendered.bytes.len() <= AGENT_TIMELINE_RESULT_PNG_BYTES_MAX); + let decoded = image::load_from_memory_with_format(&rendered.bytes, image::ImageFormat::Png) + .expect("decode result PNG") + .into_rgba8(); + assert_eq!(decoded.dimensions(), (320, 180)); + assert!(decoded.width().max(decoded.height()) <= AGENT_TIMELINE_RESULT_MAX_DIMENSION); + let pixels = decoded.pixels().collect::>(); + assert!(pixels + .iter() + .any(|pixel| pixel.0 == EMPTY_TIMELINE_BACKGROUND_RGBA)); + assert!(pixels + .iter() + .any(|pixel| pixel.0 == EMPTY_TIMELINE_MARKER_RGBA)); + assert_eq!(rendered.playhead_frame, 0); + assert_eq!(rendered.timecode, "00:00:00:00"); + } + + #[test] + fn authoritative_visible_count_uses_render_plan_source_surface_and_track_semantics() { + let mut timeline = Timeline { + width: 320, + height: 180, + fps: 24, + ..Timeline::new() + }; + let mut text = Clip::new("meaningful-text", "", 0, 24); + text.media_type = ClipType::Text; + text.source_clip_type = ClipType::Text; + text.text_content = Some(" ".into()); + text.text_style = Some(TextStyle::default()); + text.transform.width = 0.5; + text.transform.height = 0.5; + let mut track = Track::new("text", ClipType::Text); + track.clips.push(text); + timeline.tracks.push(track); + + assert_eq!( + authoritative_visible_clip_count(&timeline, &MediaManifest::new()).unwrap(), + 0, + "blank text has no meaningful render-plan source" + ); + timeline.tracks[0].clips[0].text_content = Some("visible".into()); + assert_eq!( + authoritative_visible_clip_count(&timeline, &MediaManifest::new()).unwrap(), + 1 + ); + timeline.tracks[0].clips[0].opacity = 0.0; + assert_eq!( + authoritative_visible_clip_count(&timeline, &MediaManifest::new()).unwrap(), + 0, + "zero-opacity surfaces are not visible" + ); + timeline.tracks[0].clips[0].opacity = 1.0; + timeline.tracks[0].hidden = true; + assert_eq!( + authoritative_visible_clip_count(&timeline, &MediaManifest::new()).unwrap(), + 0, + "hidden tracks never enter the authoritative render plan" + ); + } + + #[test] + fn empty_timeline_nonempty_fixture_uses_authoritative_timeline_compositor() { + let mut timeline = Timeline { + width: 320, + height: 180, + fps: 25, + ..Timeline::new() + }; + let mut text = Clip::new("fixture-text", "", 0, 25); + text.media_type = ClipType::Text; + text.source_clip_type = ClipType::Text; + text.text_content = Some("fixture".into()); + text.text_style = Some(TextStyle::default()); + text.transform.width = 0.5; + text.transform.height = 0.5; + let mut track = Track::new("fixture-track", ClipType::Text); + track.clips.push(text); + timeline.tracks.push(track); + let render = RenderState::new(); + let authority = CompositeSourceAuthority::new(HashMap::new()); + let cancel = MediaCancelToken::new(); + + let rendered = render_timeline_result_png( + &timeline, + &MediaManifest::new(), + &None, + &render, + EmptyTimelineCanvasInput { + project_width: timeline.width, + project_height: timeline.height, + fps: timeline.fps, + playhead_frame: 12, + }, + &cancel, + &authority, + ) + .expect("render non-empty fixture"); + let direct = composite_timeline_frame_authorized( + &timeline, + &MediaManifest::new(), + &None, + &render, + 12, + AGENT_TIMELINE_RESULT_MAX_DIMENSION, + &cancel, + &authority, + ) + .and_then(|frame| encode_png_bytes(&frame)) + .expect("direct authoritative render"); + + assert!(!rendered.empty_canvas); + assert_eq!(rendered.bytes, direct); + } + #[test] fn freeze_capture_snapshot_isolates_target_clip_and_media() { let mut timeline = Timeline::new(); diff --git a/src-tauri/src/secret.rs b/src-tauri/src/secret.rs index c065793c..866c4271 100644 --- a/src-tauri/src/secret.rs +++ b/src-tauri/src/secret.rs @@ -14,6 +14,64 @@ use tauri::State; use opentake_gen::{KeyStore, KeyringStore}; +/// Narrow secret storage boundary for persistent external MCP credentials. +/// Catalog metadata deliberately contains only a token digest; this boundary +/// keeps the token itself in the existing OpenTake keychain service. +#[allow(dead_code)] // Task 1 adds the seam before Task 4 wires the Tauri commands. +pub(crate) trait McpSecretStore: Send + Sync { + fn save_mcp_secret(&self, account: &str, value: &str) -> Result<(), String>; + fn load_mcp_secret(&self, account: &str) -> Result, String>; + fn delete_mcp_secret(&self, account: &str) -> Result<(), String>; +} + +impl McpSecretStore for KeyringStore { + fn save_mcp_secret(&self, account: &str, value: &str) -> Result<(), String> { + self.save(account, value).map_err(|error| error.to_string()) + } + + fn load_mcp_secret(&self, account: &str) -> Result, String> { + self.load(account).map_err(|error| error.to_string()) + } + + fn delete_mcp_secret(&self, account: &str) -> Result<(), String> { + self.delete(account).map_err(|error| error.to_string()) + } +} + +#[cfg(test)] +#[derive(Default)] +pub(crate) struct MemoryMcpSecretStore { + secrets: std::sync::Mutex>, +} + +#[cfg(test)] +impl McpSecretStore for MemoryMcpSecretStore { + fn save_mcp_secret(&self, account: &str, value: &str) -> Result<(), String> { + self.secrets + .lock() + .map_err(|_| "in-memory MCP secret store lock poisoned".to_string())? + .insert(account.to_owned(), value.to_owned()); + Ok(()) + } + + fn load_mcp_secret(&self, account: &str) -> Result, String> { + Ok(self + .secrets + .lock() + .map_err(|_| "in-memory MCP secret store lock poisoned".to_string())? + .get(account) + .cloned()) + } + + fn delete_mcp_secret(&self, account: &str) -> Result<(), String> { + self.secrets + .lock() + .map_err(|_| "in-memory MCP secret store lock poisoned".to_string())? + .remove(account); + Ok(()) + } +} + /// Masked status of a provider's stored key. `has_key` drives the UI; `masked` /// is the bullet-masked form (empty when there is no key). #[derive(Debug, Serialize)] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d740c1c1..5d8c3b3a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenTake", - "version": "1.0.0-beta.4", + "version": "1.0.0-beta.5", "identifier": "com.opentake.desktop", "build": { "frontendDist": "../web/dist", @@ -28,7 +28,7 @@ "hiddenTitle": true, "trafficLightPosition": { "x": 18, - "y": 24 + "y": 21 }, "dragDropEnabled": false, "transparent": true @@ -97,7 +97,7 @@ }, "windows": { "wix": { - "version": "1.0.0.4" + "version": "1.0.0.5" } } }, diff --git a/src-tauri/tests/external_mcp_integration.rs b/src-tauri/tests/external_mcp_integration.rs new file mode 100644 index 00000000..5abf3fad --- /dev/null +++ b/src-tauri/tests/external_mcp_integration.rs @@ -0,0 +1,537 @@ +use std::{ + fs, + net::{Ipv4Addr, TcpListener}, + path::{Path, PathBuf}, + time::Duration, +}; + +use opentake_gen::{KeyStore, KeyringStore}; +use opentake_tauri_lib::external_mcp::{ + ExternalMcpIntegrationHarness, ExternalMcpIntegrationReceipt, ExternalMcpListenerState, +}; +use rmcp::{ + model::CallToolRequestParams, + service::RunningService, + transport::{ + streamable_http_client::StreamableHttpClientTransportConfig, StreamableHttpClientTransport, + }, + RoleClient, ServiceExt, +}; + +const RUN_REAL_KEYCHAIN_ENV: &str = "OPENTAKE_RUN_REAL_KEYCHAIN_MCP"; +const PROCESS_EXIT_CHILD_ENV: &str = "OPENTAKE_MCP_PROCESS_EXIT_CHILD"; +const PROCESS_EXIT_ROOT_ENV: &str = "OPENTAKE_MCP_PROCESS_EXIT_ROOT"; +const PROCESS_EXIT_SERVICE_ENV: &str = "OPENTAKE_MCP_PROCESS_EXIT_SERVICE"; +const PROCESS_EXIT_PROJECT_ENV: &str = "OPENTAKE_MCP_PROCESS_EXIT_PROJECT"; +const ENDPOINT: &str = "http://127.0.0.1:19789/mcp"; +const PORT: u16 = 19_789; + +type RmcpClient = RunningService; + +struct ExactKeychainCleanup { + service: String, + accounts: Vec, +} + +impl ExactKeychainCleanup { + fn new(service: String) -> Self { + Self { + service, + accounts: Vec::new(), + } + } + + fn track(&mut self, receipt: &ExternalMcpIntegrationReceipt) { + self.accounts + .push(format!("external-mcp:{}", receipt.client_id)); + } + + fn cleanup_now(&mut self) { + let store = KeyringStore::with_service(self.service.clone()); + for account in self.accounts.drain(..) { + require( + store.delete(&account), + "delete exact integration credential", + ); + assert!( + require( + store.load(&account), + "verify integration credential cleanup" + ) + .is_none(), + "exact integration credential remains in the keychain" + ); + } + } +} + +impl Drop for ExactKeychainCleanup { + fn drop(&mut self) { + let store = KeyringStore::with_service(self.service.clone()); + for account in &self.accounts { + let _ = store.delete(account); + } + } +} + +#[derive(Clone, Default)] +struct CapturingSubscriber { + events: std::sync::Arc>>, + next_span: std::sync::Arc, +} + +struct StringVisitor<'a>(&'a mut String); + +impl tracing::field::Visit for StringVisitor<'_> { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + use std::fmt::Write as _; + + let _ = write!(self.0, " {}={value:?}", field.name()); + } +} + +impl tracing::Subscriber for CapturingSubscriber { + fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool { + true + } + + fn new_span(&self, span: &tracing::span::Attributes<'_>) -> tracing::span::Id { + let mut recorded = format!("span {}", span.metadata().name()); + span.record(&mut StringVisitor(&mut recorded)); + self.events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(recorded); + let id = self + .next_span + .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + + 1; + tracing::span::Id::from_u64(id) + } + + fn record(&self, _span: &tracing::span::Id, values: &tracing::span::Record<'_>) { + let mut recorded = "span record".to_string(); + values.record(&mut StringVisitor(&mut recorded)); + self.events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(recorded); + } + + fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {} + + fn event(&self, event: &tracing::Event<'_>) { + let mut recorded = event.metadata().name().to_owned(); + event.record(&mut StringVisitor(&mut recorded)); + self.events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(recorded); + } + + fn enter(&self, _span: &tracing::span::Id) {} + + fn exit(&self, _span: &tracing::span::Id) {} + + fn register_callsite( + &self, + _metadata: &'static tracing::Metadata<'static>, + ) -> tracing::subscriber::Interest { + tracing::subscriber::Interest::always() + } + + fn max_level_hint(&self) -> Option { + Some(tracing::level_filters::LevelFilter::TRACE) + } +} + +fn require(result: Result, message: &'static str) -> T { + result.unwrap_or_else(|_| panic!("{message}")) +} + +async fn connect_rmcp(token: &str) -> Result { + let config = StreamableHttpClientTransportConfig::with_uri(ENDPOINT).auth_header(token); + let transport = StreamableHttpClientTransport::from_config(config); + tokio::time::timeout(Duration::from_secs(5), ().serve(transport)) + .await + .map_err(|_| ())? + .map_err(|_| ()) +} + +async fn call_tool( + client: &RmcpClient, + name: &'static str, + arguments: serde_json::Map, +) -> rmcp::model::CallToolResult { + require( + tokio::time::timeout( + Duration::from_secs(5), + client.call_tool(CallToolRequestParams::new(name).with_arguments(arguments)), + ) + .await + .map_err(|_| ()) + .and_then(|result| result.map_err(|_| ())), + "rmcp tool call did not complete", + ) +} + +async fn close_rmcp(client: &mut RmcpClient) { + let _ = tokio::time::timeout(Duration::from_secs(3), client.close()).await; +} + +fn arguments(value: serde_json::Value) -> serde_json::Map { + value.as_object().cloned().expect("literal object") +} + +fn create_project(path: &Path) { + let core = opentake_core::AppCore::new(); + require( + core.save_project(Some(path.to_path_buf())), + "create integration project", + ); +} + +fn assert_port_closed() { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, PORT)) + .unwrap_or_else(|_| panic!("external MCP socket is still open")); + drop(listener); +} + +fn catalog_bytes(root: &Path) -> Vec { + let directory = root.join("external-mcp"); + let mut bytes = Vec::new(); + let Ok(entries) = fs::read_dir(directory) else { + return bytes; + }; + for entry in entries.flatten() { + if entry.file_type().is_ok_and(|kind| kind.is_file()) { + bytes.extend(require( + fs::read(entry.path()), + "read integration catalog file", + )); + } + } + bytes +} + +fn assert_tokens_absent(tokens: &[String], bytes: &[u8]) { + for token in tokens { + assert!( + !bytes + .windows(token.len()) + .any(|window| window == token.as_bytes()), + "generated credential leaked into captured bytes" + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn external_mcp_process_exit_child() { + if std::env::var_os(PROCESS_EXIT_CHILD_ENV).as_deref() != Some(std::ffi::OsStr::new("1")) { + return; + } + let root = PathBuf::from(require( + std::env::var(PROCESS_EXIT_ROOT_ENV), + "read child catalog root", + )); + let service = require( + std::env::var(PROCESS_EXIT_SERVICE_ENV), + "read child keychain service", + ); + let project = PathBuf::from(require( + std::env::var(PROCESS_EXIT_PROJECT_ENV), + "read child project path", + )); + let child = require( + ExternalMcpIntegrationHarness::new(&root, &service), + "construct process-exit child state", + ); + require(child.core().open_project(project), "open child project"); + child.initialize().await; + assert_eq!( + child.listener_state().await, + ExternalMcpListenerState::Listening + ); + std::process::exit(0); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn real_keychain_restart_and_security_matrix() { + if std::env::var_os(RUN_REAL_KEYCHAIN_ENV).as_deref() != Some(std::ffi::OsStr::new("1")) { + eprintln!("real keychain matrix skipped; set {RUN_REAL_KEYCHAIN_ENV}=1 to opt in"); + return; + } + let _ = rustls::crypto::ring::default_provider().install_default(); + let captured = CapturingSubscriber::default(); + let captured_events = captured.events.clone(); + require( + tracing::subscriber::set_global_default(captured), + "install integration tracing capture", + ); + + let root = require(tempfile::tempdir(), "create integration root"); + let namespace = uuid::Uuid::new_v4().simple().to_string(); + let service = format!("io.opentake.integration.external-mcp.{namespace}"); + let mut cleanup = ExactKeychainCleanup::new(service.clone()); + let project_a = root.path().join("A.opentake"); + let project_b = root.path().join("B.opentake"); + create_project(&project_a); + create_project(&project_b); + let mut tokens = Vec::new(); + let mut matrix_log = Vec::::new(); + + let first = require( + ExternalMcpIntegrationHarness::new(root.path(), &service), + "construct first external MCP state", + ); + require(first.core().open_project(&project_a), "open project A"); + require(first.set_enabled(true).await, "enable first endpoint"); + let paired = require( + first.pair("integration-primary").await, + "pair primary client", + ); + cleanup.track(&paired); + tokens.push(paired.bearer_token.clone()); + assert_eq!( + first.listener_state().await, + ExternalMcpListenerState::Listening + ); + + let mut session_a = require(connect_rmcp(&paired.bearer_token).await, "connect rmcp A"); + let mut session_b = require(connect_rmcp(&paired.bearer_token).await, "connect rmcp B"); + let tools = require(session_a.list_all_tools().await, "list rmcp tools"); + assert!(tools.iter().any(|tool| tool.name == "create_folder")); + + let created = call_tool( + &session_a, + "create_folder", + arguments(serde_json::json!({ "name": "owned-by-a" })), + ) + .await; + assert_ne!(created.is_error, Some(true)); + assert_eq!(first.core().media().folders.len(), 1); + let foreign_undo = call_tool(&session_b, "undo", serde_json::Map::new()).await; + assert_eq!(foreign_undo.is_error, Some(true)); + assert_eq!(first.core().media().folders.len(), 1); + let owner_undo = call_tool(&session_a, "undo", serde_json::Map::new()).await; + assert_ne!(owner_undo.is_error, Some(true)); + assert!(first.core().media().folders.is_empty()); + matrix_log.push("cross-session undo isolation: pass".to_string()); + close_rmcp(&mut session_a).await; + close_rmcp(&mut session_b).await; + require(first.shutdown().await, "stop first endpoint"); + assert_port_closed(); + + let restarted = require( + ExternalMcpIntegrationHarness::new(root.path(), &service), + "construct restarted external MCP state", + ); + require( + restarted.core().open_project(&project_a), + "reopen project A", + ); + restarted.initialize().await; + assert_eq!( + restarted.listener_state().await, + ExternalMcpListenerState::Listening + ); + let mut restored = require( + connect_rmcp(&paired.bearer_token).await, + "authenticate after restart", + ); + require( + restored.list_all_tools().await, + "use restarted rmcp session", + ); + matrix_log.push("authenticated catalog/keychain restart: pass".to_string()); + + let raw = reqwest::Client::new(); + let initialize = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": { "name": "boundary-probe", "version": "0" } + } + }); + let remote_host = require( + raw.post(ENDPOINT) + .bearer_auth(&paired.bearer_token) + .header("host", "attacker.example:19789") + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize) + .send() + .await, + "send remote Host probe", + ); + assert_eq!(remote_host.status(), reqwest::StatusCode::FORBIDDEN); + let remote_origin = require( + raw.post(ENDPOINT) + .bearer_auth(&paired.bearer_token) + .header("origin", "https://attacker.example") + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize) + .send() + .await, + "send remote Origin probe", + ); + assert_eq!(remote_origin.status(), reqwest::StatusCode::FORBIDDEN); + matrix_log.push("Host/Origin rejection: pass".to_string()); + + let peer = restored.peer().clone(); + let blocked = tokio::spawn(async move { + peer.call_tool( + CallToolRequestParams::new("import_media").with_arguments(arguments( + serde_json::json!({ + "source": { "bytes": "AA==", "mimeType": "image/png" }, + "name": "must-not-commit" + }), + )), + ) + .await + }); + require( + tokio::time::timeout(Duration::from_secs(3), restarted.wait_for_cancel_probe()) + .await + .map_err(|_| ()), + "blocking tool did not reach the project gate", + ); + let switch_core = restarted.core(); + let switch_target = project_b.clone(); + let switched = tokio::task::spawn_blocking(move || switch_core.open_project(switch_target)); + let blocked_result = require( + tokio::time::timeout(Duration::from_secs(5), blocked) + .await + .map_err(|_| ()) + .and_then(|result| result.map_err(|_| ())), + "project switch did not cancel active rmcp work", + ); + assert!( + blocked_result.is_err() || blocked_result.is_ok_and(|result| result.is_error == Some(true)) + ); + require( + require(switched.await, "join project switch"), + "switch to project B", + ); + assert!(restarted.cancel_probe_observed()); + assert!(restarted.core().media().entries.is_empty()); + matrix_log.push("project-switch cancellation: pass".to_string()); + close_rmcp(&mut restored).await; + + let survivor = require( + restarted.pair("integration-survivor").await, + "pair surviving client", + ); + cleanup.track(&survivor); + tokens.push(survivor.bearer_token.clone()); + let mut survivor_session = require( + connect_rmcp(&survivor.bearer_token).await, + "connect surviving client", + ); + require( + restarted.revoke(&paired.client_id).await, + "revoke primary client", + ); + assert_eq!( + restarted.listener_state().await, + ExternalMcpListenerState::Listening + ); + let revoked = connect_rmcp(&paired.bearer_token).await; + assert!(revoked.is_err(), "revoked credential authenticated"); + require( + survivor_session.list_all_tools().await, + "surviving credential stopped working after targeted revoke", + ); + close_rmcp(&mut survivor_session).await; + matrix_log.push("revoked credential rejection: pass".to_string()); + + require( + restarted.set_enabled(false).await, + "stop endpoint before fixed-port probe", + ); + let occupied = require( + tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, PORT)).await, + "occupy fixed port", + ); + require( + restarted.set_enabled(true).await, + "enable endpoint during port conflict", + ); + assert_eq!( + restarted.listener_state().await, + ExternalMcpListenerState::PortConflict + ); + matrix_log.push("fixed-port conflict: pass".to_string()); + drop(occupied); + + restarted.initialize().await; + assert_eq!( + restarted.listener_state().await, + ExternalMcpListenerState::Listening + ); + let mut final_client = require( + connect_rmcp(&survivor.bearer_token).await, + "connect after port conflict clears", + ); + require( + final_client.list_all_tools().await, + "use final rmcp session", + ); + close_rmcp(&mut final_client).await; + + require( + restarted.set_enabled(false).await, + "disable external endpoint", + ); + assert_port_closed(); + matrix_log.push("disable socket closure: pass".to_string()); + require( + restarted.set_enabled(true).await, + "re-enable external endpoint", + ); + require( + restarted.shutdown().await, + "release parent endpoint before process-exit child", + ); + assert_port_closed(); + let child_output = require( + std::process::Command::new(require( + std::env::current_exe(), + "resolve integration test executable", + )) + .arg("--exact") + .arg("external_mcp_process_exit_child") + .arg("--nocapture") + .env(PROCESS_EXIT_CHILD_ENV, "1") + .env(PROCESS_EXIT_ROOT_ENV, root.path()) + .env(PROCESS_EXIT_SERVICE_ENV, &service) + .env(PROCESS_EXIT_PROJECT_ENV, &project_b) + .output(), + "run process-exit child", + ); + assert!(child_output.status.success(), "process-exit child failed"); + assert_port_closed(); + matrix_log.push("application-exit socket closure: pass".to_string()); + + let mut log_bytes = matrix_log.join("\n").into_bytes(); + log_bytes.extend_from_slice(&child_output.stdout); + log_bytes.extend_from_slice(&child_output.stderr); + for event in captured_events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + { + log_bytes.extend_from_slice(event.as_bytes()); + log_bytes.push(b'\n'); + } + let persisted_bytes = catalog_bytes(root.path()); + assert_tokens_absent(&tokens, &log_bytes); + assert_tokens_absent(&tokens, &persisted_bytes); + cleanup.cleanup_now(); + eprintln!("{}", matrix_log.join("\n")); + eprintln!("credential scan: zero full-token matches in captured log/catalog bytes"); +} diff --git a/src-tauri/tests/motion_command.rs b/src-tauri/tests/motion_command.rs index 5545bec7..b2e9669f 100644 --- a/src-tauri/tests/motion_command.rs +++ b/src-tauri/tests/motion_command.rs @@ -57,7 +57,26 @@ fn sandbox_progress_cancel_validated_mp4_result() { *phases.lock().unwrap(), vec![ MotionProgress::Validating, - MotionProgress::Rendering, + MotionProgress::Rendering { + done_frames: 0, + total_frames: 4, + }, + MotionProgress::Rendering { + done_frames: 1, + total_frames: 4, + }, + MotionProgress::Rendering { + done_frames: 2, + total_frames: 4, + }, + MotionProgress::Rendering { + done_frames: 3, + total_frames: 4, + }, + MotionProgress::Rendering { + done_frames: 4, + total_frames: 4, + }, MotionProgress::Encoding, MotionProgress::Committing, MotionProgress::Complete, diff --git a/src-tauri/tests/motion_integration.rs b/src-tauri/tests/motion_integration.rs new file mode 100644 index 00000000..87a31123 --- /dev/null +++ b/src-tauri/tests/motion_integration.rs @@ -0,0 +1,234 @@ +use opentake_agent::mcp::motion::{MotionBridge, MotionBridgeErrorKind}; +use opentake_core::{AppCore, EditCommand}; +use opentake_domain::MediaResolver; +use opentake_tauri_lib::motion::{ + DocumentMotionAddRequest, DocumentMotionEditRequest, DocumentMotionSource, TauriMotionBridge, +}; + +fn live_motion_enabled() -> bool { + std::env::var("OPENTAKE_RUN_FFMPEG_TESTS").as_deref() == Ok("1") +} + +fn document_source(id: &str, revision: char, title: &str, accent: &str) -> DocumentMotionSource { + DocumentMotionSource { + document_id: id.to_string(), + revision_hash: revision.to_string().repeat(64), + html: format!(r#"

{title}

"#), + css: format!( + r#"html,body{{background:#08090b;color:white}}.stage{{width:100%;height:100%;display:grid;place-items:center}}h1{{font:700 18px sans-serif;color:{accent};animation:enter .6s both}}@keyframes enter{{from{{opacity:.08;transform:translateX(-20px)}}to{{opacity:1;transform:translateX(20px)}}}}"# + ), + } +} + +fn decode(path: &std::path::Path, time_secs: f64) -> Vec { + opentake_media::decode_frame_at( + path, + &opentake_media::FrameRequest { + time_secs, + max_size: (96, 54), + tolerance_secs: 0.0, + apply_rotation: true, + }, + ) + .expect("decode published Motion Studio frame") + .1 + .rgba +} + +#[test] +fn studio_document_publish_is_visible_atomic_editable_and_reopenable() { + if !live_motion_enabled() { + eprintln!("SKIP: set OPENTAKE_RUN_FFMPEG_TESTS=1 for live Studio publishing"); + return; + } + + let root = tempfile::tempdir().unwrap(); + let bundle = root.path().join("Studio.opentake"); + let core = AppCore::new(); + core.apply(EditCommand::SetTimelineSettings { + fps: 20, + width: 96, + height: 54, + }) + .unwrap(); + core.save_project(Some(bundle.clone())).unwrap(); + let bridge = TauriMotionBridge::new(core.clone(), root.path().join("cache")); + if !bridge.can_render_motion() { + eprintln!("SKIP: packaged Chromium/FFmpeg motion capability is unavailable"); + return; + } + + let added = bridge + .add_document( + DocumentMotionAddRequest { + source: document_source("doc-a", 'a', "真实字符 Real text", "#ff3366"), + project_authority: core.project_asset_authority().unwrap(), + width: 96, + height: 54, + fps: 10, + start_frame: 2, + duration_frames: 6, + track_index: None, + }, + &opentake_media::MediaCancelToken::new(), + ) + .unwrap(); + assert_eq!( + added + .source_document + .as_ref() + .map(|source| source.document_id.as_str()), + Some("doc-a") + ); + assert_eq!( + added + .source_document + .as_ref() + .map(|source| source.revision_hash.clone()), + Some("a".repeat(64)) + ); + + let snapshot = core.runtime_snapshot(); + let clip = snapshot + .timeline + .tracks + .iter() + .flat_map(|track| &track.clips) + .find(|clip| clip.id == added.clip_id) + .unwrap(); + assert_eq!((clip.start_frame, clip.duration_frames), (2, 12)); + let entry = snapshot + .media + .entries + .iter() + .find(|entry| entry.id == added.asset_id) + .unwrap(); + let published = MediaResolver::new(&snapshot.media, snapshot.project_dir.as_deref()) + .expected_path(&entry.id) + .unwrap(); + let beginning = decode(&published, 0.0); + let middle = decode(&published, 0.3); + let end = decode(&published, 0.5); + assert_ne!( + beginning, middle, + "published CSS animation must move over time" + ); + assert_ne!( + middle, end, + "middle/end frames must remain time-addressable" + ); + assert!( + middle + .chunks_exact(4) + .map(|pixel| [pixel[0], pixel[1], pixel[2]]) + .collect::>() + .len() + > 4, + "published frame must contain real glyph and scene pixels" + ); + + let edited = bridge + .edit_document( + DocumentMotionEditRequest { + clip_id: added.clip_id.clone(), + source: document_source("doc-a", 'b', "更新字符 Updated", "#33ccff"), + project_authority: core.project_asset_authority().unwrap(), + width: 96, + height: 54, + fps: 10, + duration_frames: 6, + }, + &opentake_media::MediaCancelToken::new(), + ) + .unwrap(); + assert_eq!(edited.clip_id, added.clip_id); + assert_ne!(edited.asset_id, added.asset_id); + assert_eq!( + core.media().entries.len(), + 2, + "one edit registers exactly one replacement asset" + ); + assert_eq!( + edited + .source_document + .as_ref() + .map(|source| source.revision_hash.clone()), + Some("b".repeat(64)) + ); + + let edited_snapshot = core.runtime_snapshot(); + let edited_entry = edited_snapshot + .media + .entries + .iter() + .find(|entry| entry.id == edited.asset_id) + .unwrap(); + let edited_path = MediaResolver::new( + &edited_snapshot.media, + edited_snapshot.project_dir.as_deref(), + ) + .expected_path(&edited_entry.id) + .unwrap(); + let edited_middle = decode(&edited_path, 0.3); + + core.save_project(None).unwrap(); + let reopened = AppCore::new(); + reopened.open_project(bundle).unwrap(); + let reopened_snapshot = reopened.runtime_snapshot(); + assert_eq!(reopened_snapshot.timeline, core.runtime_snapshot().timeline); + assert_eq!(reopened_snapshot.media, core.runtime_snapshot().media); + let reopened_entry = reopened_snapshot + .media + .entries + .iter() + .find(|entry| entry.id == edited.asset_id) + .unwrap(); + let reopened_path = MediaResolver::new( + &reopened_snapshot.media, + reopened_snapshot.project_dir.as_deref(), + ) + .expected_path(&reopened_entry.id) + .unwrap(); + assert_eq!(decode(&reopened_path, 0.3), edited_middle); + + let before_cancel = core.runtime_snapshot(); + let cancelled = opentake_media::MediaCancelToken::new(); + cancelled.cancel(); + let error = bridge + .add_document( + DocumentMotionAddRequest { + source: document_source("doc-cancel", 'c', "cancel", "#ffffff"), + project_authority: core.project_asset_authority().unwrap(), + width: 96, + height: 54, + fps: 10, + start_frame: 0, + duration_frames: 2, + track_index: None, + }, + &cancelled, + ) + .unwrap_err(); + assert_eq!(error.kind, MotionBridgeErrorKind::Cancelled); + assert_eq!(core.runtime_snapshot().timeline, before_cancel.timeline); + assert_eq!(core.runtime_snapshot().media, before_cancel.media); + + let invalid = bridge + .add_document( + DocumentMotionAddRequest { + source: document_source("doc-invalid", 'd', "invalid", "#ffffff"), + project_authority: core.project_asset_authority().unwrap(), + width: 1, + height: 54, + fps: 10, + start_frame: 0, + duration_frames: 2, + track_index: None, + }, + &opentake_media::MediaCancelToken::new(), + ) + .unwrap_err(); + assert_eq!(invalid.kind, MotionBridgeErrorKind::InvalidArguments); + assert_eq!(core.runtime_snapshot().timeline, before_cancel.timeline); + assert_eq!(core.runtime_snapshot().media, before_cancel.media); +} diff --git a/src-tauri/tests/security_config.rs b/src-tauri/tests/security_config.rs index 35843cbe..3c82f147 100644 --- a/src-tauri/tests/security_config.rs +++ b/src-tauri/tests/security_config.rs @@ -122,6 +122,15 @@ fn main_window_capability_exposes_no_shell_or_filesystem_commands() { && !permission.starts_with("http:") && !permission.starts_with("process:") })); + + assert!( + permissions.contains(&"core:window:allow-set-size"), + "Appearance settings must be allowed to resize the packaged main window" + ); + assert!( + permissions.contains(&"core:window:allow-set-position"), + "Appearance settings must be allowed to recenter the packaged main window" + ); } #[test] diff --git a/web/index.html b/web/index.html index 03fccdef..54090df5 100644 --- a/web/index.html +++ b/web/index.html @@ -3,6 +3,7 @@ + OpenTake diff --git a/web/package.json b/web/package.json index ebc86f7b..c385d94b 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "opentake-web", "private": true, - "version": "1.0.0-beta.4", + "version": "1.0.0-beta.5", "type": "module", "scripts": { "dev": "vite", @@ -10,8 +10,13 @@ "test": "vitest run" }, "dependencies": { + "@codemirror/lang-css": "6.3.1", + "@codemirror/lang-html": "6.4.12", + "@codemirror/state": "6.7.1", + "@codemirror/theme-one-dark": "6.1.3", "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-dialog": "^2.7.1", + "codemirror": "6.0.2", "lucide-react": "^0.468.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 337f0136..554e0ee8 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -8,12 +8,27 @@ importers: .: dependencies: + '@codemirror/lang-css': + specifier: 6.3.1 + version: 6.3.1 + '@codemirror/lang-html': + specifier: 6.4.12 + version: 6.4.12 + '@codemirror/state': + specifier: 6.7.1 + version: 6.7.1 + '@codemirror/theme-one-dark': + specifier: 6.1.3 + version: 6.1.3 '@tauri-apps/api': specifier: ^2.11.1 version: 2.11.1 '@tauri-apps/plugin-dialog': specifier: ^2.7.1 version: 2.7.1 + codemirror: + specifier: 6.0.2 + version: 6.0.2 lucide-react: specifier: ^0.468.0 version: 0.468.0(react@18.3.1) @@ -57,6 +72,39 @@ importers: packages: + '@codemirror/autocomplete@6.20.3': + resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} + + '@codemirror/commands@6.10.4': + resolution: {integrity: sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==} + + '@codemirror/lang-css@6.3.1': + resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} + + '@codemirror/lang-html@6.4.12': + resolution: {integrity: sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==} + + '@codemirror/lang-javascript@6.2.5': + resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==} + + '@codemirror/language@6.12.4': + resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==} + + '@codemirror/lint@6.9.7': + resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==} + + '@codemirror/search@6.7.1': + resolution: {integrity: sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==} + + '@codemirror/state@6.7.1': + resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==} + + '@codemirror/theme-one-dark@6.1.3': + resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==} + + '@codemirror/view@6.43.8': + resolution: {integrity: sha512-qtItTDssZ/5GFfi94hrILu9j/VUeFPDPkhovEfmWFj2ipTxnzPB8DdHgfbb8HYTzLTYhrndKmyQxXUz/PDLenw==} + '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} @@ -69,6 +117,27 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@lezer/common@1.5.2': + resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + + '@lezer/css@1.3.6': + resolution: {integrity: sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/html@1.3.13': + resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==} + + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + + '@lezer/lr@1.4.10': + resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} + + '@marijn/find-cluster-break@1.0.3': + resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==} + '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: @@ -347,9 +416,15 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + codemirror@6.0.2: + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + crelt@1.0.7: + resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -531,6 +606,9 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -641,6 +719,9 @@ packages: jsdom: optional: true + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} @@ -682,6 +763,89 @@ packages: snapshots: + '@codemirror/autocomplete@6.20.3': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + + '@codemirror/commands@6.10.4': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + + '@codemirror/lang-css@6.3.1': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.6 + + '@codemirror/lang-html@6.4.12': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.6 + '@lezer/html': 1.3.13 + + '@codemirror/lang-javascript@6.2.5': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + '@lezer/javascript': 1.5.4 + + '@codemirror/language@6.12.4': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + style-mod: 4.1.3 + + '@codemirror/lint@6.9.7': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + crelt: 1.0.7 + + '@codemirror/search@6.7.1': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + crelt: 1.0.7 + + '@codemirror/state@6.7.1': + dependencies: + '@marijn/find-cluster-break': 1.0.3 + + '@codemirror/theme-one-dark@6.1.3': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + '@lezer/highlight': 1.2.3 + + '@codemirror/view@6.43.8': + dependencies: + '@codemirror/state': 6.7.1 + crelt: 1.0.7 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -700,6 +864,36 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@lezer/common@1.5.2': {} + + '@lezer/css@1.3.6': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/html@1.3.13': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/lr@1.4.10': + dependencies: + '@lezer/common': 1.5.2 + + '@marijn/find-cluster-break@1.0.3': {} + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -904,8 +1098,20 @@ snapshots: chai@6.2.2: {} + codemirror@6.0.2: + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/commands': 6.10.4 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/search': 6.7.1 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.8 + convert-source-map@2.0.0: {} + crelt@1.0.7: {} + csstype@3.2.3: {} detect-libc@2.1.2: {} @@ -1062,6 +1268,8 @@ snapshots: std-env@4.1.0: {} + style-mod@4.1.3: {} + tinybench@2.9.0: {} tinyexec@1.2.4: {} @@ -1119,6 +1327,8 @@ snapshots: transitivePeerDependencies: - msw + w3c-keyname@2.2.8: {} + whatwg-mimetype@3.0.0: {} why-is-node-running@2.3.0: diff --git a/web/src/App.lifecycle.test.tsx b/web/src/App.lifecycle.test.tsx index c5df4fe9..f702a709 100644 --- a/web/src/App.lifecycle.test.tsx +++ b/web/src/App.lifecycle.test.tsx @@ -45,11 +45,14 @@ vi.mock("./components/preview/nativePlaybackSession", () => ({ stopNativePlaybackForProjectBoundary: srv.stopNativePlayback, })); vi.mock("./i18n", () => ({ initI18n: vi.fn() })); -vi.mock("./store/settingsStore", () => ({ +const settings = vi.hoisted(() => ({ initProxyPlayback: vi.fn(), - initTheme: vi.fn(), initWindowSize: vi.fn(), })); +vi.mock("./store/settingsStore", () => ({ + initProxyPlayback: settings.initProxyPlayback, + initWindowSize: settings.initWindowSize, +})); vi.mock("./hooks/useKeyboardShortcuts", () => ({ useKeyboardShortcuts: vi.fn() })); vi.mock("./components/preview/previewEngine", () => ({ useTimelinePlaybackEngine: vi.fn() })); vi.mock("./hooks/useAutosave", () => ({ useAutosave: vi.fn() })); @@ -112,6 +115,21 @@ vi.mock("./components/media/LibraryView", async () => { }, }; }); +vi.mock("./components/motion/MotionStudio", async () => { + const React = await vi.importActual("react"); + return { + MotionStudio: () => { + const [count, setCount] = React.useState(0); + return ( +
+ +
+ ); + }, + }; +}); vi.mock("./components/shell/ViewMenu", () => ({ ApplicationMenuBridge: () => null })); import App from "./App"; @@ -131,6 +149,8 @@ describe("App lifecycle listeners", () => { srv.stopLibrarySync.mockReset(); srv.onGoHome.mockReset().mockResolvedValue(vi.fn()); srv.stopNativePlayback.mockReset().mockResolvedValue(undefined); + settings.initProxyPlayback.mockReset(); + settings.initWindowSize.mockReset(); container = document.createElement("div"); document.body.append(container); root = createRoot(container); @@ -166,6 +186,13 @@ describe("App lifecycle listeners", () => { expect(srv.stopMediaSync).toHaveBeenCalledOnce(); }); + it("initializes persisted window preferences without a theme initializer", async () => { + await act(async () => root?.render()); + + expect(settings.initWindowSize).toHaveBeenCalledOnce(); + expect(settings.initProxyPlayback).toHaveBeenCalledOnce(); + }); + it("ignores an old go-home callback after its owning effect is disposed", async () => { const registration = deferred<() => void>(); let goHome: (() => void) | null = null; @@ -365,6 +392,23 @@ describe("App lifecycle listeners", () => { expect(srv.startLibrarySync).toHaveBeenCalledTimes(2); }); + it("mounts only Motion Studio as active and preserves the visited Chat editor", async () => { + useEditorUiStore.setState({ view: "editor" }); + await act(async () => root?.render()); + const editorState = container.querySelector('[data-testid="editor-state"]')!; + await act(async () => editorState.click()); + + await act(async () => useEditorUiStore.getState().setView("motion")); + const active = container.querySelector('[data-app-view="motion"]'); + expect(active?.hidden).toBe(false); + expect(active?.querySelector('[data-testid="motion-studio"]')).not.toBeNull(); + expect(container.querySelector('[data-app-view="editor"]')?.hidden).toBe(true); + expect(container.querySelectorAll('[data-app-view]:not([hidden])')).toHaveLength(1); + + await act(async () => useEditorUiStore.getState().setView("editor")); + expect(container.querySelector('[data-testid="editor-state"]')?.textContent).toBe("1"); + }); + it("stops playback before a go-home callback changes the view", async () => { let goHome: (() => void) | null = null; srv.onGoHome.mockImplementationOnce(async (handler: () => void) => { diff --git a/web/src/App.tsx b/web/src/App.tsx index 4e275c37..5dacbeb7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,6 +10,7 @@ import { HomeView } from "./components/home/HomeView"; import { SettingsView } from "./components/settings/SettingsView"; import { UpdateCenter } from "./components/settings/UpdateDialog"; import { LibraryView } from "./components/media/LibraryView"; +import { MotionStudio } from "./components/motion/MotionStudio"; import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; import { useTimelinePlaybackEngine } from "./components/preview/previewEngine"; import { useAutosave } from "./hooks/useAutosave"; @@ -18,7 +19,7 @@ import { startMediaSync, stopMediaSync } from "./store/mediaStore"; import { startLibrarySync, stopLibrarySync } from "./store/libraryStore"; import { useEditorUiStore } from "./store/uiStore"; import { initI18n } from "./i18n"; -import { initProxyPlayback, initTheme, initWindowSize } from "./store/settingsStore"; +import { initProxyPlayback, initWindowSize } from "./store/settingsStore"; import { isTauri, onGoHome } from "./lib/api"; import { stopNativePlaybackForProjectBoundary } from "./components/preview/nativePlaybackSession"; import { useUpdateStore } from "./store/updateStore"; @@ -67,12 +68,22 @@ function Toast() { ); } -const PRIMARY_VIEWS = ["home", "library", "editor"] as const; +const PRIMARY_VIEWS = ["home", "library", "editor", "motion"] as const; type PrimaryView = (typeof PRIMARY_VIEWS)[number]; function PrimaryViewContent({ view }: { view: PrimaryView }) { if (view === "home") return ; if (view === "library") return ; + if (view === "motion") { + return ( + <> + +
+ +
+ + ); + } return ( <> @@ -95,13 +106,12 @@ export default function App() { const view = useEditorUiStore((s) => s.view); const settingsOpen = useEditorUiStore((s) => s.settingsOpen); const activePrimaryView: PrimaryView = - view === "home" || view === "library" ? view : "editor"; + view === "home" || view === "library" || view === "motion" ? view : "editor"; const mountedPrimaryViews = useRef(new Set()); mountedPrimaryViews.current.add(activePrimaryView); useEffect(() => { initI18n(); - initTheme(); initWindowSize(); initProxyPlayback(); const stopUpdateScheduler = isTauri diff --git a/web/src/components/agent/AgentConversation.test.tsx b/web/src/components/agent/AgentConversation.test.tsx new file mode 100644 index 00000000..cceda992 --- /dev/null +++ b/web/src/components/agent/AgentConversation.test.tsx @@ -0,0 +1,441 @@ +// @vitest-environment happy-dom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + MAX_CHAT_IMAGE_BASE64_CHARS, + type ChatMessage, +} from "../../lib/types"; +import { AssistantTurn, ConversationMessage } from "./AgentPanel"; + +vi.mock("../../i18n", () => ({ + useT: () => (key: string, values?: Record) => + values ? `${key}:${Object.values(values).join(":")}` : key, +})); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query === "(prefers-reduced-motion: reduce)", + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +function assistant(overrides: Partial = {}): ChatMessage { + return { + id: "assistant-ordered", + role: "assistant", + content: "legacy content must not render", + toolCalls: [ + { id: "legacy-tool", name: "legacy_tool", args: { ignored: true } }, + ], + blocks: [ + { type: "text", text: "Text A" }, + { + type: "toolUse", + id: "tool-1", + name: "inspect_timeline", + input: { frame: 42 }, + }, + { + type: "toolResult", + toolUseId: "tool-1", + content: [ + { kind: "image", mediaType: "image/png", base64: "iVBORw0KGgo=" }, + { kind: "text", text: "Timeline inspected" }, + ], + }, + { type: "text", text: "Text B" }, + ], + createdAt: 1, + ...overrides, + }; +} + +async function render(message: ChatMessage) { + await act(async () => root.render()); +} + +describe("AssistantTurn", () => { + it("renders authoritative blocks in exact order without assistant bubble or tool-card chrome", async () => { + await render(assistant()); + + const turn = container.querySelector("[data-assistant-turn]"); + expect(turn).not.toBeNull(); + expect( + Array.from(turn?.querySelectorAll("[data-agent-block-index]") ?? []).map( + (block) => [block.dataset.agentBlockIndex, block.dataset.agentBlockType], + ), + ).toEqual([ + ["0", "text"], + ["1", "toolUse"], + ["2", "toolResult"], + ["3", "text"], + ]); + expect(turn?.textContent).toContain("Text A"); + expect(turn?.textContent).toContain("Text B"); + expect(turn?.textContent).not.toContain("legacy content must not render"); + expect(turn?.textContent).not.toContain("legacy_tool"); + expect(turn?.classList.contains("agent-message__bubble")).toBe(false); + expect(container.querySelector(".agent-tool-card")).toBeNull(); + expect( + Array.from(container.querySelectorAll("[data-tool-activity]")).every( + (activity) => activity.style.border === "", + ), + ).toBe(true); + }); + + it("exposes arguments, result text, and a bounded raster image through accessible disclosures", async () => { + await render(assistant()); + const triggers = Array.from( + container.querySelectorAll("[data-tool-activity-trigger]"), + ); + expect(triggers).toHaveLength(2); + expect(triggers[0].getAttribute("aria-expanded")).toBe("false"); + expect(triggers[0].getAttribute("aria-controls")).toBeTruthy(); + expect(document.getElementById(triggers[0].getAttribute("aria-describedby")!)?.textContent) + .toBe("agent.toolRunning"); + + await act(async () => triggers[0].click()); + expect(triggers[0].getAttribute("aria-expanded")).toBe("true"); + const argsRegion = document.getElementById(triggers[0].getAttribute("aria-controls")!); + expect(argsRegion?.textContent).toContain('"frame": 42'); + + await act(async () => triggers[1].click()); + const resultRegion = document.getElementById(triggers[1].getAttribute("aria-controls")!); + expect(resultRegion?.textContent).toContain("Timeline inspected"); + const image = resultRegion?.querySelector("img"); + expect(image?.getAttribute("src")).toBe("data:image/png;base64,iVBORw0KGgo="); + expect(image?.getAttribute("alt")).toBe("agent.toolImageAlt:inspect_timeline"); + expect(image?.classList.contains("agent-tool-activity__image")).toBe(true); + }); + + it("renders a Codex MCP raster result inside its tool disclosure without dumping base64 text", async () => { + await render(assistant({ + content: "", + toolCalls: [], + blocks: [{ + type: "toolUse", + id: "clear-timeline", + name: "remove_clips", + input: { clipIds: ["clip-1"] }, + result: { + content: [ + { kind: "text", text: "Removed 1 clip" }, + { kind: "image", mediaType: "image/png", base64: "iVBORw0KGgo=" }, + ], + }, + isError: false, + }], + })); + + const trigger = container.querySelector("[data-tool-activity-trigger]")!; + await act(async () => trigger.click()); + + expect(container.textContent).toContain("Removed 1 clip"); + expect(container.querySelector("img")?.getAttribute("src")).toBe( + "data:image/png;base64,iVBORw0KGgo=", + ); + expect(container.querySelector("pre")?.textContent).not.toContain("iVBORw0KGgo="); + }); + + it.each([ + [ + "an oversized image", + { + content: [{ + kind: "image", + mediaType: "image/png", + base64: `PRIVATE_OVERSIZED_${"A".repeat(MAX_CHAT_IMAGE_BASE64_CHARS)}`, + }], + }, + "PRIVATE_OVERSIZED_", + "agent.toolResultUnavailable", + ], + [ + "too many blocks", + { + content: Array.from({ length: 65 }, (_, index) => ({ + kind: "text", + text: `PRIVATE_BLOCK_${index}`, + })), + }, + "PRIVATE_BLOCK_64", + "agent.toolResultUnavailable", + ], + [ + "malformed base64", + { + content: [{ + kind: "image", + mediaType: "image/png", + base64: "PRIVATE_MALFORMED_