diff --git a/README.md b/README.md index 54e9f23..1daa457 100644 --- a/README.md +++ b/README.md @@ -116,13 +116,20 @@ Run `inkfinite --help` for examples and the complete command reference. ### Live desktop control With the desktop app running, use `app status`, `app inspect`, and `app query` to -read its open sessions. `app focus` asks the frontend to bring its window forward: +read its open sessions. `app focus` asks the frontend to bring its window forward. +Use `app propose` to send a transaction for ghost-preview review, then accept all +or selected operations with `app accept`, or reject it with `app reject`. Direct +`app apply` requires a one-time authorization token issued by the desktop UI: ```sh inkfinite app status --json inkfinite app inspect --json inkfinite app query --role architecture.service --json inkfinite app focus +inkfinite app propose --transaction transaction.json --json +inkfinite app accept --proposal-id proposal:1 --operation-position 0 +inkfinite app reject --proposal-id proposal:2 +inkfinite app apply --transaction transaction.json --authorization TOKEN --json ``` The desktop publishes a per-user Unix-domain socket on Unix-like systems or a diff --git a/ROADMAP.md b/ROADMAP.md index 95d84ef..8503724 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # Inkfinite vNext / Version 2 -Status: implementation in progress; V2-01 through V2-17 are complete +Status: implementation in progress; V2-01 through V2-18 are complete This is the product and architecture contract for vNext. [TODO.md](TODO.md) is the implementation queue. @@ -63,9 +63,9 @@ the CLI and a bundled `SKILL.md`; MCP and UI automation are not part of vNext. - The desktop owns an authenticated, versioned local IPC server. The CLI can inspect open sessions, query the same materialized records as file mode, and request frontend focus without a TCP listener or background daemon. -- [TODO.md](TODO.md) starts the remaining work at V2-18: reviewable live - proposals, followed by sync, agent packaging, release verification, and v1 - compatibility removal. +- [TODO.md](TODO.md) starts the remaining work at V2-19: offline replica sync, + followed by agent packaging, release verification, and v1 compatibility + removal. ## Architecture @@ -99,7 +99,7 @@ TypeScript editor runtime | Local-first state | Automerge, isolated behind Inkfinite interfaces | | CLI | Rust and `clap` | | Schemas and bindings | Serde, Schemars, and `ts-rs` | -| Desktop control | Planned local sockets and length-prefixed messages | +| Desktop control | Authenticated local sockets and length-prefixed messages | | Async I/O | Tokio where needed | | Agent integration | CLI and bundled `SKILL.md` | @@ -329,7 +329,10 @@ never edit document bytes manually. ## Live control and collaboration The CLI connects to the running desktop app for `app status`, `app inspect`, -`app query`, and `app focus`. V2-18 will add proposals and explicit apply. +`app query`, and `app focus`. It can also propose a transaction for review with +`app propose`, accept all or selected operations with `app accept`, reject it with +`app reject`, or apply a transaction directly only with a one-time authorization +issued by the desktop UI via `app apply`. `app propose` is the default agent path. Rust validates it and the UI shows a ghost preview plus created, changed, and deleted IDs. Rejection changes nothing. @@ -337,11 +340,13 @@ Partial acceptance creates a new transaction from the selected operations and revalidates it against current heads. `app apply` requires explicit user authorization. -V2-17 uses a per-user Unix-domain socket or Windows named pipe, a protected -per-session token, protocol versions, length-prefixed messages, and strict size -limits. It does not expose public TCP or local HTTP. The Tauri process owns the -server and removes its discovery record when it exits; vNext has no background -daemon. +V2-17 and V2-18 use a per-user Unix-domain socket or Windows named pipe, a +protected per-session token, protocol versions, length-prefixed messages, and +strict size limits. Proposals are bounded and expire; partial acceptance creates +and revalidates a new transaction, while direct apply consumes a short-lived, +one-time authorization grant. The server does not expose public TCP or local +HTTP. The Tauri process owns the server and removes its discovery record when it +exits; vNext has no background daemon. Automerge sync between trusted peers is a vNext deliverable. The sync layer must be transport-independent and prove offline concurrent edits between two app @@ -415,9 +420,9 @@ deterministic SVG, and the mutating file-mode CLI all passed. - **Milestone 5, layers and styles:** complete. - **Milestone 6, CLI and SVG:** complete. The file-mode CLI exposes the SVG renderer and validated, atomic mutation commands. -- **Milestone 7, live control and sync:** authenticated local IPC is complete; - reviewable proposals, explicit apply, and two-replica offline convergence - remain. +- **Milestone 7, live control and sync:** authenticated IPC, reviewable + proposals, explicit apply, and one-time authorization are complete; two-replica + offline convergence remains. - **Milestone 8, agent and release readiness:** bundle the agent skill, record release evidence, replace useful v1 coverage with v2-native fixtures, remove the unreleased v1 compatibility surface, and rerun the release matrix. diff --git a/TODO.md b/TODO.md index 48d83de..41ef158 100644 --- a/TODO.md +++ b/TODO.md @@ -154,15 +154,15 @@ Blocked by: V2-09, V2-16, V2-17 Acceptance criteria: -- [ ] `app propose` validates without committing and previews geometry plus +- [x] `app propose` validates without committing and previews geometry plus created, changed, and deleted IDs in the desktop UI. -- [ ] Reject changes nothing; full accept commits once; partial accept builds a +- [x] Reject changes nothing; full accept commits once; partial accept builds a new transaction and revalidates it against current heads. -- [ ] Intervening local or remote edits produce a refreshed preview or a clear +- [x] Intervening local or remote edits produce a refreshed preview or a clear conflict, never a stale silent commit. -- [ ] `app apply` requires explicit authorization conveyed through protocol state, +- [x] `app apply` requires explicit authorization conveyed through protocol state, not a UI convention, and creates normal history/provenance. -- [ ] Proposal limits, timeout/expiry, permissions, and malformed operations have +- [x] Proposal limits, timeout/expiry, permissions, and malformed operations have integration and accessibility tests. Verification: @@ -170,6 +170,10 @@ Verification: - Run end-to-end propose/reject/accept/partial/stale/direct-apply flows and compare persisted documents after reopen. +Implemented with Rust proposal storage and revalidation, authenticated IPC/Tauri +commands, CLI subcommands, desktop ghost previews, explicit one-time apply grants, +and integration/accessibility coverage. + ### V2-19: Sync two offline replicas What to build: Wire the selected CRDT sync protocol through a transport-neutral @@ -317,4 +321,4 @@ Verification: ## Frontier -V2-18 is the current frontier. Add reviewable live proposals and explicit apply. +V2-19 is the current frontier. Sync two offline replicas and prove convergence. diff --git a/apps/desktop/src-tauri/src/ipc.rs b/apps/desktop/src-tauri/src/ipc.rs index ceaec41..a7c71d9 100644 --- a/apps/desktop/src-tauri/src/ipc.rs +++ b/apps/desktop/src-tauri/src/ipc.rs @@ -1,4 +1,4 @@ -//! Local IPC server for read-only live CLI control. +//! Local IPC server for authenticated live CLI control. use std::sync::{Arc, Mutex}; @@ -13,6 +13,13 @@ use serde_json::json; use tauri::{AppHandle, Emitter}; use tokio::sync::oneshot; +/// Tauri event carrying a newly created or refreshed live proposal. +pub const PROPOSAL_EVENT: &str = "inkfinite-proposal"; +/// Tauri event clearing the currently reviewed proposal. +pub const PROPOSAL_CLEARED_EVENT: &str = "inkfinite-proposal-cleared"; +/// Tauri event carrying a commit made by a live client. +pub const COMMIT_EVENT: &str = "inkfinite-live-commit"; + /// Handle used by the Tauri lifecycle to stop the local server and clean up discovery. pub struct IpcServerHandle { shutdown: Mutex>>, @@ -214,14 +221,35 @@ async fn handle_connection( fn dispatch_request( app: &AppHandle, service: &Arc>, request: AppRequest, ) -> Result { + let requested_session = match &request { + AppRequest::Inspect { session_id } + | AppRequest::Query { session_id, .. } + | AppRequest::Propose { session_id, .. } + | AppRequest::AcceptProposal { session_id, .. } + | AppRequest::RejectProposal { session_id, .. } + | AppRequest::Apply { session_id, .. } => session_id.clone(), + AppRequest::Status | AppRequest::Focus => None, + }; let mut service = service.lock().map_err(|_| { protocol_error( "session_service_unavailable", "the desktop session service lock is poisoned", ) })?; - let response = dispatch(&mut service, request)?; - if matches!(response, AppResponse::Focused) { + let mut response = match dispatch(&mut service, request) { + Ok(response) => response, + Err(error) => { + emit_refreshed_proposal(app, &error)?; + return Err(error); + } + }; + if let AppResponse::Committed(commit) = &mut response { + let saved = service + .save(&commit.status.session_id, &commit.status.snapshot.heads) + .map_err(|error| inkfinite_core::ipc::session_protocol_error(&error))?; + commit.status = saved.status; + } + if matches!(&response, AppResponse::Focused) { app.emit(FOCUS_EVENT, json!({ "source": "cli" })).map_err(|error| { protocol_error( "focus_notification_failed", @@ -229,9 +257,66 @@ fn dispatch_request( ) })?; } + match &response { + AppResponse::Proposal(proposal) => app + .emit( + PROPOSAL_EVENT, + json!({ "session_id": requested_session, "proposal": proposal }), + ) + .map_err(|error| { + protocol_error( + "proposal_notification_failed", + format!("could not notify the desktop frontend: {error}"), + ) + })?, + AppResponse::ProposalRejected => app.emit(PROPOSAL_CLEARED_EVENT, json!({})).map_err(|error| { + protocol_error( + "proposal_notification_failed", + format!("could not notify the desktop frontend: {error}"), + ) + })?, + AppResponse::Committed(commit) => app + .emit( + COMMIT_EVENT, + json!({ "session_id": requested_session, "commit": commit }), + ) + .map_err(|error| { + protocol_error( + "commit_notification_failed", + format!("could not notify the desktop frontend: {error}"), + ) + })?, + AppResponse::Status(_) | AppResponse::Snapshot(_) | AppResponse::QueryResult(_) | AppResponse::Focused => {} + } Ok(response) } +fn emit_refreshed_proposal(app: &AppHandle, error: &ProtocolError) -> Result<(), ProtocolError> { + match error.code.as_str() { + "proposal_stale" => { + let Some(proposal) = error.details.as_ref() else { + return Ok(()); + }; + app.emit(PROPOSAL_EVENT, json!({ "proposal": proposal })) + .map_err(|emit_error| { + protocol_error( + "proposal_notification_failed", + format!("could not notify the desktop frontend: {emit_error}"), + ) + }) + } + "proposal_conflict" => app + .emit(PROPOSAL_CLEARED_EVENT, json!({ "message": error.message })) + .map_err(|emit_error| { + protocol_error( + "proposal_notification_failed", + format!("could not notify the desktop frontend: {emit_error}"), + ) + }), + _ => Ok(()), + } +} + fn protocol_error(code: &str, message: impl Into) -> ProtocolError { ProtocolError { code: code.into(), message: message.into(), details: None } } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 8464771..bc6149a 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -22,6 +22,10 @@ pub fn run() { session::open_document, session::snapshot, session::commit, + session::propose, + session::accept_proposal, + session::reject_proposal, + session::authorize_apply, session::undo, session::redo, session::save, diff --git a/apps/desktop/src-tauri/src/session.rs b/apps/desktop/src-tauri/src/session.rs index 38f2f29..be30ebd 100644 --- a/apps/desktop/src-tauri/src/session.rs +++ b/apps/desktop/src-tauri/src/session.rs @@ -2,12 +2,16 @@ use std::sync::{Arc, Mutex, MutexGuard}; -use inkfinite_core::proto::{DocumentPath, ProtocolError, Query, QueryResult, SessionId, TransactionDraft}; +use inkfinite_core::proto::{ + ApplyAuthorization, DocumentPath, Proposal, ProposalId, ProtocolError, Query, QueryResult, SessionId, + TransactionDraft, +}; use inkfinite_core::session::{ SessionCommit, SessionError, SessionOpened, SessionSaved, SessionService, SessionStatus, }; use inkfinite_core::{ActorId, ChangeHash, DocumentId}; -use tauri::State; +use serde_json::json; +use tauri::{AppHandle, Emitter, State}; type Result = std::result::Result; @@ -81,6 +85,60 @@ pub fn commit( .map_err(to_protocol_error) } +/// Validates and stores an agent transaction for desktop proposal review. +#[tauri::command] +pub fn propose(state: State<'_, DesktopState>, session_id: String, transaction: TransactionDraft) -> Result { + lock_service(&state)? + .propose(&SessionId(session_id), transaction) + .map_err(to_protocol_error) +} + +/// Accepts all or selected operations from a reviewed proposal. +#[tauri::command] +pub fn accept_proposal( + app: AppHandle, state: State<'_, DesktopState>, session_id: String, proposal_id: ProposalId, + operation_positions: Option>, +) -> Result { + let session_id = SessionId(session_id); + let result = lock_service(&state)?.accept_proposal(&session_id, &proposal_id, operation_positions.as_deref()); + match result { + Ok(commit) => Ok(commit), + Err(error) => { + let protocol_error = to_protocol_error(error); + if protocol_error.code == "proposal_stale" { + if let Some(proposal) = protocol_error.details.as_ref() { + let _ = app.emit( + super::ipc::PROPOSAL_EVENT, + json!({ "session_id": session_id, "proposal": proposal }), + ); + } + } else if protocol_error.code == "proposal_conflict" { + let _ = app.emit( + super::ipc::PROPOSAL_CLEARED_EVENT, + json!({ "message": protocol_error.message }), + ); + } + Err(protocol_error) + } + } +} + +/// Rejects a reviewed proposal without changing the document. +#[tauri::command] +pub fn reject_proposal(state: State<'_, DesktopState>, session_id: String, proposal_id: ProposalId) -> Result<()> { + lock_service(&state)? + .reject_proposal(&SessionId(session_id), &proposal_id) + .map_err(to_protocol_error) +} + +/// Issues a one-time authorization for a direct live agent apply. +#[tauri::command] +pub fn authorize_apply(state: State<'_, DesktopState>, session_id: String) -> Result { + lock_service(&state)? + .authorize_apply(&SessionId(session_id)) + .map_err(to_protocol_error) +} + /// Compensates the latest transaction for the session actor. #[tauri::command] pub fn undo(state: State<'_, DesktopState>, session_id: String, actor_id: String) -> Result { diff --git a/apps/desktop/src/lib/persistence/desktop-session.test.ts b/apps/desktop/src/lib/persistence/desktop-session.test.ts index fa43cc4..d1ccacd 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.test.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.test.ts @@ -1,8 +1,10 @@ import type { DesktopFileOps, FileHandle } from "@inkfinite/core"; import type { - ChangeHash, - DocumentSnapshot, - TransactionDraft, + ApplyAuthorization, + ChangeHash, + DocumentSnapshot, + Proposal, + TransactionDraft, } from "@inkfinite/bindings"; import { beforeEach, describe, expect, it } from "vitest"; import { @@ -153,6 +155,22 @@ function createFakeSessionApi() { }; }, + async propose(_args: Parameters[0]): Promise { + throw new Error("Proposals are not part of this fake session"); + }, + + async acceptProposal(_args: Parameters[0]): Promise { + throw new Error("Proposals are not part of this fake session"); + }, + + async rejectProposal(_args: Parameters[0]): Promise { + throw new Error("Proposals are not part of this fake session"); + }, + + async authorizeApply(_args: Parameters[0]): Promise { + throw new Error("Apply authorization is not part of this fake session"); + }, + async undo(args: Parameters[0]): Promise { const session = sessions.get(args.session_id); const previous = session?.undo.pop(); diff --git a/apps/desktop/src/lib/persistence/desktop-session.ts b/apps/desktop/src/lib/persistence/desktop-session.ts index 2325a5c..335ccb3 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.ts @@ -1,4 +1,5 @@ import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; import type { BoardExport, BoardMeta, @@ -16,6 +17,7 @@ import type { import { createId } from '@inkfinite/core'; import type { BindingRecord as V2BindingRecord, + ApplyAuthorization, ChangeHash, CommitResult, ContainerLayout, @@ -23,6 +25,7 @@ import type { Query, QueryResult, Provenance, + Proposal, ShapeProperties, ShapeRecord, ShapeStyle, @@ -56,6 +59,9 @@ export type SessionCommit = { commit: CommitResult; status: SessionStatus }; /** Result returned after persisting a session. */ export type SessionSaved = { save: { path: string; heads: ChangeHash[] }; status: SessionStatus }; +/** State update emitted when a live proposal is created, refreshed, or cleared. */ +export type ProposalUpdate = { proposal: Proposal | null; message?: string }; + /** Typed command boundary used by the desktop adapter and its tests. */ export interface SessionApi { createDocument(args: { @@ -67,6 +73,14 @@ export interface SessionApi { openDocument(args: { path: string; actor_id: string }): Promise; snapshot(args: { session_id: string }): Promise; commit(args: { session_id: string; transaction: TransactionDraft }): Promise; + propose(args: { session_id: string; transaction: TransactionDraft }): Promise; + acceptProposal(args: { + session_id: string; + proposal_id: string; + operation_positions?: number[]; + }): Promise; + rejectProposal(args: { session_id: string; proposal_id: string }): Promise; + authorizeApply(args: { session_id: string }): Promise; undo(args: { session_id: string; actor_id: string }): Promise; redo(args: { session_id: string; actor_id: string }): Promise; save(args: { session_id: string; expected_heads: ChangeHash[] }): Promise; @@ -82,6 +96,10 @@ function createSessionApi(): SessionApi { openDocument: (args) => invoke('open_document', args), snapshot: (args) => invoke('snapshot', args), commit: (args) => invoke('commit', args), + propose: (args) => invoke('propose', args), + acceptProposal: (args) => invoke('accept_proposal', args), + rejectProposal: (args) => invoke('reject_proposal', args), + authorizeApply: (args) => invoke('authorize_apply', args), undo: (args) => invoke('undo', args), redo: (args) => invoke('redo', args), save: (args) => invoke('save', args), @@ -105,6 +123,11 @@ export type DesktopSessionRepo = PersistentDocRepo & { query(query: Query): Promise; validate(): Promise; getSessionStatus(): SessionStatus | null; + getProposal(): Proposal | null; + subscribeProposal(listener: (update: ProposalUpdate) => void): () => void; + acceptProposal(proposalId: string, operationPositions?: number[]): Promise; + rejectProposal(proposalId: string): Promise; + authorizeApply(): Promise; closeSession(): Promise; }; @@ -119,8 +142,51 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: let currentBoard: BoardMeta | null = null; let currentDoc: LoadedDoc | null = null; let currentStatus: SessionStatus | null = null; + let currentProposal: Proposal | null = null; + const proposalListeners = new Set<(update: ProposalUpdate) => void>(); + const liveUnlisteners: Array<() => void> = []; const boardFiles = new Map(); + type ProposalEvent = { session_id?: string | null; proposal: Proposal }; + type ProposalClearedEvent = { message?: string }; + type LiveCommitEvent = { session_id?: string | null; commit: SessionCommit }; + + function notifyProposal(update: ProposalUpdate) { + currentProposal = update.proposal; + for (const listener of proposalListeners) listener(update); + } + + function eventBelongsToCurrentSession(sessionId?: string | null): boolean { + return Boolean(currentStatus && (!sessionId || sessionId === currentStatus.session_id)); + } + + function startLiveListeners() { + void listen('inkfinite-proposal', (event) => { + if (eventBelongsToCurrentSession(event.payload.session_id)) { + notifyProposal({ proposal: event.payload.proposal }); + } + }) + .then((stop) => liveUnlisteners.push(stop)) + .catch(() => undefined); + void listen('inkfinite-proposal-cleared', (event) => { + if (currentStatus) notifyProposal({ proposal: null, message: event.payload.message }); + }) + .then((stop) => liveUnlisteners.push(stop)) + .catch(() => undefined); + void listen('inkfinite-live-commit', (event) => { + if (!eventBelongsToCurrentSession(event.payload.session_id)) return; + updateStatus(event.payload.commit.status); + notifyProposal({ + proposal: null, + message: 'The document changed while this proposal was open. Review it again.' + }); + }) + .then((stop) => liveUnlisteners.push(stop)) + .catch(() => undefined); + } + + startLiveListeners(); + function setCurrentState(status: SessionStatus, boardName?: string) { currentStatus = status; currentFile = { path: status.path, name: fileName(status.path) }; @@ -151,6 +217,7 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: async function closeCurrentSession() { if (!currentStatus) return; await api.close({ session_id: currentStatus.session_id }); + notifyProposal({ proposal: null }); currentStatus = null; currentFile = null; currentBoard = null; @@ -295,6 +362,12 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: }; const committed = await api.commit({ session_id: currentStatus.session_id, transaction }); updateStatus(committed.status); + if (currentProposal) { + notifyProposal({ + proposal: null, + message: 'The document changed while this proposal was open. Review it again.' + }); + } // Desktop edits are persisted by the backend service before the input event // queue advances, so reopening cannot lose a completed gesture. await saveCurrentSession(); @@ -344,6 +417,12 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: if (!currentStatus) return; const result = await api.undo({ session_id: currentStatus.session_id, actor_id: ACTOR_ID }); updateStatus(result.status); + if (currentProposal) { + notifyProposal({ + proposal: null, + message: 'The document changed while this proposal was open. Review it again.' + }); + } await saveCurrentSession(); } @@ -351,6 +430,12 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: if (!currentStatus) return; const result = await api.redo({ session_id: currentStatus.session_id, actor_id: ACTOR_ID }); updateStatus(result.status); + if (currentProposal) { + notifyProposal({ + proposal: null, + message: 'The document changed while this proposal was open. Review it again.' + }); + } await saveCurrentSession(); } @@ -366,6 +451,39 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: return status; } + function getProposal(): Proposal | null { + return currentProposal; + } + + function subscribeProposal(listener: (update: ProposalUpdate) => void): () => void { + proposalListeners.add(listener); + if (currentProposal) listener({ proposal: currentProposal }); + return () => proposalListeners.delete(listener); + } + + async function acceptProposal(proposalId: string, operationPositions?: number[]): Promise { + if (!currentStatus) throw new Error('No board loaded'); + const result = await api.acceptProposal({ + session_id: currentStatus.session_id, + proposal_id: proposalId, + ...(operationPositions ? { operation_positions: operationPositions } : {}) + }); + updateStatus(result.status); + notifyProposal({ proposal: null }); + await saveCurrentSession(); + } + + async function rejectProposal(proposalId: string): Promise { + if (!currentStatus) throw new Error('No board loaded'); + await api.rejectProposal({ session_id: currentStatus.session_id, proposal_id: proposalId }); + notifyProposal({ proposal: null }); + } + + async function authorizeApply(): Promise { + if (!currentStatus) throw new Error('No board loaded'); + return api.authorizeApply({ session_id: currentStatus.session_id }); + } + return { kind: 'desktop', listBoards, @@ -387,6 +505,11 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: query, validate, getSessionStatus: () => currentStatus, + getProposal, + subscribeProposal, + acceptProposal, + rejectProposal, + authorizeApply, closeSession: closeCurrentSession }; } diff --git a/apps/desktop/src/lib/persistence/desktop-workspace.test.ts b/apps/desktop/src/lib/persistence/desktop-workspace.test.ts index 0782024..8dbd098 100644 --- a/apps/desktop/src/lib/persistence/desktop-workspace.test.ts +++ b/apps/desktop/src/lib/persistence/desktop-workspace.test.ts @@ -50,6 +50,10 @@ describe("desktop workspace adapter", () => { openDocument: async () => { throw new Error("not used"); }, snapshot: async () => { throw new Error("not used"); }, commit: async () => { throw new Error("not used"); }, + propose: async () => { throw new Error("not used"); }, + acceptProposal: async () => { throw new Error("not used"); }, + rejectProposal: async () => { throw new Error("not used"); }, + authorizeApply: async () => { throw new Error("not used"); }, undo: async () => { throw new Error("not used"); }, redo: async () => { throw new Error("not used"); }, save: async () => { throw new Error("not used"); }, diff --git a/crates/inkfinite-cli/src/bin/generate-bindings.rs b/crates/inkfinite-cli/src/bin/generate-bindings.rs index 575081e..52778ec 100644 --- a/crates/inkfinite-cli/src/bin/generate-bindings.rs +++ b/crates/inkfinite-cli/src/bin/generate-bindings.rs @@ -6,7 +6,7 @@ use std::fs; use std::path::{Path, PathBuf}; use inkfinite_core::proto::{ - AffectedRegion, AssetPatch, Bounds, CommitResult, DocumentPatch, DocumentPath, InverseMetadata, + AffectedRegion, ApplyAuthorization, AssetPatch, Bounds, CommitResult, DocumentPatch, DocumentPath, InverseMetadata, LayerContentsDisposition, LayerPatch, LayoutAxis, Operation, Proposal, ProposalId, ProtocolError, Query, QueryResult, RecordId, Request, Response, SaveResult, SessionId, ShapeAlignment, ShapePatch, TransactionDraft, TransactionId, Warning, @@ -203,12 +203,13 @@ fn protocol_bindings() -> String { let config = ts_config(); let mut output = GENERATED_TS_HEADER.to_owned(); output.push_str( - "import type { ActorId, ChangeHash, DocumentId, DocumentSnapshot, JsonValue, Origin } from \"./model.js\";\n", + "import type { ActorId, ChangeHash, DocumentId, DocumentSnapshot, JsonValue, Origin, Timestamp } from \"./model.js\";\n", ); output.push_str( "import type { CommitResult, DocumentPath, Proposal, ProposalId, Query, QueryResult, SaveResult, TransactionDraft } from \"./transaction.js\";\n\n", ); append_declaration::(&mut output, &config); + append_declaration::(&mut output, &config); append_declaration::(&mut output, &config); append_declaration::(&mut output, &config); append_declaration::(&mut output, &config); diff --git a/crates/inkfinite-cli/src/cli/app.rs b/crates/inkfinite-cli/src/cli/app.rs index bd65691..7905bd0 100644 --- a/crates/inkfinite-cli/src/cli/app.rs +++ b/crates/inkfinite-cli/src/cli/app.rs @@ -1,12 +1,15 @@ //! Commands for inspecting and focusing a running desktop app. use inkfinite_core::ipc::{self, AppRequest, AppResponse, IpcError}; -use inkfinite_core::proto::{Query, RecordId}; +use inkfinite_core::proto::{ApplyAuthorization, ProposalId, Query, RecordId, SessionId}; use inkfinite_core::{LayerId, PageId}; -use super::args::{AppCommand, AppInspectArgs, AppQueryArgs}; +use super::apply::read_transaction; +use super::args::{ + AppAcceptArgs, AppApplyArgs, AppCommand, AppInspectArgs, AppProposeArgs, AppQueryArgs, AppRejectArgs, +}; use super::support::{map_output_error, write_heads, write_json}; -use super::{CliError, EXIT_INPUT, EXIT_INVALID, Result, Write, anyhow, json}; +use super::{CliError, EXIT_CONFLICT, EXIT_INPUT, EXIT_INVALID, Result, Write, anyhow, json}; /// Runs one authenticated read-only desktop command. pub fn run_app_command(command: AppCommand, json_output: bool, stdout: &mut dyn Write) -> Result<()> { @@ -14,6 +17,10 @@ pub fn run_app_command(command: AppCommand, json_output: bool, stdout: &mut dyn AppCommand::Status => status(json_output, stdout), AppCommand::Inspect(args) => inspect(args, json_output, stdout), AppCommand::Query(args) => query(args, json_output, stdout), + AppCommand::Propose(args) => propose(args, json_output, stdout), + AppCommand::Accept(args) => accept(args, json_output, stdout), + AppCommand::Reject(args) => reject(args, json_output, stdout), + AppCommand::Apply(args) => apply(args, json_output, stdout), AppCommand::Focus => focus(json_output, stdout), } } @@ -115,13 +122,117 @@ fn focus(json_output: bool, stdout: &mut dyn Write) -> Result<()> { } } +fn propose(args: AppProposeArgs, json_output: bool, stdout: &mut dyn Write) -> Result<()> { + let transaction = read_transaction(&args.transaction)?; + let response = send(AppRequest::Propose { session_id: args.session_id.map(SessionId), transaction })?; + let AppResponse::Proposal(proposal) = response else { + return unexpected_response("propose"); + }; + if json_output { + return write_json(stdout, &proposal); + } + writeln!(stdout, "Proposal: {}", proposal.id.0).map_err(map_output_error)?; + write_heads(stdout, &proposal.transaction.base_heads)?; + writeln!(stdout, "Created: {}", proposal.preview.created.len()).map_err(map_output_error)?; + writeln!(stdout, "Changed: {}", proposal.preview.changed.len()).map_err(map_output_error)?; + writeln!(stdout, "Deleted: {}", proposal.preview.deleted.len()).map_err(map_output_error)?; + writeln!(stdout, "Affected regions: {}", proposal.affected_regions.len()).map_err(map_output_error) +} + +fn accept(args: AppAcceptArgs, json_output: bool, stdout: &mut dyn Write) -> Result<()> { + let operation_positions = (!args.operation_positions.is_empty()).then_some(args.operation_positions); + let response = send(AppRequest::AcceptProposal { + session_id: args.session_id.map(SessionId), + proposal_id: ProposalId(args.proposal_id), + operation_positions, + })?; + let AppResponse::Committed(commit) = response else { + return unexpected_response("accept"); + }; + write_commit(&commit, json_output, stdout) +} + +fn reject(args: AppRejectArgs, json_output: bool, stdout: &mut dyn Write) -> Result<()> { + let response = send(AppRequest::RejectProposal { + session_id: args.session_id.map(SessionId), + proposal_id: ProposalId(args.proposal_id), + })?; + if !matches!(response, AppResponse::ProposalRejected) { + return unexpected_response("reject"); + } + if json_output { + write_json(stdout, &json!({ "rejected": true })) + } else { + writeln!(stdout, "Proposal rejected").map_err(map_output_error) + } +} + +fn apply(args: AppApplyArgs, json_output: bool, stdout: &mut dyn Write) -> Result<()> { + let transaction = read_transaction(&args.transaction)?; + let session_id = resolve_session_id(args.session_id)?; + let authorization = ApplyAuthorization { + token: args.authorization, + session_id: session_id.clone(), + expires_at: inkfinite_core::Timestamp(0), + }; + let response = send(AppRequest::Apply { session_id: Some(session_id), transaction, authorization })?; + let AppResponse::Committed(commit) = response else { + return unexpected_response("apply"); + }; + write_commit(&commit, json_output, stdout) +} + +fn resolve_session_id(session_id: Option) -> Result { + if let Some(session_id) = session_id { + return Ok(SessionId(session_id)); + } + let response = send(AppRequest::Status)?; + let AppResponse::Status(statuses) = response else { + return Err(CliError::new( + EXIT_INVALID, + anyhow!("desktop app returned an unexpected response for status"), + )); + }; + let [status] = statuses.as_slice() else { + return Err(CliError::new( + EXIT_INPUT, + anyhow!("--session-id is required when the desktop has zero or multiple open sessions"), + )); + }; + Ok(status.session_id.clone()) +} + +fn write_commit( + commit: &inkfinite_core::session::SessionCommit, json_output: bool, stdout: &mut dyn Write, +) -> Result<()> { + if json_output { + return write_json(stdout, commit); + } + writeln!(stdout, "Transaction: {}", commit.commit.transaction_id.0).map_err(map_output_error)?; + write_heads(stdout, &commit.commit.heads)?; + writeln!(stdout, "Created: {}", commit.commit.patch.created.len()).map_err(map_output_error)?; + writeln!(stdout, "Changed: {}", commit.commit.patch.changed.len()).map_err(map_output_error)?; + writeln!(stdout, "Deleted: {}", commit.commit.patch.deleted.len()).map_err(map_output_error) +} + fn send(request: AppRequest) -> Result { let runtime = tokio::runtime::Runtime::new() .map_err(|error| CliError::new(EXIT_INPUT, anyhow!(error)).context("could not start the IPC runtime"))?; let response = runtime.block_on(ipc::send(request)).map_err(map_ipc_error)?; - response - .result - .map_err(|error| CliError::new(EXIT_INVALID, anyhow!("[{}] {}", error.code, error.message))) + response.result.map_err(|error| { + let exit_code = match error.code.as_str() { + "proposal_stale" + | "proposal_conflict" + | "stale_heads" + | "authorization_required" + | "invalid_authorization" + | "authorization_expired" + | "actor_mismatch" + | "document_engine_error" => EXIT_CONFLICT, + _ => EXIT_INVALID, + }; + CliError::new(exit_code, anyhow!("[{}] {}", error.code, error.message)) + }) } fn map_ipc_error(error: IpcError) -> CliError { diff --git a/crates/inkfinite-cli/src/cli/apply.rs b/crates/inkfinite-cli/src/cli/apply.rs index 9f9c0e7..2ddfbc6 100644 --- a/crates/inkfinite-cli/src/cli/apply.rs +++ b/crates/inkfinite-cli/src/cli/apply.rs @@ -3,22 +3,24 @@ use super::support::{open_document, portable_path}; use super::{ApplyArgs, CliError, EXIT_INPUT, EXIT_INVALID, Path, Read, Result, TransactionDraft, Write, fs, io}; pub fn apply_transaction(args: &ApplyArgs, json_output: bool, stdout: &mut dyn Write) -> Result<()> { - let transaction_json = if args.transaction == Path::new("-") { + let transaction = read_transaction(&args.transaction)?; + let mut file = open_document(&args.path)?; + commit_mutation(&mut file, transaction, args.dry_run, json_output, stdout) +} + +/// Reads one transaction from a JSON file or standard input for file and live modes. +pub fn read_transaction(path: &Path) -> Result { + let transaction_json = if path == Path::new("-") { let mut input = String::new(); io::stdin() .read_to_string(&mut input) .map_err(|error| CliError::new(EXIT_INPUT, error).context("could not read transaction from stdin"))?; input } else { - fs::read_to_string(&args.transaction).map_err(|error| { - CliError::new(EXIT_INPUT, error).context(format!( - "could not read transaction {}", - portable_path(&args.transaction) - )) + fs::read_to_string(path).map_err(|error| { + CliError::new(EXIT_INPUT, error).context(format!("could not read transaction {}", portable_path(path))) })? }; - let transaction: TransactionDraft = serde_json::from_str(&transaction_json) - .map_err(|error| CliError::new(EXIT_INVALID, error).context("could not parse transaction JSON"))?; - let mut file = open_document(&args.path)?; - commit_mutation(&mut file, transaction, args.dry_run, json_output, stdout) + serde_json::from_str(&transaction_json) + .map_err(|error| CliError::new(EXIT_INVALID, error).context("could not parse transaction JSON")) } diff --git a/crates/inkfinite-cli/src/cli/args.rs b/crates/inkfinite-cli/src/cli/args.rs index 10f5794..d969c96 100644 --- a/crates/inkfinite-cli/src/cli/args.rs +++ b/crates/inkfinite-cli/src/cli/args.rs @@ -460,6 +460,32 @@ pub enum AppCommand { inkfinite app query --session-id session:1 --kind rect ")] Query(AppQueryArgs), + /// Validate and send an agent transaction to the desktop for review. + #[command(after_help = "Examples: + + inkfinite app propose --transaction transaction.json --json + cat transaction.json | inkfinite app propose --transaction - --json +")] + Propose(AppProposeArgs), + /// Accept all or selected operations from a reviewed proposal. + #[command(after_help = "Examples: + + inkfinite app accept --proposal-id proposal:1 --json + inkfinite app accept --proposal-id proposal:1 --operation-position 0 --operation-position 2 +")] + Accept(AppAcceptArgs), + /// Reject a reviewed proposal without changing the document. + #[command(after_help = "Example: + + inkfinite app reject --proposal-id proposal:1 +")] + Reject(AppRejectArgs), + /// Apply an agent transaction using a one-time authorization issued by the desktop UI. + #[command(after_help = "Example: + + inkfinite app apply --transaction transaction.json --authorization TOKEN --json +")] + Apply(AppApplyArgs), /// Ask the desktop frontend to focus its main window. #[command(after_help = "Example: @@ -509,6 +535,52 @@ pub struct AppQueryArgs { pub bounds: Option, } +#[derive(Debug, Args)] +pub struct AppProposeArgs { + /// Transaction JSON file, or - to read standard input. + #[arg(long, value_name = "TRANSACTION")] + pub transaction: PathBuf, + /// Propose against this session, or the only open session when omitted. + #[arg(long, value_name = "SESSION_ID")] + pub session_id: Option, +} + +#[derive(Debug, Args)] +pub struct AppAcceptArgs { + /// Proposal to accept. + #[arg(long, value_name = "PROPOSAL_ID")] + pub proposal_id: String, + /// Zero-based operation position to accept. Repeat for partial acceptance. + #[arg(long = "operation-position", value_name = "INDEX")] + pub operation_positions: Vec, + /// Accept in this session, or the only open session when omitted. + #[arg(long, value_name = "SESSION_ID")] + pub session_id: Option, +} + +#[derive(Debug, Args)] +pub struct AppRejectArgs { + /// Proposal to reject. + #[arg(long, value_name = "PROPOSAL_ID")] + pub proposal_id: String, + /// Reject in this session, or the only open session when omitted. + #[arg(long, value_name = "SESSION_ID")] + pub session_id: Option, +} + +#[derive(Debug, Args)] +pub struct AppApplyArgs { + /// Transaction JSON file, or - to read standard input. + #[arg(long, value_name = "TRANSACTION")] + pub transaction: PathBuf, + /// One-time authorization copied from the desktop review surface. + #[arg(long, value_name = "TOKEN")] + pub authorization: String, + /// Apply in this session, or the only open session when omitted. + #[arg(long, value_name = "SESSION_ID")] + pub session_id: Option, +} + #[derive(Clone, Copy, Debug, ValueEnum)] pub enum SchemaKind { Document, diff --git a/crates/inkfinite-cli/src/cli/contract.rs b/crates/inkfinite-cli/src/cli/contract.rs index 039aa48..9af0b33 100644 --- a/crates/inkfinite-cli/src/cli/contract.rs +++ b/crates/inkfinite-cli/src/cli/contract.rs @@ -49,7 +49,9 @@ pub fn print_capabilities(json_output: bool, stdout: &mut dyn Write) -> Result<( "global_options": ["--json", "--non-interactive"], "json_stdout_is_machine_only": true, "live_mode": { - "commands": ["status", "inspect", "query", "focus"], + "commands": ["status", "inspect", "query", "focus", "propose", "accept", "reject", "apply"], + "proposal_review": true, + "direct_apply_requires_one_time_authorization": true, "transport": "authenticated_local_socket", "tcp_or_http": false }, @@ -81,7 +83,11 @@ pub fn print_capabilities(json_output: bool, stdout: &mut dyn Write) -> Result<( "Commands: new, inspect, query, app, validate, apply, shape, connect, layout, render, schema, capabilities" ) .map_err(map_output_error)?; - writeln!(stdout, "Live mode: app status, app inspect, app query, app focus").map_err(map_output_error)?; + writeln!( + stdout, + "Live mode: app status, app inspect, app query, app focus, app propose, app accept, app reject, app apply" + ) + .map_err(map_output_error)?; writeln!(stdout, "Global options: --json, --non-interactive").map_err(map_output_error)?; writeln!(stdout, "Schemas: document, transaction, protocol").map_err(map_output_error)?; writeln!(stdout, "JSON: machine data on stdout; diagnostics on stderr").map_err(map_output_error) diff --git a/crates/inkfinite-cli/tests/cli.rs b/crates/inkfinite-cli/tests/cli.rs index 09d6b56..f8117de 100644 --- a/crates/inkfinite-cli/tests/cli.rs +++ b/crates/inkfinite-cli/tests/cli.rs @@ -11,7 +11,9 @@ use std::thread; use inkfinite_core::file::DocumentFile; #[cfg(unix)] -use inkfinite_core::ipc::{self, AppRequest, AppResponse, DiscoveryRecord, RequestEnvelope, ResponseEnvelope}; +use inkfinite_core::ipc::{ + self, AppRequest, AppResponse, DiscoveryRecord, RequestEnvelope, RequestGuard, ResponseEnvelope, +}; use inkfinite_core::proto::{Operation, TransactionDraft, TransactionId}; use inkfinite_core::{ ActorId, DocumentId, Opacity, Origin, Provenance, RecordVersion, SemanticMetadata, ShapeId, ShapeKind, ShapeParent, @@ -623,6 +625,10 @@ fn live_commands_read_shared_records_from_the_authenticated_local_server() { bounds: BTreeMap::new(), }), AppRequest::Focus => AppResponse::Focused, + AppRequest::Propose { .. } + | AppRequest::AcceptProposal { .. } + | AppRequest::RejectProposal { .. } + | AppRequest::Apply { .. } => panic!("proposal requests are outside this IPC fixture"), }; ipc::write_frame(&mut stream, &ResponseEnvelope { request_id, result: Ok(response) }) .await @@ -663,6 +669,182 @@ fn live_commands_read_shared_records_from_the_authenticated_local_server() { ); } +#[cfg(unix)] +#[test] +#[allow(clippy::too_many_lines)] +fn live_propose_accept_partial_and_authorized_apply_round_trip_through_cli() { + use std::os::unix::fs::FileTypeExt; + use std::os::unix::net::{UnixListener, UnixStream}; + use std::sync::mpsc; + + use inkfinite_core::proto::ProtocolError; + use inkfinite_core::session::SessionService; + + let _guard = IPC_TEST_LOCK.lock().unwrap(); + ipc::ensure_ipc_directory().unwrap(); + let endpoint = ipc::endpoint_name(); + let endpoint_path = Path::new(&endpoint); + if UnixStream::connect(endpoint_path).is_ok() { + return; + } + if let Ok(metadata) = fs::symlink_metadata(endpoint_path) { + if !metadata.file_type().is_socket() { + return; + } + fs::remove_file(endpoint_path).unwrap(); + } + + let temporary = TestDirectory::new("live-proposals"); + let document_path = temporary.path.join("live.inkfinite"); + let transaction_path = temporary.path.join("transaction.json"); + let document_id = DocumentId::from("document:live-proposals"); + let actor = ActorId::from("actor:live-proposals"); + let document = blank_document(&document_id, Some("Live proposals")); + let mut file = DocumentFile::create(&document_path, document_id, actor.clone(), document).unwrap(); + let snapshot = file.snapshot().unwrap(); + drop(file); + + let page_id = snapshot.document.page_ids[0].clone(); + let proposal_transaction = TransactionDraft { + id: TransactionId("transaction:proposal".into()), + actor_id: actor.clone(), + origin: Origin::Agent, + base_heads: snapshot.heads.clone(), + description: "preview a page rename".into(), + operations: vec![Operation::RenamePage { + page_id: page_id.clone(), + name: "Proposed".into(), + expected_version: None, + }], + timestamp: Timestamp(10), + }; + fs::write( + &transaction_path, + serde_json::to_vec_pretty(&proposal_transaction).unwrap(), + ) + .unwrap(); + + let mut service = SessionService::new(); + let opened = service.open(&document_path, actor.clone()).unwrap(); + let session_id = opened.session_id.clone(); + let authorization = service.authorize_apply(&session_id).unwrap(); + let authorization_token = authorization.token.clone(); + + let previous_discovery = ipc::read_discovery(&ipc::discovery_path()).ok(); + let discovery = DiscoveryRecord { + protocol_id: inkfinite_core::proto::PROTOCOL_ID.into(), + version: inkfinite_core::proto::PROTOCOL_VERSION, + endpoint: endpoint.clone(), + token: "cli-proposal-test-token".into(), + }; + let listener = UnixListener::bind(endpoint_path).unwrap(); + listener.set_nonblocking(true).unwrap(); + ipc::write_discovery(&ipc::discovery_path(), &discovery).unwrap(); + + let (final_snapshot_sender, final_snapshot_receiver) = mpsc::channel(); + let server_token = discovery.token.clone(); + let server = thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async move { + let listener = tokio::net::UnixListener::from_std(listener).unwrap(); + let mut guard = RequestGuard::new(server_token); + for _ in 0..3 { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = ipc::read_frame::(&mut stream).await.unwrap(); + guard.validate(&request).unwrap(); + let request_id = request.request_id; + let result: Result = ipc::dispatch(&mut service, request.request); + ipc::write_frame(&mut stream, &ResponseEnvelope { request_id, result }) + .await + .unwrap(); + } + let status = service.status(&session_id).unwrap(); + service.save(&session_id, &status.snapshot.heads).unwrap(); + let final_snapshot = service.status(&session_id).unwrap().snapshot; + service.close(&session_id).unwrap(); + final_snapshot_sender.send(final_snapshot).unwrap(); + }); + }); + + let proposed = run([ + "app", + "propose", + "--session-id", + "session:1", + "--transaction", + path(&transaction_path), + "--json", + ]); + assert_success(&proposed); + let proposed_json = parse_stdout(&proposed); + assert_eq!(proposed_json["id"], "proposal:1"); + assert_eq!(proposed_json["preview"]["changed"].as_array().unwrap().len(), 1); + + let accepted = run([ + "app", + "accept", + "--session-id", + "session:1", + "--proposal-id", + "proposal:1", + "--json", + ]); + assert_success(&accepted); + let accepted_json = parse_stdout(&accepted); + let accepted_heads = accepted_json["status"]["snapshot"]["heads"].clone(); + let direct_transaction = TransactionDraft { + id: TransactionId("transaction:authorized".into()), + actor_id: actor, + origin: Origin::Agent, + base_heads: serde_json::from_value(accepted_heads).unwrap(), + description: "authorized direct edit".into(), + operations: vec![Operation::RenamePage { page_id, name: "Applied".into(), expected_version: None }], + timestamp: Timestamp(11), + }; + fs::write( + &transaction_path, + serde_json::to_vec_pretty(&direct_transaction).unwrap(), + ) + .unwrap(); + + let applied = run([ + "app", + "apply", + "--session-id", + "session:1", + "--transaction", + path(&transaction_path), + "--authorization", + &authorization_token, + "--json", + ]); + assert_success(&applied); + assert_eq!(parse_stdout(&applied)["status"]["dirty"], true); + + server.join().unwrap(); + let final_snapshot = final_snapshot_receiver.recv().unwrap(); + assert_eq!(final_snapshot.document.pages.values().next().unwrap().name, "Applied"); + let mut reopened = DocumentFile::open(&document_path, ActorId::from("actor:verify")).unwrap(); + assert_eq!( + reopened + .snapshot() + .unwrap() + .document + .pages + .values() + .next() + .unwrap() + .name, + "Applied" + ); + + let _ = ipc::remove_discovery(&ipc::discovery_path(), &discovery); + let _ = fs::remove_file(endpoint_path); + if let Some(previous) = previous_discovery { + ipc::write_discovery(&ipc::discovery_path(), &previous).unwrap(); + } +} + fn run(args: [&str; N]) -> Output { Command::new(env!("CARGO_BIN_EXE_inkfinite")) .args(args) diff --git a/crates/inkfinite-core/src/engine/mod.rs b/crates/inkfinite-core/src/engine/mod.rs index 83c8d43..a1dd035 100644 --- a/crates/inkfinite-core/src/engine/mod.rs +++ b/crates/inkfinite-core/src/engine/mod.rs @@ -42,6 +42,27 @@ use operations::apply_operation; use policy::{validate_permissions, validate_transaction_schema}; use query::query_document; +/// Validated transaction result that has not been committed to the CRDT. +#[derive(Clone, Debug, PartialEq)] +pub struct TransactionPreview { + /// Materialized document after applying the transaction. + pub document: Document, + /// Records created, changed, or deleted by the transaction. + pub patch: DocumentPatch, + /// Records affected directly or through hierarchy repairs. + pub affected_ids: Vec, + /// Document-coordinate regions invalidated by the transaction. + pub affected_regions: Vec, +} + +struct PreparedTransaction { + document: Document, + inverse: Vec, + patch: DocumentPatch, + affected_ids: Vec, + affected_regions: Vec, +} + /// Rust-owned document state plus actor-scoped undo and redo metadata. pub struct TransactionEngine { crdt: AutomergeDocument, @@ -122,6 +143,19 @@ impl TransactionEngine { self.commit_internal(transaction, true) } + /// Validates a transaction and returns its materialized geometry without + /// changing the CRDT or either history stack. + /// + /// # Errors + /// + /// Returns the same schema, head, permission, precondition, and invariant + /// failures as [`Self::commit`]. + pub fn preview(&mut self, transaction: &TransactionDraft) -> Result { + let PreparedTransaction { document, patch, affected_ids, affected_regions, .. } = + self.prepare_transaction(transaction)?; + Ok(TransactionPreview { document, patch, affected_ids, affected_regions }) + } + /// Compensates the latest eligible transaction from `actor_id`. /// /// Changes to unrelated fields remain intact. An intervening edit to the @@ -260,28 +294,8 @@ impl TransactionEngine { fn commit_internal( &mut self, transaction: TransactionDraft, track_history: bool, ) -> Result { - validate_transaction_schema(&transaction)?; - let current_heads = self.crdt.heads(); - if canonical_heads(&transaction.base_heads) != canonical_heads(¤t_heads) { - return Err(EngineError::StaleHeads); - } - - let before = self.crdt.snapshot()?.document; - let mut candidate = before.clone(); - let mut inverse = Vec::new(); - for operation in &transaction.operations { - validate_permissions(&candidate, operation, &transaction.origin)?; - let operation_inverse = apply_operation(&mut candidate, operation)?; - inverse.splice(0..0, operation_inverse); - } - validate_document(&candidate)?; - refresh_inverse_preconditions(&mut inverse, &candidate); - - let (patch, affected_ids) = diff_documents(&before, &candidate); - if affected_ids.is_empty() { - return Err(EngineError::Schema("transaction must produce a durable change".into())); - } - let affected_regions = affected_regions(&before, &candidate, &affected_ids); + let PreparedTransaction { document: candidate, inverse, patch, affected_ids, affected_regions } = + self.prepare_transaction(&transaction)?; let mut fork = self.crdt.clone(); fork.set_actor(&transaction.actor_id); let outcome = fork.commit_document(&candidate, &transaction.description)?; @@ -311,6 +325,32 @@ impl TransactionEngine { }) } + fn prepare_transaction(&mut self, transaction: &TransactionDraft) -> Result { + validate_transaction_schema(transaction)?; + let current_heads = self.crdt.heads(); + if canonical_heads(&transaction.base_heads) != canonical_heads(¤t_heads) { + return Err(EngineError::StaleHeads); + } + + let before = self.crdt.snapshot()?.document; + let mut candidate = before.clone(); + let mut inverse = Vec::new(); + for operation in &transaction.operations { + validate_permissions(&candidate, operation, &transaction.origin)?; + let operation_inverse = apply_operation(&mut candidate, operation)?; + inverse.splice(0..0, operation_inverse); + } + validate_document(&candidate)?; + refresh_inverse_preconditions(&mut inverse, &candidate); + + let (patch, affected_ids) = diff_documents(&before, &candidate); + if affected_ids.is_empty() { + return Err(EngineError::Schema("transaction must produce a durable change".into())); + } + let affected_regions = affected_regions(&before, &candidate, &affected_ids); + Ok(PreparedTransaction { document: candidate, inverse, patch, affected_ids, affected_regions }) + } + fn history_transaction( &mut self, actor_id: &ActorId, action: &str, operations: Vec, ) -> TransactionDraft { diff --git a/crates/inkfinite-core/src/ipc/mod.rs b/crates/inkfinite-core/src/ipc/mod.rs index 7d53b48..a7959ba 100644 --- a/crates/inkfinite-core/src/ipc/mod.rs +++ b/crates/inkfinite-core/src/ipc/mod.rs @@ -18,8 +18,10 @@ use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use crate::DocumentSnapshot; use crate::file::FileError; -use crate::proto::{PROTOCOL_ID, PROTOCOL_VERSION, Query, QueryResult, SessionId}; -use crate::session::{SessionError, SessionService, SessionStatus}; +use crate::proto::{ + ApplyAuthorization, PROTOCOL_ID, PROTOCOL_VERSION, Proposal, ProposalId, Query, QueryResult, SessionId, +}; +use crate::session::{SessionCommit, SessionError, SessionService, SessionStatus}; pub use crate::engine::{CommitResult, TransactionDraft}; pub use crate::proto::{ProtocolError, Request, Response}; @@ -70,6 +72,38 @@ pub enum AppRequest { /// Shared semantic and hierarchy filters. query: Query, }, + /// Validate and store an agent transaction for desktop review. + Propose { + /// Session to propose against, or the only open session when omitted. + session_id: Option, + /// Agent transaction to validate and preview. + transaction: TransactionDraft, + }, + /// Accept all or selected operations from a reviewed proposal. + AcceptProposal { + /// Session owning the proposal, or the only open session when omitted. + session_id: Option, + /// Proposal to accept. + proposal_id: ProposalId, + /// Zero-based operation positions, or all operations when omitted. + operation_positions: Option>, + }, + /// Reject a reviewed proposal without changing the document. + RejectProposal { + /// Session owning the proposal, or the only open session when omitted. + session_id: Option, + /// Proposal to reject. + proposal_id: ProposalId, + }, + /// Apply an agent transaction after consuming explicit desktop authorization. + Apply { + /// Session to change, or the only open session when omitted. + session_id: Option, + /// Agent transaction to validate and apply. + transaction: TransactionDraft, + /// One-time authorization issued by the desktop UI. + authorization: ApplyAuthorization, + }, /// Ask the desktop frontend to bring its main window forward. Focus, } @@ -100,6 +134,12 @@ pub enum AppResponse { Snapshot(DocumentSnapshot), /// Shared deterministic query result. QueryResult(QueryResult), + /// A transaction was validated and stored for review. + Proposal(Proposal), + /// A proposal was rejected and removed. + ProposalRejected, + /// A directly authorized transaction was committed. + Committed(Box), /// The focus notification was emitted. Focused, } @@ -381,6 +421,42 @@ pub fn dispatch(service: &mut SessionService, request: AppRequest) -> Result { + let session_id = service + .resolve_session_id(session_id.as_ref()) + .map_err(|error| session_protocol_error(&error))?; + service + .propose(&session_id, transaction) + .map(AppResponse::Proposal) + .map_err(|error| session_protocol_error(&error)) + } + AppRequest::AcceptProposal { session_id, proposal_id, operation_positions } => { + let session_id = service + .resolve_session_id(session_id.as_ref()) + .map_err(|error| session_protocol_error(&error))?; + service + .accept_proposal(&session_id, &proposal_id, operation_positions.as_deref()) + .map(|commit| AppResponse::Committed(Box::new(commit))) + .map_err(|error| session_protocol_error(&error)) + } + AppRequest::RejectProposal { session_id, proposal_id } => { + let session_id = service + .resolve_session_id(session_id.as_ref()) + .map_err(|error| session_protocol_error(&error))?; + service + .reject_proposal(&session_id, &proposal_id) + .map(|()| AppResponse::ProposalRejected) + .map_err(|error| session_protocol_error(&error)) + } + AppRequest::Apply { session_id, transaction, authorization } => { + let session_id = service + .resolve_session_id(session_id.as_ref()) + .map_err(|error| session_protocol_error(&error))?; + service + .apply_authorized(&session_id, &authorization, transaction) + .map(|commit| AppResponse::Committed(Box::new(commit))) + .map_err(|error| session_protocol_error(&error)) + } AppRequest::Focus => Ok(AppResponse::Focused), } } @@ -394,6 +470,19 @@ pub fn session_protocol_error(error: &SessionError) -> ProtocolError { SessionError::SessionSelectionRequired { .. } => "session_selection_required", SessionError::ActorMismatch { .. } => "actor_mismatch", SessionError::StaleHeads => "stale_heads", + SessionError::ProposalLimit(_) + | SessionError::ProposalTooLarge { .. } + | SessionError::ProposalDescriptionTooLong { .. } => "proposal_limit", + SessionError::AgentOriginRequired => "agent_origin_required", + SessionError::ProposalNotFound(_) => "proposal_not_found", + SessionError::ProposalExpired(_) => "proposal_expired", + SessionError::ProposalStale { .. } => "proposal_stale", + SessionError::ProposalConflict { .. } => "proposal_conflict", + SessionError::InvalidProposalSelection(_) => "invalid_proposal_selection", + SessionError::AuthorizationRequired => "authorization_required", + SessionError::InvalidAuthorization => "invalid_authorization", + SessionError::AuthorizationExpired => "authorization_expired", + SessionError::AuthorizationUnavailable(_) => "authorization_unavailable", SessionError::AlreadyOpen { .. } => "document_already_open", SessionError::File(file_error) => match file_error { FileError::Locked { .. } => "document_locked", @@ -411,7 +500,15 @@ pub fn session_protocol_error(error: &SessionError) -> ProtocolError { }, SessionError::Engine(_) => "document_engine_error", }; - protocol_error(code, error.to_string()) + let details = match error { + SessionError::ProposalStale { proposal, .. } => serde_json::to_value(proposal).ok(), + SessionError::ProposalConflict { proposal_id, message } => Some(serde_json::json!({ + "proposal_id": proposal_id, + "message": message, + })), + _ => None, + }; + ProtocolError { code: code.into(), message: error.to_string(), details } } /// Sends one request to the currently published desktop process. diff --git a/crates/inkfinite-core/src/proto/mod.rs b/crates/inkfinite-core/src/proto/mod.rs index 47aa8cb..cdc2360 100644 --- a/crates/inkfinite-core/src/proto/mod.rs +++ b/crates/inkfinite-core/src/proto/mod.rs @@ -32,6 +32,17 @@ pub struct SessionId(pub String); #[serde(transparent)] pub struct ProposalId(pub String); +/// One-time user authorization for a direct live agent apply. +#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize, TS)] +pub struct ApplyAuthorization { + /// Opaque single-use token issued by the desktop UI. + pub token: String, + /// Session for which the authorization was issued. + pub session_id: SessionId, + /// Wall-clock expiry retained for clients and diagnostics. + pub expires_at: Timestamp, +} + /// Cross-platform serialized document path used at file-service boundaries. #[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize, TS)] #[serde(transparent)] @@ -420,8 +431,12 @@ pub struct Proposal { pub transaction: TransactionDraft, /// Preview patch shown by the UI. pub preview: DocumentPatch, + /// Document-coordinate regions affected by the proposed geometry. + pub affected_regions: Vec, /// Validation or repair warnings shown before acceptance. pub warnings: Vec, + /// Wall-clock expiry retained for clients and diagnostics. + pub expires_at: Timestamp, } /// Transport-independent request accepted by desktop commands, IPC, or CLI adapters. @@ -461,6 +476,20 @@ pub enum Request { /// Transaction to preview. transaction: TransactionDraft, }, + /// Issue a one-time authorization for a direct live apply. + AuthorizeApply { + /// Open session to authorize. + session_id: SessionId, + }, + /// Apply a transaction directly after explicit desktop authorization. + Apply { + /// Open session to change. + session_id: SessionId, + /// Transaction to validate and apply. + transaction: TransactionDraft, + /// One-time authorization issued by the desktop UI. + authorization: ApplyAuthorization, + }, /// Accept all or selected operations from a proposal and revalidate at current heads. AcceptProposal { /// Open session to change. @@ -538,6 +567,8 @@ pub enum Response { Committed(CommitResult), /// A transaction was validated and held for user review. Proposed(Proposal), + /// A one-time direct-apply authorization was issued. + ApplyAuthorized(ApplyAuthorization), /// A proposal was rejected and removed. ProposalRejected, /// A document was persisted. diff --git a/crates/inkfinite-core/src/session.rs b/crates/inkfinite-core/src/session.rs index be2a7f1..247857b 100644 --- a/crates/inkfinite-core/src/session.rs +++ b/crates/inkfinite-core/src/session.rs @@ -6,16 +6,32 @@ //! Adapters can expose this service over Tauri, local IPC, or a CLI without //! moving document bytes into the frontend. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write as _; use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::engine::{EngineError, validate_document}; use crate::file::{DocumentFile, FileError}; -use crate::proto::{CommitResult, DocumentPath, Query, QueryResult, SaveResult, SessionId, TransactionDraft}; -use crate::{ActorId, DocumentId, DocumentSnapshot, blank_document}; +use crate::proto::{ + ApplyAuthorization, CommitResult, DocumentPath, Proposal, ProposalId, Query, QueryResult, SaveResult, SessionId, + TransactionDraft, TransactionId, Warning, +}; +use crate::{ActorId, DocumentId, DocumentSnapshot, Origin, Timestamp, blank_document}; + +/// Maximum number of pending proposals held by one live session. +pub const MAX_PROPOSALS_PER_SESSION: usize = 32; +/// Maximum number of operations accepted by a proposal or direct live apply. +pub const MAX_PROPOSAL_OPERATIONS: usize = 256; +/// Maximum UTF-8 byte length of a proposal description. +pub const MAX_PROPOSAL_DESCRIPTION_BYTES: usize = 4096; +/// Monotonic lifetime of an unaccepted proposal. +pub const PROPOSAL_TTL: Duration = Duration::from_secs(5 * 60); +/// Monotonic lifetime of a one-time direct-apply authorization. +pub const APPLY_AUTHORIZATION_TTL: Duration = Duration::from_secs(2 * 60); /// State of the session's future synchronization boundary. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] @@ -101,6 +117,63 @@ pub enum SessionError { /// The caller inspected heads that are no longer current. #[error("stale session heads")] StaleHeads, + /// A live proposal exceeded the session's bounded proposal store. + #[error("the session already holds {0} pending proposals")] + ProposalLimit(usize), + /// A proposal contains more operations than the live control boundary permits. + #[error("proposal contains {count} operations; the limit is {max}")] + ProposalTooLarge { + /// Number of operations supplied by the caller. + count: usize, + /// Maximum number of operations accepted by the boundary. + max: usize, + }, + /// A proposal description exceeds the live control boundary. + #[error("proposal description exceeds the {max} byte limit")] + ProposalDescriptionTooLong { + /// Maximum UTF-8 byte length. + max: usize, + }, + /// The live proposal boundary only accepts agent-originated transactions. + #[error("live proposals and direct applies require agent origin")] + AgentOriginRequired, + /// No pending proposal has the requested identifier. + #[error("proposal not found: {0:?}")] + ProposalNotFound(ProposalId), + /// A pending proposal passed its review window. + #[error("proposal expired: {0:?}")] + ProposalExpired(ProposalId), + /// A proposal was refreshed after its inspected heads changed. + #[error("proposal {proposal_id:?} is stale; its preview was refreshed")] + ProposalStale { + /// Proposal whose preview was refreshed. + proposal_id: ProposalId, + /// Refreshed proposal for the UI or caller to review. + proposal: Box, + }, + /// A proposal could not be refreshed after intervening edits. + #[error("proposal {proposal_id:?} conflicts with the current document: {message}")] + ProposalConflict { + /// Proposal that could not be refreshed. + proposal_id: ProposalId, + /// Actionable conflict detail. + message: String, + }, + /// A partial acceptance selected no valid operations. + #[error("invalid proposal operation selection: {0}")] + InvalidProposalSelection(String), + /// The desktop UI did not issue an explicit direct-apply authorization. + #[error("explicit apply authorization is required")] + AuthorizationRequired, + /// The supplied authorization was issued for another session or token. + #[error("explicit apply authorization is invalid")] + InvalidAuthorization, + /// The supplied authorization passed its expiry time. + #[error("explicit apply authorization expired")] + AuthorizationExpired, + /// The operating system could not create a one-time authorization token. + #[error("could not create apply authorization: {0}")] + AuthorizationUnavailable(String), /// Two commands attempted to open the same path through one service. #[error("document is already open in session {session_id:?}: {path:?}")] AlreadyOpen { @@ -120,12 +193,27 @@ pub enum SessionError { struct DocumentSession { file: DocumentFile, sync: SyncState, + proposals: BTreeMap, + expired_proposals: BTreeSet, + authorizations: BTreeMap, +} + +#[derive(Clone)] +struct PendingProposal { + proposal: Proposal, + created_at: Instant, +} + +struct ApplyGrant { + authorization: ApplyAuthorization, + expires_at: Instant, } /// In-process owner of all open desktop document sessions. pub struct SessionService { sessions: BTreeMap, next_session_number: u64, + next_proposal_number: u64, } impl Default for SessionService { @@ -138,7 +226,7 @@ impl SessionService { /// Creates an empty service with no open files. #[must_use] pub fn new() -> Self { - Self { sessions: BTreeMap::new(), next_session_number: 0 } + Self { sessions: BTreeMap::new(), next_session_number: 0, next_proposal_number: 0 } } /// Creates and persists a new canonical document session. @@ -184,6 +272,7 @@ impl SessionService { /// Returns [`SessionError::NotFound`] or a snapshot/recovery error. pub fn status(&mut self, session_id: &SessionId) -> Result { let session = self.session_mut(session_id)?; + session.expire_state(); session.status(session_id) } @@ -238,6 +327,161 @@ impl SessionService { Ok(SessionCommit { commit, status }) } + /// Validates and stores one agent transaction for explicit desktop review. + /// + /// The document, CRDT heads, and history remain unchanged. + /// + /// # Errors + /// + /// Returns a typed limit, authorization, validation, or session error. + pub fn propose(&mut self, session_id: &SessionId, transaction: TransactionDraft) -> Result { + self.next_proposal_number = self.next_proposal_number.saturating_add(1); + let proposal_id = ProposalId(format!("proposal:{}", self.next_proposal_number)); + let session = self.session_mut(session_id)?; + session.expire_state(); + if session.proposals.len() >= MAX_PROPOSALS_PER_SESSION { + return Err(SessionError::ProposalLimit(MAX_PROPOSALS_PER_SESSION)); + } + ensure_actor(session.file.actor_id(), &transaction.actor_id)?; + validate_live_transaction(&transaction)?; + let proposal = create_proposal(&mut session.file, proposal_id, transaction, None)?; + session.proposals.insert( + proposal.id.clone(), + PendingProposal { proposal: proposal.clone(), created_at: Instant::now() }, + ); + Ok(proposal) + } + + /// Accepts all or selected operations from a pending proposal. + /// + /// The original causal heads must still be current. When they changed, + /// the stored proposal is refreshed and [`SessionError::ProposalStale`] + /// returns the new preview without committing anything. + /// + /// # Errors + /// + /// Returns a typed proposal, validation, permission, or stale-head error. + pub fn accept_proposal( + &mut self, session_id: &SessionId, proposal_id: &ProposalId, operation_positions: Option<&[u32]>, + ) -> Result { + let session = self.session_mut(session_id)?; + session.expire_state(); + let pending = session.proposals.get(proposal_id).cloned().ok_or_else(|| { + if session.expired_proposals.remove(proposal_id) { + SessionError::ProposalExpired(proposal_id.clone()) + } else { + SessionError::ProposalNotFound(proposal_id.clone()) + } + })?; + let current_heads = session.file.snapshot()?.heads; + if !same_heads(¤t_heads, &pending.proposal.transaction.base_heads) { + let mut refreshed_transaction = pending.proposal.transaction.clone(); + refreshed_transaction.base_heads = current_heads; + let refreshed = match create_proposal( + &mut session.file, + pending.proposal.id.clone(), + refreshed_transaction, + Some(pending.proposal.expires_at), + ) { + Ok(proposal) => proposal, + Err(error) => { + return Err(SessionError::ProposalConflict { + proposal_id: proposal_id.clone(), + message: error.to_string(), + }); + } + }; + session.proposals.insert( + proposal_id.clone(), + PendingProposal { proposal: refreshed.clone(), created_at: pending.created_at }, + ); + return Err(SessionError::ProposalStale { + proposal_id: proposal_id.clone(), + proposal: Box::new(refreshed), + }); + } + + let operations = select_operations(&pending.proposal.transaction.operations, operation_positions)?; + let transaction = if operation_positions.is_none() { + pending.proposal.transaction.clone() + } else { + let mut transaction = pending.proposal.transaction.clone(); + transaction.id = TransactionId(format!("{}:partial", pending.proposal.id.0)); + transaction.description = format!("{} (partial proposal acceptance)", transaction.description); + transaction.operations = operations; + transaction + }; + let commit = session.file.commit(transaction)?; + session.proposals.remove(proposal_id); + let status = session.status(session_id)?; + Ok(SessionCommit { commit, status }) + } + + /// Rejects and removes a pending proposal without changing the document. + /// + /// # Errors + /// + /// Returns [`SessionError::ProposalNotFound`] or [`SessionError::ProposalExpired`]. + pub fn reject_proposal(&mut self, session_id: &SessionId, proposal_id: &ProposalId) -> Result<(), SessionError> { + let session = self.session_mut(session_id)?; + session.expire_state(); + if session.proposals.remove(proposal_id).is_some() { + Ok(()) + } else if session.expired_proposals.remove(proposal_id) { + Err(SessionError::ProposalExpired(proposal_id.clone())) + } else { + Err(SessionError::ProposalNotFound(proposal_id.clone())) + } + } + + /// Issues a one-time authorization that the desktop UI can pass to a + /// direct live apply request. + /// + /// # Errors + /// + /// Returns a session lookup or operating-system random-source error. + pub fn authorize_apply(&mut self, session_id: &SessionId) -> Result { + let session = self.session_mut(session_id)?; + session.expire_state(); + let token = random_token().map_err(SessionError::AuthorizationUnavailable)?; + let expires_at = Instant::now() + APPLY_AUTHORIZATION_TTL; + let authorization = ApplyAuthorization { + token: token.clone(), + session_id: session_id.clone(), + expires_at: timestamp_after(APPLY_AUTHORIZATION_TTL), + }; + session + .authorizations + .insert(token, ApplyGrant { authorization: authorization.clone(), expires_at }); + Ok(authorization) + } + + /// Applies one agent transaction after consuming a one-time desktop grant. + /// + /// # Errors + /// + /// Returns a typed authorization, validation, permission, or persistence error. + pub fn apply_authorized( + &mut self, session_id: &SessionId, authorization: &ApplyAuthorization, transaction: TransactionDraft, + ) -> Result { + let session = self.session_mut(session_id)?; + session.expire_state(); + let Some(grant) = session.authorizations.remove(&authorization.token) else { + return Err(SessionError::AuthorizationRequired); + }; + if grant.authorization.token != authorization.token || grant.authorization.session_id != *session_id { + return Err(SessionError::InvalidAuthorization); + } + if Instant::now() >= grant.expires_at { + return Err(SessionError::AuthorizationExpired); + } + ensure_actor(session.file.actor_id(), &transaction.actor_id)?; + validate_live_transaction(&transaction)?; + let commit = session.file.commit(transaction)?; + let status = session.status(session_id)?; + Ok(SessionCommit { commit, status }) + } + /// Compensates the latest transaction owned by `actor_id`. /// /// # Errors @@ -357,7 +601,13 @@ impl SessionService { self.next_session_number = self.next_session_number.saturating_add(1); let session_id = SessionId(format!("session:{}", self.next_session_number)); - let mut session = DocumentSession { file, sync: SyncState::Disabled }; + let mut session = DocumentSession { + file, + sync: SyncState::Disabled, + proposals: BTreeMap::new(), + expired_proposals: BTreeSet::new(), + authorizations: BTreeMap::new(), + }; let status = session.status(&session_id)?; self.sessions.insert(session_id.clone(), session); Ok(SessionOpened { session_id, status }) @@ -371,6 +621,25 @@ impl SessionService { } impl DocumentSession { + fn expire_state(&mut self) { + let now = Instant::now(); + let expired = self + .proposals + .iter() + .filter(|(_, pending)| now.duration_since(pending.created_at) >= PROPOSAL_TTL) + .map(|(proposal_id, _)| proposal_id.clone()) + .collect::>(); + for proposal_id in expired { + self.proposals.remove(&proposal_id); + self.expired_proposals.insert(proposal_id); + } + while self.expired_proposals.len() > MAX_PROPOSALS_PER_SESSION { + let Some(oldest) = self.expired_proposals.iter().next().cloned() else { break }; + self.expired_proposals.remove(&oldest); + } + self.authorizations.retain(|_, grant| now < grant.expires_at); + } + fn status(&mut self, session_id: &SessionId) -> Result { let snapshot = self.file.snapshot()?; let dirty = self.file.is_dirty()?; @@ -390,6 +659,97 @@ impl DocumentSession { } } +fn create_proposal( + file: &mut DocumentFile, id: ProposalId, transaction: TransactionDraft, expires_at: Option, +) -> Result { + let preview = file.engine_mut().preview(&transaction)?; + Ok(Proposal { + id, + transaction, + preview: preview.patch, + affected_regions: preview.affected_regions, + warnings: Vec::::new(), + expires_at: expires_at.unwrap_or_else(|| timestamp_after(PROPOSAL_TTL)), + }) +} + +fn validate_live_transaction(transaction: &TransactionDraft) -> Result<(), SessionError> { + if transaction.origin != Origin::Agent { + return Err(SessionError::AgentOriginRequired); + } + if transaction.operations.len() > MAX_PROPOSAL_OPERATIONS { + return Err(SessionError::ProposalTooLarge { + count: transaction.operations.len(), + max: MAX_PROPOSAL_OPERATIONS, + }); + } + if transaction.description.len() > MAX_PROPOSAL_DESCRIPTION_BYTES { + return Err(SessionError::ProposalDescriptionTooLong { max: MAX_PROPOSAL_DESCRIPTION_BYTES }); + } + Ok(()) +} + +fn select_operations( + operations: &[crate::proto::Operation], positions: Option<&[u32]>, +) -> Result, SessionError> { + let Some(positions) = positions else { + return Ok(operations.to_vec()); + }; + if positions.is_empty() { + return Err(SessionError::InvalidProposalSelection( + "at least one operation is required".into(), + )); + } + let mut selected = BTreeSet::new(); + for position in positions { + let index = usize::try_from(*position) + .map_err(|_| SessionError::InvalidProposalSelection(format!("operation position {position} is invalid")))?; + if index >= operations.len() { + return Err(SessionError::InvalidProposalSelection(format!( + "operation position {position} is outside the proposal" + ))); + } + if !selected.insert(index) { + return Err(SessionError::InvalidProposalSelection(format!( + "operation position {position} was selected more than once" + ))); + } + } + Ok(selected.into_iter().map(|index| operations[index].clone()).collect()) +} + +fn same_heads(left: &[crate::ChangeHash], right: &[crate::ChangeHash]) -> bool { + let mut left = left.to_vec(); + let mut right = right.to_vec(); + left.sort(); + right.sort(); + left == right +} + +fn timestamp_after(duration: Duration) -> Timestamp { + let now = timestamp_now().0; + let millis = i64::try_from(duration.as_millis()).unwrap_or(i64::MAX); + Timestamp(now.saturating_add(millis)) +} + +fn timestamp_now() -> Timestamp { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default(); + Timestamp(i64::try_from(millis).unwrap_or(i64::MAX)) +} + +fn random_token() -> Result { + let mut bytes = [0_u8; 32]; + getrandom::fill(&mut bytes).map_err(|error| error.to_string())?; + let mut token = String::with_capacity(bytes.len() * 2); + for byte in bytes { + write!(&mut token, "{byte:02x}").map_err(|error| error.to_string())?; + } + Ok(token) +} + fn ensure_actor(expected: &ActorId, actual: &ActorId) -> Result<(), SessionError> { if expected == actual { Ok(()) @@ -527,6 +887,259 @@ mod tests { remove_test_directory(root); } + #[test] + fn proposal_rejection_leaves_heads_and_bytes_unchanged() { + let root = test_directory(); + let path = root.join("proposal-reject.inkfinite"); + let actor = ActorId::from("actor:proposal"); + let mut service = SessionService::new(); + let opened = service + .create(&path, DocumentId::from("document:proposal-reject"), actor.clone(), None) + .expect("create session"); + let before = service.status(&opened.session_id).expect("status"); + let before_bytes = fs::read(&path).expect("read canonical file"); + let proposal = service + .propose( + &opened.session_id, + agent_rename(&before.snapshot, actor, "Preview only"), + ) + .expect("propose transaction"); + + assert_eq!( + proposal.preview.changed, + vec![crate::proto::RecordId::Page(PageId::from( + "page:document:proposal-reject:1" + ))] + ); + assert_eq!( + service + .status(&opened.session_id) + .expect("status after proposal") + .snapshot, + before.snapshot + ); + service + .reject_proposal(&opened.session_id, &proposal.id) + .expect("reject proposal"); + assert_eq!(fs::read(&path).expect("read unchanged canonical file"), before_bytes); + assert_eq!( + service.status(&opened.session_id).expect("final status").snapshot, + before.snapshot + ); + + service.close(&opened.session_id).expect("close session"); + remove_test_directory(root); + } + + #[test] + fn partial_acceptance_commits_only_selected_operations() { + let root = test_directory(); + let path = root.join("proposal-partial.inkfinite"); + let actor = ActorId::from("actor:proposal"); + let mut service = SessionService::new(); + let opened = service + .create( + &path, + DocumentId::from("document:proposal-partial"), + actor.clone(), + None, + ) + .expect("create session"); + let snapshot = opened.status.snapshot.clone(); + let page_id = snapshot.document.page_ids[0].clone(); + let layer_id = snapshot.document.pages[&page_id].layer_ids[0].clone(); + let transaction = TransactionDraft { + id: TransactionId("transaction:proposal-partial".into()), + actor_id: actor, + origin: Origin::Agent, + base_heads: snapshot.heads, + description: "rename page and layer".into(), + operations: vec![ + Operation::RenamePage { page_id, name: "Renamed page".into(), expected_version: None }, + Operation::PatchLayer { + layer_id, + patch: crate::proto::LayerPatch { name: Some("Renamed layer".into()), ..Default::default() }, + expected_version: None, + }, + ], + timestamp: Timestamp(2), + }; + let proposal = service + .propose(&opened.session_id, transaction) + .expect("propose transaction"); + let accepted = service + .accept_proposal(&opened.session_id, &proposal.id, Some(&[1_u32])) + .expect("accept selected operation"); + + assert_eq!(accepted.commit.patch.changed.len(), 1); + let status = service + .status(&opened.session_id) + .expect("status after partial acceptance"); + assert_eq!(status.snapshot.document.pages.values().next().unwrap().name, "Page 1"); + assert_eq!( + status.snapshot.document.layers.values().next().unwrap().name, + "Renamed layer" + ); + + service.close(&opened.session_id).expect("close session"); + remove_test_directory(root); + } + + #[test] + fn stale_acceptance_refreshes_preview_without_committing() { + let root = test_directory(); + let path = root.join("proposal-stale.inkfinite"); + let actor = ActorId::from("actor:proposal"); + let mut service = SessionService::new(); + let opened = service + .create(&path, DocumentId::from("document:proposal-stale"), actor.clone(), None) + .expect("create session"); + let snapshot = opened.status.snapshot.clone(); + let proposal = service + .propose( + &opened.session_id, + agent_rename(&snapshot, actor.clone(), "Agent result"), + ) + .expect("propose transaction"); + service + .commit( + &opened.session_id, + rename_transaction(&snapshot, actor.clone(), "Local edit"), + ) + .expect("intervening local edit"); + + let error = service + .accept_proposal(&opened.session_id, &proposal.id, None) + .expect_err("stale acceptance must not commit"); + let refreshed = match error { + SessionError::ProposalStale { proposal, .. } => proposal, + other => panic!("expected refreshed proposal, got {other:?}"), + }; + assert_eq!( + refreshed.transaction.base_heads, + service.status(&opened.session_id).unwrap().snapshot.heads + ); + assert_eq!( + service + .status(&opened.session_id) + .unwrap() + .snapshot + .document + .pages + .values() + .next() + .unwrap() + .name, + "Local edit" + ); + + let accepted = service + .accept_proposal(&opened.session_id, &refreshed.id, None) + .expect("accept refreshed proposal"); + assert_eq!(accepted.commit.patch.changed.len(), 1); + assert_eq!( + service + .status(&opened.session_id) + .unwrap() + .snapshot + .document + .pages + .values() + .next() + .unwrap() + .name, + "Agent result" + ); + + service.close(&opened.session_id).expect("close session"); + remove_test_directory(root); + } + + #[test] + fn direct_apply_consumes_explicit_authorization_once() { + let root = test_directory(); + let path = root.join("apply-authorized.inkfinite"); + let actor = ActorId::from("actor:proposal"); + let mut service = SessionService::new(); + let opened = service + .create( + &path, + DocumentId::from("document:apply-authorized"), + actor.clone(), + None, + ) + .expect("create session"); + let snapshot = opened.status.snapshot.clone(); + let transaction = agent_rename(&snapshot, actor, "Authorized edit"); + let authorization = service.authorize_apply(&opened.session_id).expect("authorize apply"); + service + .apply_authorized(&opened.session_id, &authorization, transaction.clone()) + .expect("apply authorized transaction"); + assert!(matches!( + service.apply_authorized(&opened.session_id, &authorization, transaction), + Err(SessionError::AuthorizationRequired) + )); + + service.close(&opened.session_id).expect("close session"); + remove_test_directory(root); + } + + #[test] + fn expired_proposals_and_oversized_transactions_are_rejected() { + let root = test_directory(); + let path = root.join("proposal-limits.inkfinite"); + let actor = ActorId::from("actor:proposal"); + let mut service = SessionService::new(); + let opened = service + .create(&path, DocumentId::from("document:proposal-limits"), actor.clone(), None) + .expect("create session"); + let snapshot = opened.status.snapshot.clone(); + let mut oversized = agent_rename(&snapshot, actor.clone(), "Too many operations"); + oversized.operations = (0..=MAX_PROPOSAL_OPERATIONS) + .map(|_| Operation::RenamePage { + page_id: snapshot.document.page_ids[0].clone(), + name: "same".into(), + expected_version: None, + }) + .collect(); + assert!(matches!( + service.propose(&opened.session_id, oversized), + Err(SessionError::ProposalTooLarge { .. }) + )); + + let proposal = service + .propose(&opened.session_id, agent_rename(&snapshot, actor, "Will expire")) + .expect("propose expiring transaction"); + service + .sessions + .get_mut(&opened.session_id) + .unwrap() + .proposals + .get_mut(&proposal.id) + .unwrap() + .created_at = Instant::now() + .checked_sub(PROPOSAL_TTL) + .expect("proposal TTL is representable"); + assert!(matches!( + service.accept_proposal(&opened.session_id, &proposal.id, None), + Err(SessionError::ProposalExpired(_)) + )); + + service.close(&opened.session_id).expect("close session"); + remove_test_directory(root); + } + + fn agent_rename(snapshot: &DocumentSnapshot, actor_id: ActorId, name: &str) -> TransactionDraft { + let mut transaction = rename_transaction(snapshot, actor_id, name); + transaction.origin = Origin::Agent; + transaction.operations = vec![Operation::RenamePage { + page_id: snapshot.document.page_ids[0].clone(), + name: name.into(), + expected_version: None, + }]; + transaction + } + fn rename_transaction(snapshot: &DocumentSnapshot, actor_id: ActorId, name: &str) -> TransactionDraft { let page_id = snapshot.document.page_ids[0].clone(); TransactionDraft { diff --git a/packages/bindings/src/protocol.ts b/packages/bindings/src/protocol.ts index 8389b53..294f25b 100644 --- a/packages/bindings/src/protocol.ts +++ b/packages/bindings/src/protocol.ts @@ -1,7 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // Generated by Inkfinite's binding generator. Run `cargo run -p inkfinite-cli --bin generate-bindings`. -import type { ActorId, ChangeHash, DocumentId, DocumentSnapshot, JsonValue, Origin } from "./model.js"; +import type { ActorId, ChangeHash, DocumentId, DocumentSnapshot, JsonValue, Origin, Timestamp } from "./model.js"; import type { CommitResult, DocumentPath, Proposal, ProposalId, Query, QueryResult, SaveResult, TransactionDraft } from "./transaction.js"; /** @@ -9,6 +9,23 @@ import type { CommitResult, DocumentPath, Proposal, ProposalId, Query, QueryResu */ export type SessionId = string; +/** + * One-time user authorization for a direct live agent apply. + */ +export type ApplyAuthorization = { +/** + * Opaque single-use token issued by the desktop UI. + */ +token: string, +/** + * Session for which the authorization was issued. + */ +session_id: SessionId, +/** + * Wall-clock expiry retained for clients and diagnostics. + */ +expires_at: Timestamp, }; + /** * Transport-independent request accepted by desktop commands, IPC, or CLI adapters. */ @@ -48,7 +65,23 @@ session_id: SessionId, /** * Transaction to preview. */ -transaction: TransactionDraft, } | { "type": "accept_proposal", +transaction: TransactionDraft, } | { "type": "authorize_apply", +/** + * Open session to authorize. + */ +session_id: SessionId, } | { "type": "apply", +/** + * Open session to change. + */ +session_id: SessionId, +/** + * Transaction to validate and apply. + */ +transaction: TransactionDraft, +/** + * One-time authorization issued by the desktop UI. + */ +authorization: ApplyAuthorization, } | { "type": "accept_proposal", /** * Open session to change. */ @@ -125,7 +158,7 @@ session_id: SessionId, }; /** * Successful transport-independent response. */ -export type Response = { "type": "session_opened", "value": SessionId } | { "type": "snapshot", "value": DocumentSnapshot } | { "type": "committed", "value": CommitResult } | { "type": "proposed", "value": Proposal } | { "type": "proposal_rejected" } | { "type": "saved", "value": SaveResult } | { "type": "query_result", "value": QueryResult } | { "type": "valid" } | { "type": "closed" }; +export type Response = { "type": "session_opened", "value": SessionId } | { "type": "snapshot", "value": DocumentSnapshot } | { "type": "committed", "value": CommitResult } | { "type": "proposed", "value": Proposal } | { "type": "apply_authorized", "value": ApplyAuthorization } | { "type": "proposal_rejected" } | { "type": "saved", "value": SaveResult } | { "type": "query_result", "value": QueryResult } | { "type": "valid" } | { "type": "closed" }; /** * Stable protocol error returned across all adapters. diff --git a/packages/bindings/src/transaction.ts b/packages/bindings/src/transaction.ts index c73ef38..3cce8a6 100644 --- a/packages/bindings/src/transaction.ts +++ b/packages/bindings/src/transaction.ts @@ -517,8 +517,16 @@ transaction: TransactionDraft, * Preview patch shown by the UI. */ preview: DocumentPatch, +/** + * Document-coordinate regions affected by the proposed geometry. + */ +affected_regions: Array, /** * Validation or repair warnings shown before acceptance. */ -warnings: Array, }; +warnings: Array, +/** + * Wall-clock expiry retained for clients and diagnostics. + */ +expires_at: Timestamp, }; diff --git a/packages/ui/src/lib/editor/canvas/Canvas.svelte b/packages/ui/src/lib/editor/canvas/Canvas.svelte index cfa9540..8fa8989 100644 --- a/packages/ui/src/lib/editor/canvas/Canvas.svelte +++ b/packages/ui/src/lib/editor/canvas/Canvas.svelte @@ -5,6 +5,7 @@ import FileBrowser from '../filebrowser/FileBrowser.svelte'; import StencilPalette from '../components/StencilPalette.svelte'; import LayerPanel from '../components/LayerPanel.svelte'; + import ProposalReview from '../components/ProposalReview.svelte'; import { createCanvasController } from './canvas-store.svelte'; import { draggingStencil, endDrag } from '../dnd.svelte'; import type { EditorPlatformAdapter } from '../platform'; @@ -32,6 +33,8 @@ let markdownEditorCurrent = $derived(c.markdownEditor.current); let persistenceStatusStore = $derived(c.persistenceStatusStore()); let marqueeRect = $derived(c.marqueeRect()); + let liveProposal = $derived(c.proposal()); + let proposalMessage = $derived(c.proposalMessage()); $effect(() => { c.setCanvasRef(canvasEl); @@ -132,6 +135,23 @@ bind:this={canvasEl} ondblclick={c.handleCanvasDoubleClick} onpointerleave={c.handlePointerLeave}> + {#if liveProposal} + + {/if} {#if textEditorCurrent} {@const layout = c.textEditor.getLayout()} @@ -210,6 +230,12 @@ {/if} + (fallbackStatusStore); let activeBoardId: string | null = null; let desktopRepo: DesktopDocumentRepo | null = null; + let proposal = $state(null); + let proposalMessage = $state(null); + let unsubscribeProposal: (() => void) | null = null; let removeBeforeUnload: (() => void) | null = null; let stencilPaletteOpen = $state(false); let overlayViewport = $state({ width: 1, height: 1 }); @@ -411,6 +415,13 @@ export function createCanvasController( sink = platformSession.sink; persistenceStatusStore = platformSession.status; desktopRepo = platformSession.desktop ?? null; + if (desktopRepo) { + proposal = desktopRepo.getProposal(); + unsubscribeProposal = desktopRepo.subscribeProposal((update) => { + proposal = update.proposal; + proposalMessage = update.message ?? null; + }); + } if (platform === 'desktop') { if (desktopRepo) { @@ -450,6 +461,7 @@ export function createCanvasController( onDestroy(() => { renderer?.dispose(); inputAdapter?.dispose(); + unsubscribeProposal?.(); platformSession?.dispose?.(); if (platform === 'desktop') { void sink @@ -485,6 +497,20 @@ export function createCanvasController( brushStore, setCanvasRef, marqueeRect: () => marqueeRect, + proposal: () => proposal, + proposalMessage: () => proposalMessage, + acceptProposal: (operationPositions?: number[]) => { + if (!desktopRepo || !proposal) return Promise.reject(new Error('No proposal is open')); + return desktopRepo.acceptProposal(proposal.id, operationPositions); + }, + rejectProposal: () => { + if (!desktopRepo || !proposal) return Promise.reject(new Error('No proposal is open')); + return desktopRepo.rejectProposal(proposal.id); + }, + authorizeApply: () => { + if (!desktopRepo) return Promise.reject(new Error('No desktop session is open')); + return desktopRepo.authorizeApply(); + }, insertStencil, commitLayerState, get stencilPaletteOpen() { diff --git a/packages/ui/src/lib/editor/components/ProposalReview.svelte b/packages/ui/src/lib/editor/components/ProposalReview.svelte new file mode 100644 index 0000000..e8cd213 --- /dev/null +++ b/packages/ui/src/lib/editor/components/ProposalReview.svelte @@ -0,0 +1,399 @@ + + +{#if proposal} + +{:else if message} +
{message}
+{/if} + + diff --git a/packages/ui/src/lib/editor/components/ProposalReview.svelte.test.ts b/packages/ui/src/lib/editor/components/ProposalReview.svelte.test.ts new file mode 100644 index 0000000..aa6662d --- /dev/null +++ b/packages/ui/src/lib/editor/components/ProposalReview.svelte.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; + +import ProposalReview from './ProposalReview.svelte'; +import type { LiveProposal } from '../platform'; + +const proposal: LiveProposal = { + id: 'proposal:1', + transaction: { operations: [{ type: 'rename_page' }, { type: 'patch_shape' }] }, + preview: { created: ['shape:new'], changed: ['shape:changed'], deleted: ['shape:deleted'] }, + affected_regions: [{ page_id: 'page:one', bounds: { x: 10, y: 20, width: 80, height: 40 } }], + warnings: [], + expires_at: Date.now() + 60_000 +}; + +describe('ProposalReview', () => { + it('exposes review, partial acceptance, and rejection through accessible controls', async () => { + const accept = vi.fn(async () => undefined); + const reject = vi.fn(async () => undefined); + const authorize = vi.fn(async () => ({ + token: 'one-time-token', + session_id: 'session:1', + expires_at: 1 + })); + const screen = render(ProposalReview, { + proposal, + message: null, + onAccept: accept, + onReject: reject, + onAuthorize: authorize + }); + + await expect + .element(screen.getByRole('complementary', { name: 'Agent review' })) + .toBeInTheDocument(); + await expect.element(screen.getByText('proposal:1')).toBeInTheDocument(); + await expect.element(screen.getByText('created')).toBeInTheDocument(); + + await screen.getByRole('checkbox', { name: 'Select operation 2' }).click(); + await screen.getByRole('button', { name: 'Accept selected' }).click(); + await screen.getByRole('button', { name: 'Authorize direct apply' }).click(); + await screen.getByRole('button', { name: 'Reject' }).click(); + + expect(accept).toHaveBeenCalledWith([1]); + expect(reject).toHaveBeenCalledOnce(); + expect(authorize).toHaveBeenCalledOnce(); + await expect.element(screen.getByText('one-time-token')).toBeInTheDocument(); + }); + + it('announces a cleared proposal conflict', async () => { + const screen = render(ProposalReview, { + proposal: null, + message: 'The document changed while this proposal was open. Review it again.', + onAccept: vi.fn(async () => undefined), + onReject: vi.fn(async () => undefined) + }); + + await expect + .element(screen.getByRole('status')) + .toHaveTextContent( + 'The document changed while this proposal was open. Review it again.' + ); + }); +}); diff --git a/packages/ui/src/lib/editor/platform.ts b/packages/ui/src/lib/editor/platform.ts index fb567fc..1e7bd2f 100644 --- a/packages/ui/src/lib/editor/platform.ts +++ b/packages/ui/src/lib/editor/platform.ts @@ -10,6 +10,26 @@ import type { StatusStore } from "./status"; /** Runtime selected by an application composition root. */ export type EditorPlatform = "web" | "desktop"; +/** Small, renderer-facing projection of a Rust live proposal. */ +export type LiveProposal = { + id: string; + transaction: { operations: readonly unknown[] }; + preview: { + created: readonly unknown[]; + changed: readonly unknown[]; + deleted: readonly unknown[]; + }; + affected_regions: Array<{ + page_id: string; + bounds: { x: number; y: number; width: number; height: number }; + }>; + warnings: Array<{ code: string; message: string }>; + expires_at: number; +}; + +/** Change notification used by the desktop review surface. */ +export type ProposalUpdate = { proposal: LiveProposal | null; message?: string }; + /** * Desktop-only capabilities consumed by the shared editor. * @@ -23,6 +43,11 @@ export interface DesktopDocumentRepo extends PersistentDocRepo { setWorkspaceDir(path: string | null): Promise; pickWorkspaceDir(): Promise; closeSession(): Promise; + getProposal(): LiveProposal | null; + subscribeProposal(listener: (update: ProposalUpdate) => void): () => void; + acceptProposal(proposalId: string, operationPositions?: number[]): Promise; + rejectProposal(proposalId: string): Promise; + authorizeApply(): Promise<{ token: string; session_id: string; expires_at: number }>; } /** Connected persistence services used for one mounted editor. */ diff --git a/schemas/protocol-request.schema.json b/schemas/protocol-request.schema.json index 2f68a35..63a2a1e 100644 --- a/schemas/protocol-request.schema.json +++ b/schemas/protocol-request.schema.json @@ -5,6 +5,29 @@ "description": "Stable identifier for a human, agent, or system actor.", "type": "string" }, + "ApplyAuthorization": { + "description": "One-time user authorization for a direct live agent apply.", + "properties": { + "expires_at": { + "$ref": "#/$defs/Timestamp", + "description": "Wall-clock expiry retained for clients and diagnostics." + }, + "session_id": { + "$ref": "#/$defs/SessionId", + "description": "Session for which the authorization was issued." + }, + "token": { + "description": "Opaque single-use token issued by the desktop UI.", + "type": "string" + } + }, + "required": [ + "token", + "session_id", + "expires_at" + ], + "type": "object" + }, "AssetId": { "description": "Stable identifier for an embedded or linked asset.", "type": "string" @@ -2013,6 +2036,52 @@ ], "type": "object" }, + { + "description": "Issue a one-time authorization for a direct live apply.", + "properties": { + "session_id": { + "$ref": "#/$defs/SessionId", + "description": "Open session to authorize." + }, + "type": { + "const": "authorize_apply", + "type": "string" + } + }, + "required": [ + "type", + "session_id" + ], + "type": "object" + }, + { + "description": "Apply a transaction directly after explicit desktop authorization.", + "properties": { + "authorization": { + "$ref": "#/$defs/ApplyAuthorization", + "description": "One-time authorization issued by the desktop UI." + }, + "session_id": { + "$ref": "#/$defs/SessionId", + "description": "Open session to change." + }, + "transaction": { + "$ref": "#/$defs/TransactionDraft", + "description": "Transaction to validate and apply." + }, + "type": { + "const": "apply", + "type": "string" + } + }, + "required": [ + "type", + "session_id", + "transaction", + "authorization" + ], + "type": "object" + }, { "description": "Accept all or selected operations from a proposal and revalidate at current heads.", "properties": { diff --git a/schemas/protocol-response.schema.json b/schemas/protocol-response.schema.json index cdd6f4e..c98ee7d 100644 --- a/schemas/protocol-response.schema.json +++ b/schemas/protocol-response.schema.json @@ -23,6 +23,29 @@ ], "type": "object" }, + "ApplyAuthorization": { + "description": "One-time user authorization for a direct live agent apply.", + "properties": { + "expires_at": { + "$ref": "#/$defs/Timestamp", + "description": "Wall-clock expiry retained for clients and diagnostics." + }, + "session_id": { + "$ref": "#/$defs/SessionId", + "description": "Session for which the authorization was issued." + }, + "token": { + "description": "Opaque single-use token issued by the desktop UI.", + "type": "string" + } + }, + "required": [ + "token", + "session_id", + "expires_at" + ], + "type": "object" + }, "AssetId": { "description": "Stable identifier for an embedded or linked asset.", "type": "string" @@ -1387,6 +1410,17 @@ "Proposal": { "description": "Validated transaction held for explicit user review.", "properties": { + "affected_regions": { + "description": "Document-coordinate regions affected by the proposed geometry.", + "items": { + "$ref": "#/$defs/AffectedRegion" + }, + "type": "array" + }, + "expires_at": { + "$ref": "#/$defs/Timestamp", + "description": "Wall-clock expiry retained for clients and diagnostics." + }, "id": { "$ref": "#/$defs/ProposalId", "description": "Stable proposal identifier." @@ -1411,7 +1445,9 @@ "id", "transaction", "preview", - "warnings" + "affected_regions", + "warnings", + "expires_at" ], "type": "object" }, @@ -2314,6 +2350,23 @@ ], "type": "object" }, + { + "description": "A one-time direct-apply authorization was issued.", + "properties": { + "type": { + "const": "apply_authorized", + "type": "string" + }, + "value": { + "$ref": "#/$defs/ApplyAuthorization" + } + }, + "required": [ + "type", + "value" + ], + "type": "object" + }, { "description": "A proposal was rejected and removed.", "properties": {