Files
Jarvis/App/Vision/AppDelegateFrameAttachAdapters.swift
james.maesandClaude Opus 4.7 87aec282a0 feat(09-02): wire FrameAttachController + Bus frameAttachRequested + broadcaster release subscriber (D-15, D-16, INT-07-04)
- App/Vision/AppDelegateFrameAttachAdapters.swift (NEW): FrameAttachCaptureSourceAdapter wraps CameraCapture, FrameAttachReplaySinkAdapter wraps Replay.ReplayLog (placeholders log via JarvisLogChannel.replay — see file docstring for FK rationale), FrameAttachControllerReplaySinkBridge bridges Vision's FrameAttachReplaySink actor to FrameAttachController.ReplaySink protocol
- AppDelegate: new frameAttachController + frameAttachReleaseTask properties; installVision now constructs FrameAttachController (D-15); installAgent waits for visionInstallTask before constructing the orchestrator and passes visionRouter:; new step 5d frame-attach release subscriber subscribes broadcaster's .frameAttach priority and fires onAssistantTurnComplete() on .turnEnd for image-bearing turns (D-16)
- AppDelegate: tryPhraseAttachIfMatch(_:) helper exposed for Plan 4's chatSubmit + chatCancelAndSubmit handlers (WARNING-4: must call from BOTH paths)
- AppDelegate.installBus: bridge.onInbound now switches on BusInbound; .frameAttachRequested → frameAttachController.requestAttach(reason: .hudButton)
- BusInbound: additive .frameAttachRequested case + Discriminator + Codable arms (NO default — exhaustive-switch drift preventer); BUS_PROTOCOL_VERSION 2.1.0 → 2.2.0 in Swift + TS mirror
- TS protocol: matching frameAttachRequested case + decoder arm; round-trip.test.ts asserts version 2.2.0 + decodes new fixture
- New fixtures (parity): packages/Bus/Tests/BusTests/Fixtures/frameAttachRequested.json + webview/packages/bus/fixtures/frameAttachRequested.json (byte-identical, no trailing newline per check-bus-protocol-version.sh)
- Memory package bumped from .v13 to .v14 (cascade: Memory depends on AgentCore which now requires v14 due to JarvisVision dependency added in Task 1)
- xcodegen regen for the new App/Vision/AppDelegateFrameAttachAdapters.swift source file
- 2 new FrameAttachReleaseSubscriberTests prove broadcaster→onAssistantTurnComplete glue (image-bearing turns release once; text-only turns NEVER release); subscription uses devOverlay priority for the sync wait to avoid competing with broadcaster's drain on orch.events
- 1 new CodableRoundTripTests entry (test_roundTrip_frameAttachRequested)
- Phase 7 SOLE-emission-site invariant intact: FrameAttachDiscardSiteGrepTests passes; check-presence-vision-isolation.sh exits 0; orchestrator-events single-consumer grep gate exits 0; 65 AgentOrchestratorTests + 51 BusTests + 34 webview tests all green
- App target compiles cleanly (check-app-builds.sh exits 0)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 19:41:29 -05:00

87 lines
3.8 KiB
Swift

import Foundation
import JarvisVision
import Replay
import AgentCore
import Logging
import JarvisLogging
/// Plan 09-02 / D-15 — App-target adapters that bridge production sinks to
/// FrameAttachController's nested protocols. Two thin wrappers, no business
/// logic.
///
/// **CameraCapture** (a `JarvisVision.actor`) already has a public
/// `captureFrame() async throws -> CapturedFrame` method that matches
/// `FrameAttachController.CaptureSource` shape. We supply a small adapter
/// rather than declaring conformance on `CameraCapture` itself so the
/// VISION-03 isolation gate stays on the Vision side and AppDelegate owns
/// the bridging concern.
///
/// **FrameAttachReplaySink** (the concrete `JarvisVision.actor`) accepts a
/// `ReplayLogProtocol`-conforming object — Replay's `ReplayLog` does not
/// itself conform, so we wrap it. The wrapper records the placeholder
/// payload via `ReplayEvent.userInput(payload)` against a synthetic
/// `TurnID` derived from the recording instant; the SOLE-emission-site
/// invariant for raw-byte-discard lives in
/// `packages/Vision/Sources/Vision/FrameAttachReplaySink.swift`, NOT here.
///
/// Both adapters are `Sendable` (their wrapped peers are actors / `Sendable`
/// classes); the structs themselves carry only references.
/// Wraps `CameraCapture` to satisfy `FrameAttachController.CaptureSource`.
/// One-shot capture — delegates straight through.
struct FrameAttachCaptureSourceAdapter: FrameAttachController.CaptureSource {
let cameraCapture: CameraCapture
func captureFrame() async throws -> CapturedFrame {
try await cameraCapture.captureFrame()
}
}
/// Wraps `Replay.ReplayLog` to satisfy
/// `FrameAttachReplaySink.ReplayLogProtocol`. The placeholder is logged
/// via `JarvisLogChannel.replay` rather than persisted into the SQLite
/// events table — `recordPlaceholder(_:)` does not receive a `TurnID`
/// (the placeholder is generated at confirm-send time, BEFORE the
/// orchestrator allocates the turn), and `events.turn_id` is a non-null
/// FK to `turns(turn_id)`. Emitting under a synthetic id would fail the
/// FK at flush time.
///
/// A follow-on plan will thread the upcoming TurnID through (the chat
/// handler invokes `requestAttach` BEFORE `submit`), at which point this
/// adapter can write through `replayLog.record(.userInput(payload), for:)`.
/// The privacy invariant — no raw bytes ever reach this adapter, only the
/// pre-formatted placeholder payload from
/// `FrameAttachReplaySink.placeholder(for:)` — is unaffected by the choice
/// of sink (log vs SQLite); the `FrameAttachDiscardSiteGrepTests` gate
/// guards the producer side, not the consumer.
struct FrameAttachReplaySinkAdapter: FrameAttachReplaySink.ReplayLogProtocol {
let replayLog: ReplayLog
private let logger = Logger(label: JarvisLogChannel.replay.rawValue)
func recordPlaceholder(_ payload: Data) async {
let preview = String(data: payload, encoding: .utf8) ?? "<non-utf8>"
logger.info("frame-attach placeholder: \(preview)")
// `replayLog` is captured for the future-plan migration documented
// above; not used today. Avoids an "unused let" warning.
_ = replayLog
}
}
/// Bridge actor that conforms `FrameAttachReplaySink` (the Vision concrete
/// type) to `FrameAttachController.ReplaySink` (the controller's nested
/// protocol). The two share method shape (`recordImageTurn(text:) async`)
/// but the Vision type doesn't declare conformance — keeping the adapter
/// in the App target preserves the Vision package's public API as
/// authored.
actor FrameAttachControllerReplaySinkBridge: FrameAttachController.ReplaySink {
private let underlying: FrameAttachReplaySink
init(underlying: FrameAttachReplaySink) {
self.underlying = underlying
}
func recordImageTurn(text: String) async {
await underlying.recordImageTurn(text: text)
}
}