diff --git a/README.md b/README.md index 90f0a0b..34f8792 100644 --- a/README.md +++ b/README.md @@ -44,14 +44,14 @@ pnpm tauri dev ## CLI -The `inkfinite` CLI works on `.inkfinite` files while the desktop app is closed. -It can also inspect a running desktop app through authenticated local IPC. +The `inkfinite` CLI works with closed `.inkfinite` files and open desktop +sessions. Live commands use authenticated local IPC. Run it through Cargo during development: ```sh inkfinite new architecture.inkfinite -inkfinite inspect architecture.inkfinite +inkfinite inspect architecture.inkfinite --summary inkfinite validate architecture.inkfinite ``` @@ -59,7 +59,8 @@ Use `query` to find records by ID, name, role, tag, shape kind, page, layer, parent, or bounds: ```sh -inkfinite query architecture.inkfinite --role architecture.service --json +inkfinite query architecture.inkfinite \ + --role architecture.service --detail --limit 20 --json ``` Use `apply` for a complete transaction draft. Pass `-` to read the transaction @@ -71,12 +72,13 @@ inkfinite apply architecture.inkfinite --transaction transaction.json --dry-run cat transaction.json | inkfinite apply architecture.inkfinite --transaction - --json ``` -The structured mutation commands build ordinary transactions. Shapes can be -selected by exact ID, name, or semantic role, depending on the command: +The structured mutation commands build ordinary transactions. They generate +shape and binding IDs when you omit them. Shapes can be selected by exact ID, +name, or semantic role: ```sh inkfinite shape create architecture.inkfinite \ - --shape-id shape:api --kind rect --layer layer:architecture:1 \ + --kind rect --layer layer:architecture:1 \ --x 80 --y 120 --properties '{"width":240,"height":120}' \ --role architecture.service inkfinite shape patch architecture.inkfinite --role architecture.service \ @@ -87,6 +89,11 @@ inkfinite layout align architecture.inkfinite \ --role architecture.service --alignment top ``` +Use `--transaction-out FILE` to validate a structured edit and write its +transaction without changing the document. The destination must not exist. +Use `shape kinds` and `shape describe KIND` to inspect the built-in shape +contract instead of guessing at property names. + `shape delete` removes a selected shape and its descendants. `layout align` requires at least two shapes, and `layout distribute` requires at least three. The transaction engine rejects locked layers, locked shapes, stale heads, @@ -104,8 +111,9 @@ inkfinite render architecture.inkfinite --output services.svg \ Mutation results include the previous and current heads, transaction ID, created, updated, and deleted records, repairs, and warnings. `--json` and `--non-interactive` are global options and may appear before or after a -subcommand. JSON commands keep stdout machine-readable and send diagnostics to -stderr. +subcommand. JSON commands keep successful data on stdout. Failures are JSON on +stderr with a stable code, structured details, retryability, and a suggested +next step. The CLI can also print its schemas and capability contract: @@ -118,36 +126,44 @@ 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. -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: +With the desktop app running, use `app status`, `app context`, `app inspect`, and +`app query` to read current state. `app context` includes the active page, +selection, viewport, actor, and heads. Add `--app` to a structured mutation to +open a ghost preview instead of changing a file. The user accepts or rejects +that proposal in the desktop UI; agent-facing IPC cannot make the decision. +Use `app proposal status` for one check or `app proposal wait` to wait for the +result. ```sh inkfinite app status --json +inkfinite app context --json inkfinite app inspect --json -inkfinite app query --role architecture.service --json +inkfinite app query --role architecture.service --detail --limit 20 --json inkfinite app focus +inkfinite shape patch --app --role architecture.service \ + --patch '@service-patch.json' --json 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 proposal wait --proposal-id proposal:1 --json inkfinite app apply --transaction transaction.json --authorization TOKEN --json ``` +`app propose` remains available for operations that the structured commands do +not cover. Direct `app apply` requires a one-time authorization token issued by +the desktop UI. + The desktop publishes a per-user Unix-domain socket on Unix-like systems or a per-user named pipe on Windows. A protected discovery file carries a random process token, and requests use versioned length-prefixed frames. ### Agent skill -The installable agent package lives in [`skills/inkfinite`](skills/inkfinite). +The installable agent package lives in [`.agents/skills/inkfinite`](.agents/skills/inkfinite). It teaches the inspect/query/transaction/dry-run/proposal workflow and includes -fixtures for file-mode edits, reviewed partial acceptance, and stale-head recovery. +fixtures for file edits, desktop review polling, and stale-head recovery. ```sh INKFINITE_CLI="$PWD/target/debug/inkfinite" \ - bash skills/inkfinite/scripts/verify-examples.sh + bash .agents/skills/inkfinite/scripts/verify-examples.sh ``` ## Inkfinite files diff --git a/apps/desktop/src-tauri/src/ipc.rs b/apps/desktop/src-tauri/src/ipc.rs index b145fab..dc6d164 100644 --- a/apps/desktop/src-tauri/src/ipc.rs +++ b/apps/desktop/src-tauri/src/ipc.rs @@ -224,11 +224,11 @@ fn dispatch_request( app: &AppHandle, service: &Arc>, request: AppRequest, ) -> Result { let requested_session = match &request { - AppRequest::Inspect { session_id } + AppRequest::Context { session_id } + | AppRequest::Inspect { session_id } | AppRequest::Query { session_id, .. } | AppRequest::Propose { session_id, .. } - | AppRequest::AcceptProposal { session_id, .. } - | AppRequest::RejectProposal { session_id, .. } + | AppRequest::ProposalStatus { session_id, .. } | AppRequest::Apply { session_id, .. } => session_id.clone(), AppRequest::Status | AppRequest::Focus => None, }; @@ -271,12 +271,6 @@ fn dispatch_request( 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, @@ -288,7 +282,12 @@ fn dispatch_request( format!("could not notify the desktop frontend: {error}"), ) })?, - AppResponse::Status(_) | AppResponse::Snapshot(_) | AppResponse::QueryResult(_) | AppResponse::Focused => {} + AppResponse::Status(_) + | AppResponse::Context(_) + | AppResponse::Snapshot(_) + | AppResponse::QueryResult(_) + | AppResponse::ProposalStatus(_) + | AppResponse::Focused => {} } Ok(response) } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 4effd15..f722e94 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -27,6 +27,7 @@ pub fn run() { session::open_document, session::open_or_create_draft, session::snapshot, + session::update_context, session::commit, session::propose, session::accept_proposal, diff --git a/apps/desktop/src-tauri/src/session.rs b/apps/desktop/src-tauri/src/session.rs index b50cf89..d9fc73a 100644 --- a/apps/desktop/src-tauri/src/session.rs +++ b/apps/desktop/src-tauri/src/session.rs @@ -5,14 +5,14 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, MutexGuard}; use inkfinite_core::proto::{ - ApplyAuthorization, DocumentPath, Proposal, ProposalId, ProtocolError, Query, QueryResult, SessionId, + ApplyAuthorization, Bounds, DocumentPath, Proposal, ProposalId, ProtocolError, Query, QueryResult, SessionId, TransactionDraft, }; use inkfinite_core::session::{ SessionCommit, SessionError, SessionOpened, SessionSaved, SessionService, SessionStatus, SessionSync, }; use inkfinite_core::sync::SyncMessage; -use inkfinite_core::{ActorId, ChangeHash, DocumentId}; +use inkfinite_core::{ActorId, ChangeHash, DocumentId, PageId, ShapeId}; use serde_json::json; use tauri::{AppHandle, Emitter, Manager, State}; @@ -169,6 +169,22 @@ pub fn snapshot(state: State<'_, DesktopState>, session_id: String) -> Result, session_id: String, page_id: Option, selection_ids: Vec, + viewport: Option, +) -> Result<()> { + lock_service(&state)? + .update_context( + &SessionId(session_id), + page_id.map(PageId::from), + selection_ids.into_iter().map(ShapeId::from).collect(), + viewport, + ) + .map_err(to_protocol_error) +} + /// Commits one typed transaction through the shared transaction engine. #[tauri::command] pub fn commit( diff --git a/apps/desktop/src/lib/persistence/desktop-session.test.ts b/apps/desktop/src/lib/persistence/desktop-session.test.ts index 77a9f7c..f9ce047 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.test.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.test.ts @@ -46,6 +46,7 @@ function createFakeSessionApi() { const draftPath = '/app-data/drafts/untitled.inkfinite'; const files = new Map(); const sessions = new Map(); + const agentContexts: Array[0]> = []; let sessionNumber = 0; let headNumber = 0; @@ -124,6 +125,10 @@ function createFakeSessionApi() { return statusFor(args.session_id, session); }, + async updateContext(args: Parameters[0]): Promise { + agentContexts.push(structuredClone(args)); + }, + async commit(args: Parameters[0]): Promise { const session = sessions.get(args.session_id); if (!session) throw new Error('Missing fake session'); @@ -245,7 +250,14 @@ function createFakeSessionApi() { async query(args: Parameters[0]) { const session = sessions.get(args.session_id); if (!session) throw new Error('Missing fake session'); - return { heads: session.status.snapshot.heads, records: [], bounds: {} }; + return { + heads: session.status.snapshot.heads, + records: [], + bounds: {}, + details: [], + total: 0, + truncated: false + }; }, async validate(args: Parameters[0]) { @@ -288,7 +300,7 @@ function createFakeSessionApi() { } } satisfies SessionApi; - return { api, files, draftPath }; + return { api, files, draftPath, agentContexts }; } function createFakeFileOps() { @@ -389,7 +401,9 @@ describe('Rust-backed desktop session repository', () => { page_id: null, layer_id: null, parent_id: null, - bounds: null + bounds: null, + include_records: false, + limit: null }) ).records ).toEqual([]); @@ -412,6 +426,22 @@ describe('Rust-backed desktop session repository', () => { expect(reopened.pages[pageId].name).toBe('Renamed'); }); + it('publishes editor context only after a desktop session is open', async () => { + const repo = createDesktopSessionRepo(fileOps.ops, { api: session.api }); + await repo.updateAgentContext({ pageId: 'page:none', selectionIds: [], viewport: null }); + expect(session.agentContexts).toEqual([]); + + const opened = await repo.openDraft(); + const pageId = opened.doc.order.pageIds[0]; + await repo.updateAgentContext({ + pageId, + selectionIds: [], + viewport: { x: -100, y: -50, width: 200, height: 100 } + }); + + expect(session.agentContexts.at(-1)).toMatchObject({ page_id: pageId, viewport: { width: 200, height: 100 } }); + }); + it('lists native document paths without reading document bytes in the frontend', async () => { fileOps.setWorkspace('/workspace'); fileOps.setEntries([ diff --git a/apps/desktop/src/lib/persistence/desktop-session.ts b/apps/desktop/src/lib/persistence/desktop-session.ts index a1cb945..52c15df 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.ts @@ -116,6 +116,12 @@ export interface SessionApi { openDocument(args: { path: string; actor_id: string }): Promise; openOrCreateDraft(args: { document_id: string; actor_id: string }): Promise; snapshot(args: { session_id: string }): Promise; + updateContext(args: { + session_id: string; + page_id: string | null; + selection_ids: string[]; + viewport: { x: number; y: number; width: number; height: number } | null; + }): Promise; commit(args: { session_id: string; transaction: TransactionDraft }): Promise; propose(args: { session_id: string; transaction: TransactionDraft }): Promise; acceptProposal(args: { @@ -156,6 +162,13 @@ function createSessionApi(): SessionApi { actorId: args.actor_id }), snapshot: (args) => invokeSession('snapshot', { sessionId: args.session_id }), + updateContext: (args) => + invokeSession('update_context', { + sessionId: args.session_id, + pageId: args.page_id, + selectionIds: args.selection_ids, + viewport: args.viewport + }), commit: (args) => invokeSession('commit', { sessionId: args.session_id, transaction: args.transaction }), propose: (args) => @@ -245,6 +258,12 @@ export type DesktopSessionRepo = PersistentDocRepo & { acceptProposal(proposalId: string, operationPositions?: number[]): Promise; rejectProposal(proposalId: string): Promise; authorizeApply(): Promise; + /** Publishes editor-only context for read-only agent queries. */ + updateAgentContext(context: { + pageId: string | null; + selectionIds: string[]; + viewport: { x: number; y: number; width: number; height: number } | null; + }): Promise; syncConnect(peerId: string): Promise; syncDisconnect(peerId: string): Promise; syncNext(peerId: string): Promise; @@ -688,6 +707,20 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: return api.authorizeApply({ session_id: currentStatus.session_id }); } + async function updateAgentContext(context: { + pageId: string | null; + selectionIds: string[]; + viewport: { x: number; y: number; width: number; height: number } | null; + }): Promise { + if (!currentStatus) return; + await api.updateContext({ + session_id: currentStatus.session_id, + page_id: context.pageId, + selection_ids: context.selectionIds, + viewport: context.viewport + }); + } + async function syncConnect(peerId: string): Promise { if (!currentStatus) throw new Error('No board loaded'); const status = await api.syncConnect({ session_id: currentStatus.session_id, peer_id: peerId }); @@ -743,6 +776,7 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: acceptProposal, rejectProposal, authorizeApply, + updateAgentContext, syncConnect, syncDisconnect, syncNext, diff --git a/apps/desktop/src/lib/persistence/desktop-workspace.test.ts b/apps/desktop/src/lib/persistence/desktop-workspace.test.ts index 15dbdbc..58f6063 100644 --- a/apps/desktop/src/lib/persistence/desktop-workspace.test.ts +++ b/apps/desktop/src/lib/persistence/desktop-workspace.test.ts @@ -58,6 +58,9 @@ describe('desktop workspace adapter', () => { snapshot: async () => { throw new Error('not used'); }, + updateContext: async () => { + throw new Error('not used'); + }, commit: async () => { throw new Error('not used'); }, diff --git a/apps/web/src/routes/docs/applications/desktop/+page.svx b/apps/web/src/routes/docs/applications/desktop/+page.svx index 8baeee4..6aef940 100644 --- a/apps/web/src/routes/docs/applications/desktop/+page.svx +++ b/apps/web/src/routes/docs/applications/desktop/+page.svx @@ -35,8 +35,12 @@ live proposal workflow instead.

Local CLI access

The desktop app exposes authenticated IPC to the local `inkfinite` CLI. Read-only commands can list -sessions, inspect the open document, query records, or focus the window. Mutation commands normally -create a proposal that appears as a ghost preview for human review. +sessions, report the active page, selection, and viewport, inspect the open document, query records, +or focus the window. Structured mutation commands create a proposal that appears as a ghost preview +for human review. + +Only the desktop UI can accept or reject a proposal. The CLI can poll the outcome, which lets an +agent wait for a decision without gaining review authority. Direct live apply requires a one-time authorization token issued by the desktop UI. Discovery and authentication remain local to the current user account. See [Agent workflows](/docs/reference/agents/) diff --git a/apps/web/src/routes/docs/reference/agents/+page.svx b/apps/web/src/routes/docs/reference/agents/+page.svx index d429371..2feb2c6 100644 --- a/apps/web/src/routes/docs/reference/agents/+page.svx +++ b/apps/web/src/routes/docs/reference/agents/+page.svx @@ -11,8 +11,8 @@ Use Inkfinite's command-line and proposal tools to make reviewable document chan For a saved file, follow the same sequence for every change: -1. Run `inspect --json` and keep the returned heads. -2. Use `query --json` to read only the relevant records. +1. Run `inspect --summary --json` and keep the returned heads. +2. Use `query --detail --limit N --json` to read only the relevant records. 3. Build the smallest transaction that satisfies the request. 4. Run the mutation with `--dry-run --json`. 5. Apply it only after the dry run passes. @@ -23,9 +23,13 @@ matches multiple shapes, narrow it with an exact ID. Do not guess which one the

Proposals

-For a document open in the desktop app, use `app propose` instead of changing its file. The app -shows created, changed, and deleted records as a ghost preview. The human can accept all operations, -accept selected operations, or reject the proposal. +For a document open in the desktop app, add `--app` to a structured `shape`, `connect`, or `layout` +command instead of changing its file. Use `app propose` only when those commands cannot express the +edit. The app shows created, changed, and deleted records as a ghost preview. + +The user accepts all or selected operations, or rejects the proposal, in the desktop UI. The agent +can observe the result with `app proposal status` or wait with `app proposal wait`; it cannot make +the review decision through IPC. Partial acceptance creates a transaction from the approved operations and validates it again. A stale proposal may need a refreshed preview after another edit changes the document. Review the @@ -51,15 +55,15 @@ means the object changed after the agent inspected it.

Skills

-The repository includes an installable agent skill at `skills/inkfinite`. It documents the safe -workflow and includes runnable examples for file edits, proposal review, partial acceptance, and -stale-head recovery. +The repository includes an installable agent skill at `.agents/skills/inkfinite`. It documents the +safe workflow and includes runnable examples for file edits, proposal review, and stale-head +recovery. After building the CLI, verify the examples from the repository root: ```sh INKFINITE_CLI="$PWD/target/debug/inkfinite" \ - bash skills/inkfinite/scripts/verify-examples.sh + bash .agents/skills/inkfinite/scripts/verify-examples.sh ``` The fixture server used by those examples tests the protocol. It does not replace a real desktop diff --git a/apps/web/src/routes/docs/reference/cli/+page.svx b/apps/web/src/routes/docs/reference/cli/+page.svx index ac14d7c..f61713c 100644 --- a/apps/web/src/routes/docs/reference/cli/+page.svx +++ b/apps/web/src/routes/docs/reference/cli/+page.svx @@ -18,7 +18,7 @@ commands are: | `inspect` | Print a document summary or materialized snapshot | | `query` | Find records by semantic, structural, kind, or bounds filters | | `validate` | Load and validate a canonical document | -| `shape`, `connect`, `layout` | Build and apply structured transactions | +| `shape`, `connect`, `layout` | Build a structured file edit or desktop proposal | | `apply` | Validate and apply a transaction draft from JSON | | `render` | Write a deterministic SVG of a document or filtered view | | `app` | Inspect or work with a running desktop session | @@ -36,8 +36,9 @@ File-mode commands operate on a closed `.inkfinite` file. Start by inspecting it only the records you need: ```sh -inkfinite inspect architecture.inkfinite --json -inkfinite query architecture.inkfinite --role architecture.service --json +inkfinite inspect architecture.inkfinite --summary --json +inkfinite query architecture.inkfinite \ + --role architecture.service --detail --limit 20 --json ``` Prefer `shape create`, `shape patch`, `shape delete`, `connect`, and `layout` when one of them @@ -58,26 +59,41 @@ inkfinite apply architecture.inkfinite \ File commands never prompt. Close the desktop editor before changing its file; a lock or stale-head error is a signal to inspect current state, not a reason to overwrite the file. +To hand the edit to another process without changing the document, add +`--transaction-out transaction.json` to a structured mutation. The CLI validates the transaction +and refuses to overwrite an existing output file. Run `shape kinds` or `shape describe KIND` to +discover shape contracts. +

Live mode

-With the desktop app running, use `app status`, `app inspect`, and `app query` for read-only access. -`app propose` sends a transaction to the canvas as a reviewable ghost preview: +With the desktop app running, use `app status`, `app context`, `app inspect`, and `app query` for +read-only access. Context reports the active page, selection, viewport, actor, and current heads. +Add `--app` to a structured mutation to open a reviewable ghost preview: ```sh inkfinite app status --json -inkfinite app query --role architecture.service --json +inkfinite app context --json +inkfinite app query --role architecture.service --detail --limit 20 --json +inkfinite shape patch --app --role architecture.service \ + --patch '@service-patch.json' --json inkfinite app propose --transaction transaction.json --json +inkfinite app proposal wait --proposal-id proposal:1 --json ``` -The human can accept the whole proposal or selected operations, or reject it without changing the -document. `app apply` bypasses proposal review only when the desktop UI has issued a one-time -authorization token for that action. Do not store that token in a document or script. +The user accepts all or selected operations, or rejects the proposal, in the desktop UI. +Agent-facing IPC can observe that decision with `app proposal status` or `app proposal wait`, but it +cannot make the decision. Use raw `app propose` only when the structured commands cannot express +the edit. + +`app apply` bypasses proposal review only when the desktop UI has issued a one-time authorization +token for that action. Do not store that token in a document or script.

Output format

Pass `--json` for deterministic machine-readable output. Successful mutations report the previous and current heads, transaction ID, created, updated, and deleted records, repairs, and warnings. -Diagnostics go to standard error so standard output remains safe to pipe into another program. +Failures go to standard error as JSON with `code`, `message`, `details`, `retryable`, and +`suggestion`, so agents can respond without parsing prose. `capabilities --json` reports supported commands and stable exit codes. Current exit codes are: diff --git a/crates/inkfinite-cli/src/bin/generate-bindings.rs b/crates/inkfinite-cli/src/bin/generate-bindings.rs index 00fdc97..8ce03bc 100644 --- a/crates/inkfinite-cli/src/bin/generate-bindings.rs +++ b/crates/inkfinite-cli/src/bin/generate-bindings.rs @@ -8,8 +8,8 @@ use std::path::{Path, PathBuf}; use inkfinite_core::proto::{ 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, + QueryRecord, QueryResult, RecordId, Request, Response, SaveResult, SessionId, ShapeAlignment, ShapePatch, + TransactionDraft, TransactionId, Warning, }; use inkfinite_core::{ ActorId, AssetId, AssetRecord, AssetSource, BindingAnchor, BindingId, BindingKind, BindingRecord, ChangeHash, @@ -185,14 +185,15 @@ fn transaction_bindings() -> String { append_declaration::(&mut output, &config); append_declaration::(&mut output, &config); append_declaration::(&mut output, &config); + append_clean_declaration::(&mut output, &config); 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); append_declaration::(&mut output, &config); - append_declaration::(&mut output, &config); - append_declaration::(&mut output, &config); + append_clean_declaration::(&mut output, &config); + append_clean_declaration::(&mut output, &config); append_declaration::(&mut output, &config); append_declaration::(&mut output, &config); append_declaration::(&mut output, &config); @@ -229,7 +230,7 @@ fn registry_bindings() -> String { fn index_bindings() -> String { format!( - "{GENERATED_TS_HEADER}export * from \"./model.js\";\nexport * from \"./protocol.js\";\nexport * from \"./registry.js\";\nexport * from \"./transaction.js\";\n" + "{GENERATED_TS_HEADER}export * from './model.js';\nexport * from './protocol.js';\nexport * from './registry.js';\nexport * from './transaction.js';\n" ) } @@ -246,6 +247,18 @@ fn append_declaration(output: &mut String, config: &Config) { output.push_str("\n\n"); } +fn append_clean_declaration(output: &mut String, config: &Config) { + if let Some(docs) = T::docs() { + output.push_str(&docs); + } + let declaration = format!("export {}", T::decl(config)); + for line in declaration.lines() { + output.push_str(line.trim_end()); + output.push('\n'); + } + output.push('\n'); +} + fn fixture_json() -> Result> { let shape = fixture_shape(); let transaction = fixture_transaction(); diff --git a/crates/inkfinite-cli/src/bin/inkfinite-cli.rs b/crates/inkfinite-cli/src/bin/inkfinite-cli.rs index e67a20a..628b7ed 100644 --- a/crates/inkfinite-cli/src/bin/inkfinite-cli.rs +++ b/crates/inkfinite-cli/src/bin/inkfinite-cli.rs @@ -8,10 +8,32 @@ use clap::Parser; mod cli; fn main() { - let cli = cli::Cli::parse(); + let arguments = std::env::args_os().collect::>(); + let json_output = arguments.iter().any(|argument| argument == "--json"); + let cli = match cli::Cli::try_parse_from(arguments) { + Ok(cli) => cli, + Err(error) if json_output && error.exit_code() != 0 => { + let diagnostic = serde_json::json!({ + "error": { + "code": "invalid_usage", + "message": error.to_string(), + "details": null, + "retryable": false, + "suggestion": "Run the command with --help to inspect its accepted arguments." + } + }); + eprintln!("{diagnostic}"); + std::process::exit(error.exit_code()); + } + Err(error) => error.exit(), + }; let mut stdout = io::stdout().lock(); if let Err(error) = cli::run(cli.command, cli.json, &mut stdout) { - eprintln!("inkfinite: {:#}", error.source); + if cli.json { + eprintln!("{}", error.diagnostic()); + } else { + eprintln!("inkfinite: {:#}", error.source); + } std::process::exit(error.exit_code()); } } diff --git a/crates/inkfinite-cli/src/cli/app.rs b/crates/inkfinite-cli/src/cli/app.rs index 7905bd0..e136041 100644 --- a/crates/inkfinite-cli/src/cli/app.rs +++ b/crates/inkfinite-cli/src/cli/app.rs @@ -1,30 +1,111 @@ //! Commands for inspecting and focusing a running desktop app. +use std::time::{Duration, Instant}; + use inkfinite_core::ipc::{self, AppRequest, AppResponse, IpcError}; use inkfinite_core::proto::{ApplyAuthorization, ProposalId, Query, RecordId, SessionId}; +use inkfinite_core::session::{ProposalReviewState, ProposalStatus}; use inkfinite_core::{LayerId, PageId}; use super::apply::read_transaction; use super::args::{ - AppAcceptArgs, AppApplyArgs, AppCommand, AppInspectArgs, AppProposeArgs, AppQueryArgs, AppRejectArgs, + AppApplyArgs, AppCommand, AppInspectArgs, AppProposalCommand, AppProposalWaitArgs, AppProposeArgs, AppQueryArgs, }; use super::support::{map_output_error, write_heads, write_json}; use super::{CliError, EXIT_CONFLICT, EXIT_INPUT, EXIT_INVALID, Result, Write, anyhow, json}; -/// Runs one authenticated read-only desktop command. +/// Runs one authenticated desktop command. pub fn run_app_command(command: AppCommand, json_output: bool, stdout: &mut dyn Write) -> Result<()> { match command { AppCommand::Status => status(json_output, stdout), + AppCommand::Context(args) => context(args, 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::Proposal(command) => proposal(command, json_output, stdout), AppCommand::Apply(args) => apply(args, json_output, stdout), AppCommand::Focus => focus(json_output, stdout), } } +fn context(args: AppInspectArgs, json_output: bool, stdout: &mut dyn Write) -> Result<()> { + let response = send(AppRequest::Context { session_id: args.session_id.map(SessionId) })?; + let AppResponse::Context(context) = response else { + return unexpected_response("context"); + }; + if json_output { + return write_json(stdout, &context); + } + writeln!(stdout, "Session: {}", context.session_id.0).map_err(map_output_error)?; + writeln!( + stdout, + "Page: {}", + context.page_id.as_ref().map_or("none", PageId::as_str) + ) + .map_err(map_output_error)?; + writeln!(stdout, "Selection: {}", context.selection_ids.len()).map_err(map_output_error)?; + if let Some(viewport) = context.viewport { + writeln!( + stdout, + "Viewport: {},{},{},{}", + viewport.x, viewport.y, viewport.width, viewport.height + ) + .map_err(map_output_error)?; + } + write_heads(stdout, &context.heads) +} + +fn proposal(command: AppProposalCommand, json_output: bool, stdout: &mut dyn Write) -> Result<()> { + match command { + AppProposalCommand::Status(args) => { + let status = fetch_proposal_status(args.session_id, args.proposal_id)?; + write_proposal_status(&status, json_output, stdout) + } + AppProposalCommand::Wait(args) => wait_for_proposal(&args, json_output, stdout), + } +} + +fn fetch_proposal_status(session_id: Option, proposal_id: String) -> Result { + let response = send(AppRequest::ProposalStatus { + session_id: session_id.map(SessionId), + proposal_id: ProposalId(proposal_id), + })?; + let AppResponse::ProposalStatus(status) = response else { + return Err(CliError::new( + EXIT_INVALID, + anyhow!("desktop app returned an unexpected response for proposal status"), + )); + }; + Ok(status) +} + +fn wait_for_proposal(args: &AppProposalWaitArgs, json_output: bool, stdout: &mut dyn Write) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(args.timeout_seconds); + loop { + let status = fetch_proposal_status(args.session_id.clone(), args.proposal_id.clone())?; + if status.state != ProposalReviewState::Pending { + return write_proposal_status(&status, json_output, stdout); + } + if Instant::now() >= deadline { + return Err( + CliError::new(EXIT_CONFLICT, anyhow!("proposal {} is still pending", args.proposal_id)) + .with_code("proposal_wait_timeout") + .retryable("Continue waiting or ask the user to review the proposal in Inkfinite Desktop."), + ); + } + std::thread::sleep(Duration::from_millis(250)); + } +} + +fn write_proposal_status(status: &ProposalStatus, json_output: bool, stdout: &mut dyn Write) -> Result<()> { + if json_output { + return write_json(stdout, status); + } + writeln!(stdout, "Proposal: {}", status.proposal_id.0).map_err(map_output_error)?; + writeln!(stdout, "State: {:?}", status.state).map_err(map_output_error)?; + write_heads(stdout, &status.heads) +} + fn status(json_output: bool, stdout: &mut dyn Write) -> Result<()> { let response = send(AppRequest::Status)?; let AppResponse::Status(statuses) = response else { @@ -78,6 +159,8 @@ fn query(args: AppQueryArgs, json_output: bool, stdout: &mut dyn Write) -> Resul layer_id: args.layer.map(LayerId::from), parent_id: args.parent, bounds: args.bounds, + include_records: args.detail, + limit: args.limit, }; let response = send(AppRequest::Query { session_id: args.session_id.map(inkfinite_core::proto::SessionId), query })?; @@ -89,7 +172,10 @@ fn query(args: AppQueryArgs, json_output: bool, stdout: &mut dyn Write) -> Resul } write_heads(stdout, &result.heads)?; - writeln!(stdout, "Matches: {}", result.records.len()).map_err(map_output_error)?; + writeln!(stdout, "Matches: {} of {}", result.records.len(), result.total).map_err(map_output_error)?; + if result.truncated { + writeln!(stdout, "Truncated: true").map_err(map_output_error)?; + } for record in result.records { match record { RecordId::Page(id) => writeln!(stdout, "page\t{id}"), @@ -124,7 +210,15 @@ 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 })?; + propose_transaction(transaction, args.session_id.map(SessionId), json_output, stdout) +} + +/// Submits a prepared transaction to the desktop review surface. +pub fn propose_transaction( + transaction: inkfinite_core::proto::TransactionDraft, session_id: Option, json_output: bool, + stdout: &mut dyn Write, +) -> Result<()> { + let response = send(AppRequest::Propose { session_id, transaction })?; let AppResponse::Proposal(proposal) = response else { return unexpected_response("propose"); }; @@ -139,34 +233,6 @@ fn propose(args: AppProposeArgs, json_output: bool, stdout: &mut dyn Write) -> R 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)?; @@ -186,6 +252,11 @@ fn resolve_session_id(session_id: Option) -> Result { if let Some(session_id) = session_id { return Ok(SessionId(session_id)); } + Ok(resolve_session_status(None)?.session_id) +} + +/// Resolves explicit session state or requires exactly one open session. +pub fn resolve_session_status(session_id: Option) -> Result { let response = send(AppRequest::Status)?; let AppResponse::Status(statuses) = response else { return Err(CliError::new( @@ -193,13 +264,22 @@ fn resolve_session_id(session_id: Option) -> Result { anyhow!("desktop app returned an unexpected response for status"), )); }; + if let Some(session_id) = session_id { + return statuses + .into_iter() + .find(|status| status.session_id.0 == session_id) + .ok_or_else(|| { + CliError::new(EXIT_INPUT, anyhow!("desktop session {session_id} is not open")) + .with_code("session_not_found") + }); + } 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()) + Ok(status.clone()) } fn write_commit( @@ -231,7 +311,22 @@ fn send(request: AppRequest) -> Result { | "document_engine_error" => EXIT_CONFLICT, _ => EXIT_INVALID, }; - CliError::new(exit_code, anyhow!("[{}] {}", error.code, error.message)) + let retryable = matches!( + error.code.as_str(), + "proposal_stale" + | "stale_heads" + | "precondition_failed" + | "session_selection_required" + | "desktop_unavailable" + ); + let mut diagnostic = CliError::new(exit_code, anyhow!(error.message)).with_code(error.code); + if let Some(details) = error.details { + diagnostic = diagnostic.with_details(details); + } + if retryable { + diagnostic = diagnostic.retryable("Refresh desktop status and current heads before retrying."); + } + diagnostic }) } @@ -242,7 +337,10 @@ fn map_ipc_error(error: IpcError) -> CliError { } IpcError::TruncatedFrame | IpcError::Unavailable(_) | IpcError::Io(_) => EXIT_INPUT, }; - CliError::new(exit_code, anyhow!(error)).context("could not contact the running desktop app") + CliError::new(exit_code, anyhow!(error)) + .with_code("desktop_unavailable") + .retryable("Start Inkfinite Desktop, then retry the command.") + .context("could not contact the running desktop app") } fn unexpected_response(command: &str) -> Result<()> { diff --git a/crates/inkfinite-cli/src/cli/apply.rs b/crates/inkfinite-cli/src/cli/apply.rs index 2ddfbc6..ab1d972 100644 --- a/crates/inkfinite-cli/src/cli/apply.rs +++ b/crates/inkfinite-cli/src/cli/apply.rs @@ -5,7 +5,7 @@ use super::{ApplyArgs, CliError, EXIT_INPUT, EXIT_INVALID, Path, Read, Result, T pub fn apply_transaction(args: &ApplyArgs, json_output: bool, stdout: &mut dyn Write) -> Result<()> { let transaction = read_transaction(&args.transaction)?; let mut file = open_document(&args.path)?; - commit_mutation(&mut file, transaction, args.dry_run, json_output, stdout) + commit_mutation(&mut file, transaction, args.dry_run, None, json_output, stdout) } /// Reads one transaction from a JSON file or standard input for file and live modes. diff --git a/crates/inkfinite-cli/src/cli/args.rs b/crates/inkfinite-cli/src/cli/args.rs index d969c96..3d857dd 100644 --- a/crates/inkfinite-cli/src/cli/args.rs +++ b/crates/inkfinite-cli/src/cli/args.rs @@ -53,7 +53,7 @@ pub enum Command { inkfinite inspect architecture.inkfinite inkfinite inspect architecture.inkfinite --json ")] - Inspect(FileOutputArgs), + Inspect(InspectArgs), /// Find records using semantic, hierarchy, kind, and bounds filters. #[command(after_help = "Examples: @@ -135,6 +135,17 @@ pub struct FileOutputArgs { pub path: PathBuf, } +#[derive(Debug, Args)] +pub struct InspectArgs { + /// Canonical .inkfinite document to read. + #[arg(value_name = "FILE")] + pub path: PathBuf, + + /// Return identity, heads, initial record IDs, and counts instead of the complete snapshot. + #[arg(long)] + pub summary: bool, +} + #[derive(Debug, Args)] pub struct ApplyArgs { /// Canonical .inkfinite document to change. @@ -170,6 +181,17 @@ pub enum ShapeCommand { inkfinite shape delete architecture.inkfinite --role architecture.deprecated --expected-version 3 ")] Delete(ShapeDeleteArgs), + /// List built-in shape kinds and their common property contract. + Kinds, + /// Describe one built-in shape kind. + Describe(ShapeDescribeArgs), +} + +#[derive(Debug, Args)] +pub struct ShapeDescribeArgs { + /// Built-in registry key to describe. + #[arg(value_name = "KIND")] + pub kind: String, } #[derive(Debug, Args)] @@ -181,17 +203,29 @@ pub struct MutationOptions { /// Stable transaction ID. A command-specific ID is used when omitted. #[arg(long, value_name = "ID")] pub transaction_id: Option, + + /// Write the validated transaction to a new JSON file without changing the document. + #[arg(long, value_name = "FILE")] + pub transaction_out: Option, + + /// Propose this structured edit to the running desktop app instead of changing a file. + #[arg(long)] + pub app: bool, + + /// Target this desktop session when --app is used. + #[arg(long, value_name = "SESSION_ID", requires = "app")] + pub session_id: Option, } #[derive(Debug, Args)] #[command(group(ArgGroup::new("parent").required(true).args(["layer", "parent_shape"])))] pub struct ShapeCreateArgs { - /// Canonical .inkfinite document to change. + /// Canonical document to change. Omit when using --app. #[arg(value_name = "FILE")] - pub path: PathBuf, - /// Stable ID for the new shape. + pub path: Option, + /// Stable ID for the new shape. A deterministic available ID is generated when omitted. #[arg(long, value_name = "ID")] - pub shape_id: String, + pub shape_id: Option, /// Built-in shape registry key. #[arg(long, value_name = "KIND")] pub kind: String, @@ -235,9 +269,9 @@ pub struct ShapeCreateArgs { #[derive(Debug, Args)] #[command(group(ArgGroup::new("selector").required(true).multiple(false).args(["shape_id", "name", "role"])))] pub struct ShapePatchArgs { - /// Canonical .inkfinite document to change. + /// Canonical document to change. Omit when using --app. #[arg(value_name = "FILE")] - pub path: PathBuf, + pub path: Option, /// Select an exact shape ID. #[arg(long = "shape-id", value_name = "ID")] pub shape_id: Option, @@ -260,9 +294,9 @@ pub struct ShapePatchArgs { #[derive(Debug, Args)] #[command(group(ArgGroup::new("selector").required(true).multiple(false).args(["shape_id", "name", "role"])))] pub struct ShapeDeleteArgs { - /// Canonical .inkfinite document to change. + /// Canonical document to change. Omit when using --app. #[arg(value_name = "FILE")] - pub path: PathBuf, + pub path: Option, /// Select an exact shape ID. #[arg(long = "shape-id", value_name = "ID")] pub shape_id: Option, @@ -281,12 +315,12 @@ pub struct ShapeDeleteArgs { #[derive(Debug, Args)] pub struct ConnectArgs { - /// Canonical .inkfinite document to change. + /// Canonical document to change. Omit when using --app. #[arg(value_name = "FILE")] - pub path: PathBuf, - /// Stable ID for the new binding. + pub path: Option, + /// Stable ID for the new binding. A deterministic available ID is generated when omitted. #[arg(long, value_name = "ID")] - pub binding_id: String, + pub binding_id: Option, /// Exact source shape ID. #[arg(long, value_name = "SHAPE_ID", required_unless_present = "source_role")] pub source: Option, @@ -335,9 +369,9 @@ pub struct LayoutSelectionArgs { #[derive(Debug, Args)] pub struct LayoutAlignArgs { - /// Canonical .inkfinite document to change. + /// Canonical document to change. Omit when using --app. #[arg(value_name = "FILE")] - pub path: PathBuf, + pub path: Option, /// Alignment line. #[arg(long, value_enum)] pub alignment: AlignmentArg, @@ -349,9 +383,9 @@ pub struct LayoutAlignArgs { #[derive(Debug, Args)] pub struct LayoutDistributeArgs { - /// Canonical .inkfinite document to change. + /// Canonical document to change. Omit when using --app. #[arg(value_name = "FILE")] - pub path: PathBuf, + pub path: Option, /// Distribution axis. #[arg(long, value_enum)] pub axis: AxisArg, @@ -435,6 +469,12 @@ pub struct QueryArgs { /// Restrict shapes to bounds formatted as x,y,width,height. #[arg(long, value_name = "X,Y,WIDTH,HEIGHT", value_parser = parse_bounds)] pub bounds: Option, + /// Include complete matching records, including versions and semantic metadata. + #[arg(long)] + pub detail: bool, + /// Return at most this many matches after deterministic sorting. + #[arg(long, value_parser = clap::value_parser!(u32).range(1..))] + pub limit: Option, } #[derive(Debug, Subcommand)] @@ -446,6 +486,8 @@ pub enum AppCommand { inkfinite app status --json ")] Status, + /// Return the active page, selection, viewport, actor, and current heads. + Context(AppInspectArgs), /// Print the current snapshot from the desktop app. #[command(after_help = "Examples: @@ -467,19 +509,9 @@ pub enum AppCommand { 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), + /// Observe a proposal without accepting or rejecting it. + #[command(subcommand)] + Proposal(AppProposalCommand), /// Apply an agent transaction using a one-time authorization issued by the desktop UI. #[command(after_help = "Example: @@ -494,6 +526,37 @@ pub enum AppCommand { Focus, } +#[derive(Debug, Subcommand)] +pub enum AppProposalCommand { + /// Return the current review state once. + Status(AppProposalStatusArgs), + /// Wait until desktop review accepts, rejects, or expires the proposal. + Wait(AppProposalWaitArgs), +} + +#[derive(Debug, Args)] +pub struct AppProposalStatusArgs { + /// Proposal whose review state should be read. + #[arg(long, value_name = "PROPOSAL_ID")] + pub proposal_id: String, + /// Read from this session, or the only open session when omitted. + #[arg(long, value_name = "SESSION_ID")] + pub session_id: Option, +} + +#[derive(Debug, Args)] +pub struct AppProposalWaitArgs { + /// Proposal whose final review state should be awaited. + #[arg(long, value_name = "PROPOSAL_ID")] + pub proposal_id: String, + /// Wait in this session, or the only open session when omitted. + #[arg(long, value_name = "SESSION_ID")] + pub session_id: Option, + /// Maximum number of seconds to wait. + #[arg(long, default_value_t = 300, value_parser = clap::value_parser!(u64).range(1..))] + pub timeout_seconds: u64, +} + #[derive(Debug, Args)] pub struct AppInspectArgs { /// Inspect this session, or the only open session when omitted. @@ -533,6 +596,12 @@ pub struct AppQueryArgs { /// Restrict shapes to bounds formatted as x,y,width,height. #[arg(long, value_name = "X,Y,WIDTH,HEIGHT", value_parser = parse_bounds)] pub bounds: Option, + /// Include complete matching records, including versions and semantic metadata. + #[arg(long)] + pub detail: bool, + /// Return at most this many matches after deterministic sorting. + #[arg(long, value_parser = clap::value_parser!(u32).range(1..))] + pub limit: Option, } #[derive(Debug, Args)] @@ -545,29 +614,6 @@ pub struct AppProposeArgs { 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. diff --git a/crates/inkfinite-cli/src/cli/connect.rs b/crates/inkfinite-cli/src/cli/connect.rs index de54909..6f8d818 100644 --- a/crates/inkfinite-cli/src/cli/connect.rs +++ b/crates/inkfinite-cli/src/cli/connect.rs @@ -1,14 +1,24 @@ -use super::mutation::{commit_mutation, select_unique_shape, structured_transaction}; -use super::support::open_document; +use super::mutation::{StructuredMutationTarget, select_unique_shape}; use super::{ BindingAnchor, BindingId, BindingKind, BindingRecord, CliError, ConnectArgs, EXIT_INVALID, Operation, RecordVersion, Write, anyhow, }; pub fn connect_shapes(args: ConnectArgs, json_output: bool, stdout: &mut dyn Write) -> Result<(), CliError> { - let mut file = open_document(&args.path)?; - let source = select_unique_shape(&mut file, args.source.as_deref(), None, args.source_role.as_deref())?; - let target = select_unique_shape(&mut file, args.target.as_deref(), None, args.target_role.as_deref())?; + let mut target = StructuredMutationTarget::open(args.path.as_deref(), &args.mutation)?; + let snapshot = target.snapshot()?; + let source = select_unique_shape( + &snapshot.document, + args.source.as_deref(), + None, + args.source_role.as_deref(), + )?; + let target_shape = select_unique_shape( + &snapshot.document, + args.target.as_deref(), + None, + args.target_role.as_deref(), + )?; let anchor = match (args.anchor_x, args.anchor_y) { (None, None) => BindingAnchor::Center, (Some(x), Some(y)) if x.is_finite() && y.is_finite() => BindingAnchor::Edge { x, y }, @@ -25,22 +35,33 @@ pub fn connect_shapes(args: ConnectArgs, json_output: bool, stdout: &mut dyn Wri )); } }; - let binding_id = BindingId::from(args.binding_id); + let binding_id = match args.binding_id { + Some(binding_id) => BindingId::from(binding_id), + None => { + let mut suffix = 1_u64; + loop { + let candidate = BindingId::from(format!("binding:{suffix}")); + if !snapshot.document.bindings.contains_key(&candidate) { + break candidate; + } + suffix = suffix.saturating_add(1); + } + } + }; let binding = BindingRecord { id: binding_id.clone(), kind: BindingKind::from(args.kind), source_shape_id: source, - target_shape_id: target, + target_shape_id: target_shape, source_handle: args.source_handle, anchor, version: RecordVersion(1), }; - let transaction = structured_transaction( - &mut file, - args.mutation.transaction_id, + let transaction = target.transaction( + args.mutation.transaction_id.clone(), format!("connect:{}", binding_id.as_str()), format!("create binding {binding_id}"), vec![Operation::CreateBinding { binding }], )?; - commit_mutation(&mut file, transaction, args.mutation.dry_run, json_output, stdout) + target.finish(transaction, &args.mutation, json_output, stdout) } diff --git a/crates/inkfinite-cli/src/cli/contract.rs b/crates/inkfinite-cli/src/cli/contract.rs index 9af0b33..99da81a 100644 --- a/crates/inkfinite-cli/src/cli/contract.rs +++ b/crates/inkfinite-cli/src/cli/contract.rs @@ -48,22 +48,29 @@ pub fn print_capabilities(json_output: bool, stdout: &mut dyn Write) -> Result<( "format": { "id": INKFINITE_FORMAT_ID, "version": INKFINITE_FORMAT_VERSION }, "global_options": ["--json", "--non-interactive"], "json_stdout_is_machine_only": true, + "json_errors": { + "fields": ["code", "message", "details", "retryable", "suggestion"], + "stderr_only": true + }, "live_mode": { - "commands": ["status", "inspect", "query", "focus", "propose", "accept", "reject", "apply"], + "commands": ["status", "context", "inspect", "query", "focus", "propose", "proposal status", "proposal wait", "apply"], "proposal_review": true, + "proposal_decisions": "desktop_ui_only", "direct_apply_requires_one_time_authorization": true, "transport": "authenticated_local_socket", "tcp_or_http": false }, "mutation_commands": { "apply": ["--transaction", "--dry-run"], - "connect": ["--binding-id", "--source", "--source-role", "--target", "--target-role", "--dry-run"], + "connect": ["--binding-id", "--source", "--source-role", "--target", "--target-role", "--dry-run", "--transaction-out", "--app"], "layout": ["align", "distribute"], - "shape": ["create", "patch", "delete"] + "shape": ["create", "patch", "delete", "kinds", "describe"], + "structured_targets": ["file", "app_proposal"] }, "path_format": "forward_slashes", "protocol": { "id": PROTOCOL_ID, "version": PROTOCOL_VERSION }, "query_filters": ["id", "name", "role", "tag", "kind", "page", "layer", "parent", "bounds"], + "query_options": ["detail", "limit"], "render_filters": ["page", "layer", "shape", "role", "region"], "schemas": ["document", "transaction", "protocol", "protocol-request", "protocol-response", "protocol-error"], "shape_kinds": builtin_shape_kinds() @@ -85,7 +92,7 @@ pub fn print_capabilities(json_output: bool, stdout: &mut dyn Write) -> Result<( .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" + "Live mode: app status, app context, app inspect, app query, app focus, app propose, app proposal status/wait, app apply" ) .map_err(map_output_error)?; writeln!(stdout, "Global options: --json, --non-interactive").map_err(map_output_error)?; diff --git a/crates/inkfinite-cli/src/cli/document.rs b/crates/inkfinite-cli/src/cli/document.rs index e281a29..5b66376 100644 --- a/crates/inkfinite-cli/src/cli/document.rs +++ b/crates/inkfinite-cli/src/cli/document.rs @@ -2,8 +2,8 @@ use super::support::{ default_document_id, map_file_error, map_output_error, open_document, portable_path, write_heads, write_json, }; use super::{ - ACTOR_ID, ActorId, CliError, DocumentFile, DocumentId, EXIT_INVALID, FileOutputArgs, LayerId, NewArgs, PageId, - Query, QueryArgs, RecordId, Result, Write, anyhow, blank_document, json, validate_document, + ACTOR_ID, ActorId, CliError, DocumentFile, DocumentId, EXIT_INVALID, FileOutputArgs, InspectArgs, LayerId, NewArgs, + PageId, Query, QueryArgs, RecordId, Result, Write, anyhow, blank_document, json, validate_document, }; pub fn create_document(args: NewArgs, json_output: bool, stdout: &mut dyn Write) -> Result<()> { @@ -25,6 +25,8 @@ pub fn create_document(args: NewArgs, json_output: bool, stdout: &mut dyn Write) "document_id": document_id, "heads": snapshot.heads, "path": portable_path(&args.path), + "page_id": snapshot.document.page_ids.first(), + "layer_id": snapshot.document.page_ids.first().and_then(|page_id| snapshot.document.pages.get(page_id)).and_then(|page| page.layer_ids.first()), }), ) } else { @@ -33,9 +35,28 @@ pub fn create_document(args: NewArgs, json_output: bool, stdout: &mut dyn Write) } } -pub fn inspect_document(args: &FileOutputArgs, json_output: bool, stdout: &mut dyn Write) -> Result<()> { +pub fn inspect_document(args: &InspectArgs, json_output: bool, stdout: &mut dyn Write) -> Result<()> { let mut file = open_document(&args.path)?; let snapshot = file.snapshot().map_err(map_file_error)?; + if json_output && args.summary { + return write_json( + stdout, + &json!({ + "document_id": snapshot.document_id, + "format": snapshot.format, + "format_version": snapshot.format_version, + "heads": snapshot.heads, + "page_ids": snapshot.document.page_ids, + "counts": { + "pages": snapshot.document.pages.len(), + "layers": snapshot.document.layers.len(), + "shapes": snapshot.document.shapes.len(), + "bindings": snapshot.document.bindings.len(), + "assets": snapshot.document.assets.len(), + }, + }), + ); + } if json_output { return write_json(stdout, &snapshot); } @@ -62,6 +83,8 @@ pub fn query_document(args: QueryArgs, json_output: bool, stdout: &mut dyn Write layer_id: args.layer.map(LayerId::from), parent_id: args.parent, bounds: args.bounds, + include_records: args.detail, + limit: args.limit, }; let result = file .engine_mut() @@ -72,7 +95,10 @@ pub fn query_document(args: QueryArgs, json_output: bool, stdout: &mut dyn Write } write_heads(stdout, &result.heads)?; - writeln!(stdout, "Matches: {}", result.records.len()).map_err(map_output_error)?; + writeln!(stdout, "Matches: {} of {}", result.records.len(), result.total).map_err(map_output_error)?; + if result.truncated { + writeln!(stdout, "Truncated: true").map_err(map_output_error)?; + } for record in &result.records { match record { RecordId::Page(id) => writeln!(stdout, "page\t{id}"), diff --git a/crates/inkfinite-cli/src/cli/layout.rs b/crates/inkfinite-cli/src/cli/layout.rs index 8907331..7e81972 100644 --- a/crates/inkfinite-cli/src/cli/layout.rs +++ b/crates/inkfinite-cli/src/cli/layout.rs @@ -1,12 +1,12 @@ -use super::mutation::{commit_mutation, select_layout_shapes, structured_transaction}; -use super::support::open_document; +use super::mutation::{StructuredMutationTarget, select_layout_shapes}; use super::{AlignmentArg, AxisArg, BTreeMap, CliError, LayoutAxis, LayoutCommand, Operation, ShapeAlignment, Write}; pub fn run_layout_command(command: LayoutCommand, json_output: bool, stdout: &mut dyn Write) -> Result<(), CliError> { match command { LayoutCommand::Align(args) => { - let mut file = open_document(&args.path)?; - let shape_ids = select_layout_shapes(&mut file, args.selection)?; + let mut target = StructuredMutationTarget::open(args.path.as_deref(), &args.mutation)?; + let snapshot = target.snapshot()?; + let shape_ids = select_layout_shapes(&snapshot.document, args.selection)?; let alignment = match args.alignment { AlignmentArg::Left => ShapeAlignment::Left, AlignmentArg::Center => ShapeAlignment::Center, @@ -15,30 +15,29 @@ pub fn run_layout_command(command: LayoutCommand, json_output: bool, stdout: &mu AlignmentArg::Middle => ShapeAlignment::Middle, AlignmentArg::Bottom => ShapeAlignment::Bottom, }; - let transaction = structured_transaction( - &mut file, - args.mutation.transaction_id, + let transaction = target.transaction( + args.mutation.transaction_id.clone(), "layout:align".into(), "align shapes".into(), vec![Operation::AlignShapes { shape_ids, alignment, expected_versions: BTreeMap::new() }], )?; - commit_mutation(&mut file, transaction, args.mutation.dry_run, json_output, stdout) + target.finish(transaction, &args.mutation, json_output, stdout) } LayoutCommand::Distribute(args) => { - let mut file = open_document(&args.path)?; - let shape_ids = select_layout_shapes(&mut file, args.selection)?; + let mut target = StructuredMutationTarget::open(args.path.as_deref(), &args.mutation)?; + let snapshot = target.snapshot()?; + let shape_ids = select_layout_shapes(&snapshot.document, args.selection)?; let axis = match args.axis { AxisArg::Horizontal => LayoutAxis::Horizontal, AxisArg::Vertical => LayoutAxis::Vertical, }; - let transaction = structured_transaction( - &mut file, - args.mutation.transaction_id, + let transaction = target.transaction( + args.mutation.transaction_id.clone(), "layout:distribute".into(), "distribute shapes".into(), vec![Operation::DistributeShapes { shape_ids, axis, expected_versions: BTreeMap::new() }], )?; - commit_mutation(&mut file, transaction, args.mutation.dry_run, json_output, stdout) + target.finish(transaction, &args.mutation, json_output, stdout) } } } diff --git a/crates/inkfinite-cli/src/cli/mod.rs b/crates/inkfinite-cli/src/cli/mod.rs index 0c1b691..a637755 100644 --- a/crates/inkfinite-cli/src/cli/mod.rs +++ b/crates/inkfinite-cli/src/cli/mod.rs @@ -15,10 +15,10 @@ use inkfinite_core::proto::{ }; use inkfinite_core::render::{SvgRenderOptions, render_svg}; use inkfinite_core::{ - ActorId, BindingAnchor, BindingId, BindingKind, BindingRecord, DocumentId, INKFINITE_FORMAT_ID, - INKFINITE_FORMAT_VERSION, LayerId, Opacity, Origin, PageId, Provenance, RecordVersion, SemanticMetadata, ShapeId, - ShapeKind, ShapeParent, ShapeRecord, ShapeStyle, SiblingAnchor, Timestamp, Transform, Vec2, blank_document, - builtin_shape_kinds, + ActorId, BindingAnchor, BindingId, BindingKind, BindingRecord, BuiltinShapeKind, DocumentId, DocumentSnapshot, + INKFINITE_FORMAT_ID, INKFINITE_FORMAT_VERSION, LayerId, Opacity, Origin, PageId, Provenance, RecordVersion, + SemanticMetadata, ShapeId, ShapeKind, ShapeParent, ShapeRecord, ShapeStyle, SiblingAnchor, Timestamp, Transform, + Vec2, blank_document, builtin_shape_kinds, }; use serde::Serialize; use serde_json::{Value, json}; @@ -39,21 +39,67 @@ pub type Result = std::result::Result; #[derive(Debug)] pub struct CliError { exit_code: i32, + code: String, + details: Option, + retryable: bool, + suggestion: Option, pub source: Error, } impl CliError { fn new(exit_code: i32, source: impl Into) -> Self { - Self { exit_code, source: source.into() } + Self { + exit_code, + code: match exit_code { + EXIT_INPUT => "input_error", + EXIT_INVALID => "invalid_data", + EXIT_CONFLICT => "state_conflict", + _ => "command_failed", + } + .into(), + details: None, + retryable: false, + suggestion: None, + source: source.into(), + } } fn context(self, message: impl std::fmt::Display + Send + Sync + 'static) -> Self { - Self { exit_code: self.exit_code, source: self.source.context(message) } + Self { source: self.source.context(message), ..self } + } + + fn with_code(mut self, code: impl Into) -> Self { + self.code = code.into(); + self + } + + fn with_details(mut self, details: Value) -> Self { + self.details = Some(details); + self + } + + fn retryable(mut self, suggestion: impl Into) -> Self { + self.retryable = true; + self.suggestion = Some(suggestion.into()); + self } pub fn exit_code(&self) -> i32 { self.exit_code } + + /// Returns a stable JSON diagnostic for automation. + pub fn diagnostic(&self) -> Value { + json!({ + "error": { + "code": self.code, + "message": format!("{:#}", self.source), + "details": self.details, + "retryable": self.retryable, + "suggestion": self.suggestion, + } + }) + } } mod app; @@ -69,8 +115,9 @@ mod shape; mod support; use args::{ - AlignmentArg, ApplyArgs, AxisArg, ConnectArgs, FileOutputArgs, LayoutCommand, LayoutSelectionArgs, NewArgs, - QueryArgs, RenderArgs, SchemaKind, ShapeCommand, ShapeCreateArgs, ShapeDeleteArgs, ShapePatchArgs, + AlignmentArg, ApplyArgs, AxisArg, ConnectArgs, FileOutputArgs, InspectArgs, LayoutCommand, LayoutSelectionArgs, + MutationOptions, NewArgs, QueryArgs, RenderArgs, SchemaKind, ShapeCommand, ShapeCreateArgs, ShapeDeleteArgs, + ShapeDescribeArgs, ShapePatchArgs, }; use support::parse_bounds; diff --git a/crates/inkfinite-cli/src/cli/mutation.rs b/crates/inkfinite-cli/src/cli/mutation.rs index 18d686d..04b8bfc 100644 --- a/crates/inkfinite-cli/src/cli/mutation.rs +++ b/crates/inkfinite-cli/src/cli/mutation.rs @@ -1,8 +1,103 @@ use super::support::{map_file_error, map_output_error, portable_path, write_heads, write_json}; use super::{ - ACTOR_ID, ActorId, BTreeSet, CliError, DocumentFile, EXIT_INPUT, EXIT_INVALID, LayoutSelectionArgs, Operation, - Origin, Path, RecordId, Result, Serialize, ShapeId, Timestamp, TransactionDraft, TransactionId, Write, anyhow, fs, + BTreeSet, CliError, DocumentFile, DocumentSnapshot, EXIT_INPUT, EXIT_INVALID, LayoutSelectionArgs, MutationOptions, + Operation, Origin, Path, RecordId, Result, Serialize, ShapeId, Timestamp, TransactionDraft, TransactionId, Write, + anyhow, fs, io, }; +use inkfinite_core::Document; +use inkfinite_core::proto::SessionId; +use inkfinite_core::session::SessionStatus; + +/// Closed-file or live-session state used to build one structured transaction. +pub enum StructuredMutationTarget { + File(Box), + App { + session_id: SessionId, + status: Box, + }, +} + +impl StructuredMutationTarget { + /// Resolves exactly one file or desktop session target. + pub fn open(path: Option<&Path>, options: &MutationOptions) -> Result { + if options.app { + if path.is_some() { + return Err(CliError::new(EXIT_INVALID, anyhow!("omit FILE when using --app")) + .with_code("ambiguous_mutation_target")); + } + if options.dry_run || options.transaction_out.is_some() { + return Err(CliError::new( + EXIT_INVALID, + anyhow!("--app cannot be combined with --dry-run or --transaction-out"), + ) + .with_code("invalid_mutation_target_options")); + } + let status = super::app::resolve_session_status(options.session_id.clone())?; + return Ok(Self::App { session_id: status.session_id.clone(), status: Box::new(status) }); + } + let path = path.ok_or_else(|| { + CliError::new(EXIT_INVALID, anyhow!("FILE is required unless --app is used")) + .with_code("mutation_target_required") + })?; + Ok(Self::File(Box::new(super::support::open_document(path)?))) + } + + /// Returns the document state used for selectors and generated IDs. + pub fn snapshot(&mut self) -> Result { + match self { + Self::File(file) => file.snapshot().map_err(map_file_error), + Self::App { status, .. } => Ok(status.snapshot.clone()), + } + } + + /// Builds an agent transaction against the target's current heads and actor. + pub fn transaction( + &mut self, transaction_id: Option, mut default_id: String, description: String, ops: Vec, + ) -> Result { + let (actor_id, base_heads) = match self { + Self::File(file) => (file.actor_id().clone(), file.heads().map_err(map_file_error)?), + Self::App { status, .. } => (status.actor_id.clone(), status.snapshot.heads.clone()), + }; + let transaction_id = transaction_id.unwrap_or_else(|| { + let head_suffix = base_heads + .iter() + .map(inkfinite_core::ChangeHash::as_str) + .collect::>() + .join("."); + default_id.push(':'); + default_id.push_str(&head_suffix); + default_id + }); + Ok(TransactionDraft { + id: TransactionId(transaction_id), + actor_id, + origin: Origin::Agent, + base_heads, + description, + operations: ops, + timestamp: Timestamp(0), + }) + } + + /// Applies a file mutation or submits a live proposal. + pub fn finish( + self, transaction: TransactionDraft, options: &MutationOptions, json_output: bool, stdout: &mut dyn Write, + ) -> Result<()> { + match self { + Self::File(mut file) => commit_mutation( + &mut file, + transaction, + options.dry_run, + options.transaction_out.as_deref(), + json_output, + stdout, + ), + Self::App { session_id, .. } => { + super::app::propose_transaction(transaction, Some(session_id), json_output, stdout) + } + } + } +} #[derive(Serialize)] struct MutationResult { @@ -15,42 +110,61 @@ struct MutationResult { repairs: Vec, warnings: Vec, dry_run: bool, -} - -pub fn structured_transaction( - file: &mut DocumentFile, transaction_id: Option, mut default_id: String, description: String, - ops: Vec, -) -> Result { - let base_heads = file.heads().map_err(map_file_error)?; - let transaction_id = transaction_id.unwrap_or_else(|| { - let head_suffix = base_heads - .iter() - .map(inkfinite_core::ChangeHash::as_str) - .collect::>() - .join("."); - default_id.push(':'); - default_id.push_str(&head_suffix); - default_id - }); - Ok(TransactionDraft { - id: TransactionId(transaction_id), - actor_id: ActorId::from(ACTOR_ID), - origin: Origin::Agent, - base_heads, - description, - operations: ops, - timestamp: Timestamp(0), - }) + transaction_output: Option, } pub fn commit_mutation( - file: &mut DocumentFile, transaction: TransactionDraft, dry_run: bool, json_output: bool, stdout: &mut dyn Write, + file: &mut DocumentFile, transaction: TransactionDraft, dry_run: bool, transaction_output: Option<&Path>, + json_output: bool, stdout: &mut dyn Write, ) -> Result<()> { + let transaction_json = transaction_output + .map(|_| serde_json::to_vec_pretty(&transaction)) + .transpose() + .map_err(|error| CliError::new(EXIT_INVALID, error).with_code("transaction_serialization_failed"))?; let previous_heads = file.heads().map_err(map_file_error)?; - let commit = file.commit(transaction).map_err(map_file_error)?; - if !dry_run { + let commit = file.commit(transaction).map_err(|error| { + let include_heads = matches!( + &error, + inkfinite_core::file::FileError::Engine( + inkfinite_core::engine::EngineError::StaleHeads | inkfinite_core::engine::EngineError::Precondition(_) + ) + ); + let diagnostic = map_file_error(error); + if include_heads { + diagnostic.with_details(serde_json::json!({ "current_heads": previous_heads })) + } else { + diagnostic + } + })?; + let effective_dry_run = dry_run || transaction_output.is_some(); + if !effective_dry_run { file.save().map_err(map_file_error)?; } + if let (Some(path), Some(mut bytes)) = (transaction_output, transaction_json) { + bytes.push(b'\n'); + let mut output = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|error| { + let code = if error.kind() == io::ErrorKind::AlreadyExists { + "transaction_output_exists" + } else { + "transaction_output_error" + }; + CliError::new( + if error.kind() == io::ErrorKind::AlreadyExists { super::EXIT_CONFLICT } else { EXIT_INPUT }, + error, + ) + .with_code(code) + .context(format!("could not create transaction output {}", portable_path(path))) + })?; + output.write_all(&bytes).map_err(|error| { + CliError::new(EXIT_INPUT, error) + .with_code("transaction_output_error") + .context(format!("could not write transaction output {}", portable_path(path))) + })?; + } let result = MutationResult { previous_heads, current_heads: commit.heads, @@ -60,13 +174,17 @@ pub fn commit_mutation( deleted: commit.patch.deleted, repairs: commit.warnings, warnings: Vec::new(), - dry_run, + dry_run: effective_dry_run, + transaction_output: transaction_output.map(portable_path), }; if json_output { write_json(stdout, &result) } else { writeln!(stdout, "Transaction: {}", result.transaction_id.0).map_err(map_output_error)?; writeln!(stdout, "Dry run: {}", result.dry_run).map_err(map_output_error)?; + if let Some(path) = &result.transaction_output { + writeln!(stdout, "Transaction output: {path}").map_err(map_output_error)?; + } writeln!(stdout, "Created: {}", result.created.len()).map_err(map_output_error)?; writeln!(stdout, "Updated: {}", result.updated.len()).map_err(map_output_error)?; writeln!(stdout, "Deleted: {}", result.deleted.len()).map_err(map_output_error)?; @@ -75,11 +193,9 @@ pub fn commit_mutation( } pub fn select_unique_shape( - file: &mut DocumentFile, shape_id: Option<&str>, name: Option<&str>, role: Option<&str>, + document: &Document, shape_id: Option<&str>, name: Option<&str>, role: Option<&str>, ) -> Result { - let snapshot = file.snapshot().map_err(map_file_error)?; - let matches: Vec = snapshot - .document + let matches: Vec = document .shapes .values() .filter(|shape| { @@ -102,13 +218,11 @@ pub fn select_unique_shape( } } -pub fn select_layout_shapes(file: &mut DocumentFile, selection: LayoutSelectionArgs) -> Result> { - let snapshot = file.snapshot().map_err(map_file_error)?; +pub fn select_layout_shapes(document: &Document, selection: LayoutSelectionArgs) -> Result> { let mut selected: BTreeSet = selection.shape_ids.into_iter().map(ShapeId::from).collect(); if let Some(role) = selection.role { selected.extend( - snapshot - .document + document .shapes .values() .filter(|shape| shape.metadata.role.as_deref() == Some(role.as_str())) @@ -122,7 +236,7 @@ pub fn select_layout_shapes(file: &mut DocumentFile, selection: LayoutSelectionA )); } for shape_id in &selected { - if !snapshot.document.shapes.contains_key(shape_id) { + if !document.shapes.contains_key(shape_id) { return Err(CliError::new(EXIT_INVALID, anyhow!("shape {shape_id} does not exist"))); } } diff --git a/crates/inkfinite-cli/src/cli/shape.rs b/crates/inkfinite-cli/src/cli/shape.rs index 197d13c..7f416f9 100644 --- a/crates/inkfinite-cli/src/cli/shape.rs +++ b/crates/inkfinite-cli/src/cli/shape.rs @@ -1,17 +1,94 @@ -use super::mutation::{commit_mutation, read_json_argument, select_unique_shape, structured_transaction}; -use super::support::open_document; +use super::mutation::{StructuredMutationTarget, read_json_argument, select_unique_shape}; use super::{ - ACTOR_ID, ActorId, BTreeMap, CliError, EXIT_INVALID, LayerId, Opacity, Operation, Origin, Provenance, - RecordVersion, Result, SemanticMetadata, ShapeCommand, ShapeCreateArgs, ShapeDeleteArgs, ShapeId, ShapeKind, - ShapeParent, ShapePatch, ShapePatchArgs, ShapeRecord, ShapeStyle, SiblingAnchor, Timestamp, Transform, Value, Vec2, - Write, anyhow, builtin_shape_kinds, + BTreeMap, BuiltinShapeKind, CliError, EXIT_INVALID, LayerId, Opacity, Operation, Origin, Provenance, RecordVersion, + Result, SemanticMetadata, Serialize, ShapeCommand, ShapeCreateArgs, ShapeDeleteArgs, ShapeDescribeArgs, ShapeId, + ShapeKind, ShapeParent, ShapePatch, ShapePatchArgs, ShapeRecord, ShapeStyle, SiblingAnchor, Timestamp, Transform, + Value, Vec2, Write, anyhow, builtin_shape_kinds, }; +#[derive(Serialize)] +struct ShapeKindDescription { + kind: &'static str, + allows_children: bool, + common_properties: [ShapePropertyDescription; 2], +} + +#[derive(Clone, Copy, Serialize)] +struct ShapePropertyDescription { + name: &'static str, + value_type: &'static str, + required: bool, + finite: bool, + minimum: f64, + default: f64, +} + pub fn run_shape_command(command: ShapeCommand, json_output: bool, stdout: &mut dyn Write) -> Result<()> { match command { ShapeCommand::Create(args) => create_shape(args, json_output, stdout), - ShapeCommand::Patch(args) => patch_shape(args, json_output, stdout), - ShapeCommand::Delete(args) => delete_shape(args, json_output, stdout), + ShapeCommand::Patch(args) => patch_shape(&args, json_output, stdout), + ShapeCommand::Delete(args) => delete_shape(&args, json_output, stdout), + ShapeCommand::Kinds => list_shape_kinds(json_output, stdout), + ShapeCommand::Describe(args) => describe_shape_kind(&args, json_output, stdout), + } +} + +fn list_shape_kinds(json_output: bool, stdout: &mut dyn Write) -> Result<()> { + let descriptions: Vec = + BuiltinShapeKind::ALL.into_iter().map(shape_kind_description).collect(); + if json_output { + return super::support::write_json(stdout, &descriptions); + } + for description in descriptions { + writeln!( + stdout, + "{}\tchildren={}\tproperties=width,height", + description.kind, description.allows_children + ) + .map_err(super::support::map_output_error)?; + } + Ok(()) +} + +fn describe_shape_kind(args: &ShapeDescribeArgs, json_output: bool, stdout: &mut dyn Write) -> Result<()> { + let kind = BuiltinShapeKind::parse(&args.kind).ok_or_else(|| { + CliError::new( + EXIT_INVALID, + anyhow!( + "unknown shape kind {}; expected one of {}", + args.kind, + builtin_shape_kinds().join(", ") + ), + ) + })?; + let description = shape_kind_description(kind); + if json_output { + super::support::write_json(stdout, &description) + } else { + writeln!(stdout, "Kind: {}", description.kind).map_err(super::support::map_output_error)?; + writeln!(stdout, "Allows children: {}", description.allows_children) + .map_err(super::support::map_output_error)?; + writeln!( + stdout, + "Properties: width, height (finite numbers, minimum 0, default 0)" + ) + .map_err(super::support::map_output_error) + } +} + +fn shape_kind_description(kind: BuiltinShapeKind) -> ShapeKindDescription { + const DIMENSION: ShapePropertyDescription = ShapePropertyDescription { + name: "width", + value_type: "number", + required: false, + finite: true, + minimum: 0.0, + default: 0.0, + }; + ShapeKindDescription { + kind: kind.as_str(), + allows_children: kind == BuiltinShapeKind::Container, + common_properties: [DIMENSION, ShapePropertyDescription { name: "height", ..DIMENSION }], } } @@ -39,7 +116,25 @@ fn create_shape(args: ShapeCreateArgs, json_output: bool, stdout: &mut dyn Write (None, Some(shape)) => ShapeParent::Shape(ShapeId::from(shape)), _ => return Err(CliError::new(EXIT_INVALID, anyhow!("select exactly one shape parent"))), }; - let shape_id = ShapeId::from(args.shape_id); + let mut target = StructuredMutationTarget::open(args.path.as_deref(), &args.mutation)?; + let provenance_actor = match &target { + StructuredMutationTarget::File(file) => file.actor_id().clone(), + StructuredMutationTarget::App { status, .. } => status.actor_id.clone(), + }; + let shape_id = match args.shape_id { + Some(shape_id) => ShapeId::from(shape_id), + None => { + let snapshot = target.snapshot()?; + let mut suffix = 1_u64; + loop { + let candidate = ShapeId::from(format!("shape:{}:{suffix}", args.kind)); + if !snapshot.document.shapes.contains_key(&candidate) { + break candidate; + } + suffix = suffix.saturating_add(1); + } + } + }; let shape = ShapeRecord { id: shape_id.clone(), kind: ShapeKind::from(args.kind), @@ -61,7 +156,7 @@ fn create_shape(args: ShapeCreateArgs, json_output: bool, stdout: &mut dyn Write locked: args.locked, agent_editable: args.agent_editable, provenance: Provenance { - actor_id: ActorId::from(ACTOR_ID), + actor_id: provenance_actor, origin: Origin::Agent, timestamp: Timestamp(0), source: None, @@ -70,52 +165,50 @@ fn create_shape(args: ShapeCreateArgs, json_output: bool, stdout: &mut dyn Write style: ShapeStyle { opacity: Opacity::OPAQUE, fill_opacity: None, stroke_opacity: None }, version: RecordVersion(1), }; - let mut file = open_document(&args.path)?; - let transaction = structured_transaction( - &mut file, - args.mutation.transaction_id, + let transaction = target.transaction( + args.mutation.transaction_id.clone(), format!("shape:create:{}", shape_id.as_str()), format!("create shape {shape_id}"), vec![Operation::CreateShape { shape, anchor: SiblingAnchor::Last }], )?; - commit_mutation(&mut file, transaction, args.mutation.dry_run, json_output, stdout) + target.finish(transaction, &args.mutation, json_output, stdout) } -fn patch_shape(args: ShapePatchArgs, json_output: bool, stdout: &mut dyn Write) -> Result<()> { +fn patch_shape(args: &ShapePatchArgs, json_output: bool, stdout: &mut dyn Write) -> Result<()> { let patch_json = read_json_argument(&args.patch, "shape patch")?; let patch: ShapePatch = serde_json::from_str(&patch_json) .map_err(|error| CliError::new(EXIT_INVALID, error).context("could not parse ShapePatch JSON"))?; - let mut file = open_document(&args.path)?; + let mut target = StructuredMutationTarget::open(args.path.as_deref(), &args.mutation)?; + let snapshot = target.snapshot()?; let shape_id = select_unique_shape( - &mut file, + &snapshot.document, args.shape_id.as_deref(), args.name.as_deref(), args.role.as_deref(), )?; - let transaction = structured_transaction( - &mut file, - args.mutation.transaction_id, + let transaction = target.transaction( + args.mutation.transaction_id.clone(), format!("shape:patch:{}", shape_id.as_str()), format!("patch shape {shape_id}"), vec![Operation::PatchShape { shape_id, patch, expected_version: args.expected_version.map(RecordVersion) }], )?; - commit_mutation(&mut file, transaction, args.mutation.dry_run, json_output, stdout) + target.finish(transaction, &args.mutation, json_output, stdout) } -fn delete_shape(args: ShapeDeleteArgs, json_output: bool, stdout: &mut dyn Write) -> Result<()> { - let mut file = open_document(&args.path)?; +fn delete_shape(args: &ShapeDeleteArgs, json_output: bool, stdout: &mut dyn Write) -> Result<()> { + let mut target = StructuredMutationTarget::open(args.path.as_deref(), &args.mutation)?; + let snapshot = target.snapshot()?; let shape_id = select_unique_shape( - &mut file, + &snapshot.document, args.shape_id.as_deref(), args.name.as_deref(), args.role.as_deref(), )?; - let transaction = structured_transaction( - &mut file, - args.mutation.transaction_id, + let transaction = target.transaction( + args.mutation.transaction_id.clone(), format!("shape:delete:{}", shape_id.as_str()), format!("delete shape {shape_id}"), vec![Operation::DeleteShape { shape_id, expected_version: args.expected_version.map(RecordVersion) }], )?; - commit_mutation(&mut file, transaction, args.mutation.dry_run, json_output, stdout) + target.finish(transaction, &args.mutation, json_output, stdout) } diff --git a/crates/inkfinite-cli/src/cli/support.rs b/crates/inkfinite-cli/src/cli/support.rs index e492e9b..e4a8743 100644 --- a/crates/inkfinite-cli/src/cli/support.rs +++ b/crates/inkfinite-cli/src/cli/support.rs @@ -74,16 +74,46 @@ pub fn map_file_error(error: FileError) -> CliError { FileError::Io { .. } | FileError::RecoveryNotFound { .. } => EXIT_INPUT, _ => EXIT_INVALID, }; - CliError::new(exit_code, error) + let code = match &error { + FileError::AlreadyExists { .. } => "document_exists", + FileError::Locked { .. } => "document_locked", + FileError::SamePath { .. } => "same_path", + FileError::Engine(EngineError::StaleHeads) => "stale_heads", + FileError::Engine(EngineError::Precondition(_)) => "precondition_failed", + FileError::Engine(EngineError::Permission(_)) => "permission_denied", + FileError::Io { .. } => "file_io_error", + FileError::Json(_) => "invalid_json", + FileError::InvalidDocument(_) => "invalid_document", + FileError::UnsupportedFormat { .. } => "unsupported_format", + FileError::UnsupportedShapeKind { .. } => "unsupported_shape_kind", + FileError::RecoveryNotFound { .. } => "recovery_not_found", + FileError::InvalidRecovery(_) => "invalid_recovery", + FileError::RecoveryAhead { .. } => "recovery_ahead", + FileError::Engine(_) => "document_engine_error", + FileError::Sync(_) => "sync_error", + }; + let retryable = matches!( + &error, + FileError::Locked { .. } | FileError::Engine(EngineError::StaleHeads | EngineError::Precondition(_)) + ); + let mut diagnostic = CliError::new(exit_code, error).with_code(code); + if retryable { + diagnostic = diagnostic.retryable("Refresh the document state, then retry the command."); + } + diagnostic } pub fn map_json_error(error: serde_json::Error) -> CliError { let exit_code = if error.is_io() { EXIT_INPUT } else { EXIT_INVALID }; - CliError::new(exit_code, error).context("could not write JSON output") + CliError::new(exit_code, error) + .with_code("json_output_error") + .context("could not write JSON output") } pub fn map_output_error(error: io::Error) -> CliError { - CliError::new(EXIT_INPUT, error).context("could not write stdout") + CliError::new(EXIT_INPUT, error) + .with_code("output_error") + .context("could not write stdout") } #[cfg(test)] diff --git a/crates/inkfinite-cli/tests/cli.rs b/crates/inkfinite-cli/tests/cli.rs index 8b692ea..a65c993 100644 --- a/crates/inkfinite-cli/tests/cli.rs +++ b/crates/inkfinite-cli/tests/cli.rs @@ -53,6 +53,10 @@ fn help_makes_common_tasks_and_support_paths_discoverable() { String::from_utf8(version.stdout).unwrap(), format!("inkfinite {}\n", env!("CARGO_PKG_VERSION")) ); + + let agent_review_attempt = run(["app", "accept", "--proposal-id", "proposal:1", "--json"]); + assert_eq!(agent_review_attempt.status.code(), Some(2)); + assert_eq!(parse_stderr(&agent_review_attempt)["error"]["code"], "invalid_usage"); } #[test] @@ -64,6 +68,8 @@ fn closed_file_workflow_has_stable_human_and_json_output() { assert_success(&created); let created_json = parse_stdout(&created); assert_eq!(created_json["document_id"], "document:system-map"); + assert_eq!(created_json["page_id"], "page:document:system-map:1"); + assert_eq!(created_json["layer_id"], "layer:document:system-map:1"); assert_eq!(created_json["path"], document_path.to_string_lossy().replace('\\', "/")); assert!(created_json["heads"].as_array().is_some_and(|heads| !heads.is_empty())); @@ -73,6 +79,11 @@ fn closed_file_workflow_has_stable_human_and_json_output() { assert_eq!(inspected_json["document_id"], "document:system-map"); assert_eq!(inspected_json["heads"], created_json["heads"]); + let summary = parse_stdout(&run(["inspect", path(&document_path), "--summary", "--json"])); + assert_eq!(summary["counts"]["pages"], 1); + assert_eq!(summary["counts"]["layers"], 1); + assert!(summary.get("document").is_none()); + let globally_selected_json = run(["--json", "inspect", path(&document_path)]); assert_success(&globally_selected_json); assert_eq!(parse_stdout(&globally_selected_json), inspected_json); @@ -165,6 +176,9 @@ fn query_forwards_semantic_hierarchy_kind_and_bounds_filters() { "layer:filters:1", "--bounds", "10,20,40,30", + "--detail", + "--limit", + "1", "--json", ]); assert_success(&output); @@ -173,6 +187,48 @@ fn query_forwards_semantic_hierarchy_kind_and_bounds_filters() { assert_eq!(result["records"][0]["kind"], "shape"); assert_eq!(result["records"][0]["id"], "shape:api"); assert_eq!(result["bounds"]["shape:api"]["width"], 40.0); + assert_eq!(result["details"][0]["kind"], "shape"); + assert_eq!(result["details"][0]["record"]["version"], 1); + assert_eq!(result["total"], 1); + assert_eq!(result["truncated"], false); +} + +#[test] +fn shape_discovery_and_transaction_output_are_agent_friendly() { + let temporary = TestDirectory::new("transaction-output"); + let document_path = temporary.path.join("output.inkfinite"); + let transaction_path = temporary.path.join("create.json"); + assert_success(&run(["new", path(&document_path), "--json"])); + let original = fs::read(&document_path).unwrap(); + + let kinds = parse_stdout(&run(["shape", "kinds", "--json"])); + assert!(kinds.as_array().unwrap().iter().any(|kind| kind["kind"] == "rect")); + let described = parse_stdout(&run(["shape", "describe", "container", "--json"])); + assert_eq!(described["allows_children"], true); + + let emitted = run([ + "shape", + "create", + path(&document_path), + "--kind", + "rect", + "--layer", + "layer:document:output:1", + "--properties", + "{\"width\":40,\"height\":30}", + "--transaction-out", + path(&transaction_path), + "--json", + ]); + assert_success(&emitted); + let emitted_json = parse_stdout(&emitted); + assert_eq!(emitted_json["dry_run"], true); + assert_eq!(fs::read(&document_path).unwrap(), original); + let transaction: TransactionDraft = serde_json::from_slice(&fs::read(&transaction_path).unwrap()).unwrap(); + let Operation::CreateShape { shape, .. } = &transaction.operations[0] else { + panic!("structured create should emit a create-shape operation") + }; + assert_eq!(shape.id.as_str(), "shape:rect:1"); } #[test] @@ -227,6 +283,11 @@ fn schemas_and_capabilities_match_checked_in_contracts() { "authenticated_local_socket" ); assert_eq!(capabilities_json["live_mode"]["tcp_or_http"], false); + assert_eq!(capabilities_json["live_mode"]["proposal_decisions"], "desktop_ui_only"); + assert_eq!( + capabilities_json["query_options"], + serde_json::json!(["detail", "limit"]) + ); assert_eq!(capabilities_json["mutation_commands"]["shape"][0], "create"); assert!( capabilities_json["commands"] @@ -550,8 +611,14 @@ fn json_failures_keep_stdout_clean_and_use_stable_exit_codes() { assert_eq!(missing_output.status.code(), Some(3)); assert!(missing_output.stdout.is_empty()); let diagnostic = String::from_utf8(missing_output.stderr).unwrap(); - assert!(diagnostic.starts_with("inkfinite: could not open")); - assert!(diagnostic.contains("read canonical document")); + let diagnostic: Value = serde_json::from_str(&diagnostic).unwrap(); + assert_eq!(diagnostic["error"]["code"], "file_io_error"); + assert!( + diagnostic["error"]["message"] + .as_str() + .unwrap() + .contains("could not open") + ); let document = temporary.path.join("existing.inkfinite"); assert_success(&run(["new", path(&document), "--json"])); @@ -562,6 +629,7 @@ fn json_failures_keep_stdout_clean_and_use_stable_exit_codes() { let usage = run(["query", path(&document), "--bounds", "1,2,3", "--json"]); assert_eq!(usage.status.code(), Some(2)); assert!(usage.stdout.is_empty()); + assert_eq!(parse_stderr(&usage)["error"]["code"], "invalid_usage"); } #[cfg(unix)] @@ -623,12 +691,17 @@ fn live_commands_read_shared_records_from_the_authenticated_local_server() { heads: server_snapshot.heads.clone(), records: Vec::new(), bounds: BTreeMap::new(), + details: Vec::new(), + total: 0, + truncated: false, }), AppRequest::Focus => AppResponse::Focused, - AppRequest::Propose { .. } - | AppRequest::AcceptProposal { .. } - | AppRequest::RejectProposal { .. } - | AppRequest::Apply { .. } => panic!("proposal requests are outside this IPC fixture"), + AppRequest::Context { .. } + | AppRequest::Propose { .. } + | AppRequest::ProposalStatus { .. } + | AppRequest::Apply { .. } => { + panic!("proposal requests are outside this IPC fixture") + } }; ipc::write_frame(&mut stream, &ResponseEnvelope { request_id, result: Ok(response) }) .await @@ -672,7 +745,7 @@ 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() { +fn live_structured_proposal_review_status_and_authorized_apply_round_trip() { use std::os::unix::fs::FileTypeExt; use std::os::unix::net::{UnixListener, UnixStream}; use std::sync::mpsc; @@ -705,28 +778,19 @@ fn live_propose_accept_partial_and_authorized_apply_round_trip_through_cli() { 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, - 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 layer_id = snapshot.document.pages[&page_id].layer_ids[0].clone(); let mut service = SessionService::new(); let opened = service.open(&document_path, actor.clone()).unwrap(); let session_id = opened.session_id; + service + .update_context( + &session_id, + Some(page_id.clone()), + Vec::new(), + Some(inkfinite_core::proto::Bounds { x: -50.0, y: -25.0, width: 100.0, height: 50.0 }), + ) + .unwrap(); let authorization = service.authorize_apply(&session_id).unwrap(); let authorization_token = authorization.token; @@ -748,12 +812,17 @@ fn live_propose_accept_partial_and_authorized_apply_round_trip_through_cli() { 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 { + for _ in 0..6 { 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); + if let Ok(AppResponse::Proposal(proposal)) = &result { + service + .accept_proposal(&session_id, &proposal.id, None) + .expect("desktop review fixture should accept the proposal"); + } ipc::write_frame(&mut stream, &ResponseEnvelope { request_id, result }) .await .unwrap(); @@ -767,31 +836,47 @@ fn live_propose_accept_partial_and_authorized_apply_round_trip_through_cli() { }); let proposed = run([ - "app", - "propose", + "shape", + "create", + "--app", "--session-id", "session:1", - "--transaction", - path(&transaction_path), + "--kind", + "rect", + "--layer", + layer_id.as_str(), + "--role", + "architecture.service", "--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); + assert_eq!(proposed_json["preview"]["created"].as_array().unwrap().len(), 1); - let accepted = run([ + let proposal_status = run([ "app", - "accept", + "proposal", + "status", "--session-id", "session:1", "--proposal-id", "proposal:1", "--json", ]); + assert_success(&proposal_status); + assert_eq!(parse_stdout(&proposal_status)["state"], "accepted"); + + let context = run(["app", "context", "--session-id", "session:1", "--json"]); + assert_success(&context); + let context_json = parse_stdout(&context); + assert_eq!(context_json["page_id"], page_id.as_str()); + assert_eq!(context_json["viewport"]["width"], 100.0); + + let accepted = run(["app", "inspect", "--session-id", "session:1", "--json"]); assert_success(&accepted); let accepted_json = parse_stdout(&accepted); - let accepted_heads = accepted_json["status"]["snapshot"]["heads"].clone(); + let accepted_heads = accepted_json["heads"].clone(); let direct_transaction = TransactionDraft { id: TransactionId("transaction:authorized".into()), actor_id: actor, @@ -824,6 +909,12 @@ fn live_propose_accept_partial_and_authorized_apply_round_trip_through_cli() { server.join().unwrap(); let final_snapshot = final_snapshot_receiver.recv().unwrap(); assert_eq!(final_snapshot.document.pages.values().next().unwrap().name, "Applied"); + assert!( + final_snapshot + .document + .shapes + .contains_key(&ShapeId::from("shape:rect:1")) + ); let mut reopened = DocumentFile::open(&document_path, ActorId::from("actor:verify")).unwrap(); assert_eq!( reopened @@ -874,6 +965,10 @@ fn parse_stdout(output: &Output) -> Value { serde_json::from_slice(&output.stdout).unwrap() } +fn parse_stderr(output: &Output) -> Value { + serde_json::from_slice(&output.stderr).unwrap() +} + struct TestDirectory { path: PathBuf, } diff --git a/crates/inkfinite-core/src/engine/mod.rs b/crates/inkfinite-core/src/engine/mod.rs index b15d253..b37ce26 100644 --- a/crates/inkfinite-core/src/engine/mod.rs +++ b/crates/inkfinite-core/src/engine/mod.rs @@ -10,7 +10,8 @@ use serde::{Deserialize, Serialize}; use crate::crdt::{AutomergeDocument, CrdtError, EncodedChange}; use crate::proto::{ AffectedRegion, AssetPatch, Bounds, DocumentPatch, InverseMetadata, LayerContentsDisposition, LayerPatch, - LayoutAxis, Operation, Query, QueryResult, RecordId, ShapeAlignment, ShapePatch, TransactionId, Warning, + LayoutAxis, Operation, Query, QueryRecord, QueryResult, RecordId, ShapeAlignment, ShapePatch, TransactionId, + Warning, }; use crate::sync::{PeerSync, SyncDisposition, SyncMessage}; use crate::{ diff --git a/crates/inkfinite-core/src/engine/query.rs b/crates/inkfinite-core/src/engine/query.rs index ffaa654..87301d3 100644 --- a/crates/inkfinite-core/src/engine/query.rs +++ b/crates/inkfinite-core/src/engine/query.rs @@ -1,6 +1,6 @@ use super::geometry::{intersects, world_shape_bounds}; use super::hierarchy::containing_layer; -use super::{BTreeMap, DocumentSnapshot, Ordering, Query, QueryResult, RecordId, ShapeParent}; +use super::{BTreeMap, BTreeSet, DocumentSnapshot, Ordering, Query, QueryRecord, QueryResult, RecordId, ShapeParent}; #[allow(clippy::too_many_lines)] pub fn query_document(snapshot: &DocumentSnapshot, query: &Query) -> QueryResult { @@ -93,7 +93,39 @@ pub fn query_document(snapshot: &DocumentSnapshot, query: &Query) -> QueryResult } } records.sort_by(record_id_order); - QueryResult { heads: snapshot.heads.clone(), records, bounds } + let total = records.len(); + if let Some(limit) = query.limit { + records.truncate(limit as usize); + } + let returned_shapes: BTreeSet<_> = records + .iter() + .filter_map(|record| match record { + RecordId::Shape(shape_id) => Some(shape_id.clone()), + _ => None, + }) + .collect(); + bounds.retain(|shape_id, _| returned_shapes.contains(shape_id)); + let details = if query.include_records { + records + .iter() + .filter_map(|record| match record { + RecordId::Page(id) => document.pages.get(id).cloned().map(Box::new).map(QueryRecord::Page), + RecordId::Layer(id) => document.layers.get(id).cloned().map(Box::new).map(QueryRecord::Layer), + RecordId::Shape(id) => document.shapes.get(id).cloned().map(Box::new).map(QueryRecord::Shape), + RecordId::Binding(id) => document + .bindings + .get(id) + .cloned() + .map(Box::new) + .map(QueryRecord::Binding), + RecordId::Asset(id) => document.assets.get(id).cloned().map(Box::new).map(QueryRecord::Asset), + }) + .collect() + } else { + Vec::new() + }; + let truncated = total > records.len(); + QueryResult { heads: snapshot.heads.clone(), records, bounds, details, total, truncated } } pub fn matches_common(query: &Query, id: &str, name: Option<&String>) -> bool { diff --git a/crates/inkfinite-core/src/engine/tests.rs b/crates/inkfinite-core/src/engine/tests.rs index 650a0fe..f252ddd 100644 --- a/crates/inkfinite-core/src/engine/tests.rs +++ b/crates/inkfinite-core/src/engine/tests.rs @@ -441,6 +441,14 @@ fn queries_bounds_alignment_and_distribution_share_the_transaction_engine() { vec![crate::proto::RecordId::Shape(ShapeId::from("shape:a"))] ); + let detailed = engine + .query(&Query { include_records: true, limit: Some(1), ..Query::default() }) + .unwrap(); + assert!(detailed.total > 1); + assert!(detailed.truncated); + assert_eq!(detailed.records.len(), 1); + assert_eq!(detailed.details.len(), 1); + let align = transaction( &mut engine, "actor:a", diff --git a/crates/inkfinite-core/src/ipc/mod.rs b/crates/inkfinite-core/src/ipc/mod.rs index e536e64..ceb99b7 100644 --- a/crates/inkfinite-core/src/ipc/mod.rs +++ b/crates/inkfinite-core/src/ipc/mod.rs @@ -17,11 +17,10 @@ use thiserror::Error; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use crate::DocumentSnapshot; +use crate::engine::EngineError; use crate::file::FileError; -use crate::proto::{ - ApplyAuthorization, PROTOCOL_ID, PROTOCOL_VERSION, Proposal, ProposalId, Query, QueryResult, SessionId, -}; -use crate::session::{SessionCommit, SessionError, SessionService, SessionStatus}; +use crate::proto::{ApplyAuthorization, PROTOCOL_ID, PROTOCOL_VERSION, Proposal, Query, QueryResult, SessionId}; +use crate::session::{ProposalStatus, SessionCommit, SessionContext, SessionError, SessionService, SessionStatus}; pub use crate::engine::{CommitResult, TransactionDraft}; pub use crate::proto::{ProtocolError, Request, Response}; @@ -53,13 +52,18 @@ pub struct DiscoveryRecord { pub token: String, } -/// Read-only operation accepted by the desktop server. +/// Agent-facing operation accepted by the desktop server. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case", tag = "type")] #[allow(clippy::large_enum_variant)] pub enum AppRequest { /// List open desktop document sessions. Status, + /// Return the latest desktop page, selection, viewport, actor, and heads. + Context { + /// Session to inspect, or the only open session when omitted. + session_id: Option, + }, /// Return the current snapshot for an open session. Inspect { /// Session to inspect, or the only open session when omitted. @@ -79,21 +83,12 @@ pub enum AppRequest { /// Agent transaction to validate and preview. transaction: TransactionDraft, }, - /// Accept all or selected operations from a reviewed proposal. - AcceptProposal { + /// Read a proposal outcome without changing its review state. + ProposalStatus { /// 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, + /// Proposal whose review state should be returned. + proposal_id: crate::proto::ProposalId, }, /// Apply an agent transaction after consuming explicit desktop authorization. Apply { @@ -120,24 +115,26 @@ pub struct RequestEnvelope { pub request_id: String, /// Token read from the protected discovery record. pub token: String, - /// Requested read-only operation. + /// Requested agent-facing operation. pub request: AppRequest, } -/// Successful result from a read-only app command. +/// Successful result from an agent-facing app command. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case", tag = "type", content = "value")] pub enum AppResponse { /// Current state of every open document session. Status(Vec), + /// Current context for one editor session. + Context(SessionContext), /// Shared materialized document snapshot. 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, + /// Current or retained proposal review state. + ProposalStatus(ProposalStatus), /// A directly authorized transaction was committed. Committed(Box), /// The focus notification was emitted. @@ -391,18 +388,28 @@ pub fn remove_discovery(path: &Path, expected: &DiscoveryRecord) -> Result<(), I Ok(()) } -/// Dispatches one authenticated read-only app request through the shared session service. +/// Dispatches one authenticated app request through the shared session service. /// /// # Errors /// /// Returns a stable [`ProtocolError`] for unavailable sessions or document -/// failures. No request in this module mutates a document. +/// failures. Proposal acceptance and rejection are deliberately unavailable on +/// this agent-facing transport; those decisions belong to the desktop UI. pub fn dispatch(service: &mut SessionService, request: AppRequest) -> Result { match request { AppRequest::Status => service .statuses() .map(AppResponse::Status) .map_err(|error| session_protocol_error(&error)), + AppRequest::Context { session_id } => { + let session_id = service + .resolve_session_id(session_id.as_ref()) + .map_err(|error| session_protocol_error(&error))?; + service + .context(&session_id) + .map(AppResponse::Context) + .map_err(|error| session_protocol_error(&error)) + } AppRequest::Inspect { session_id } => { let session_id = service .resolve_session_id(session_id.as_ref()) @@ -425,42 +432,46 @@ 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 - .accept_proposal(&session_id, &proposal_id, operation_positions.as_deref()) - .map(|commit| AppResponse::Committed(Box::new(commit))) - .map_err(|error| session_protocol_error(&error)) + match service.propose(&session_id, transaction) { + Ok(proposal) => Ok(AppResponse::Proposal(proposal)), + Err(error) => Err(session_protocol_error_with_heads(service, &session_id, &error)), + } } - AppRequest::RejectProposal { session_id, proposal_id } => { + AppRequest::ProposalStatus { 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) + .proposal_status(&session_id, &proposal_id) + .map(AppResponse::ProposalStatus) .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)) + match service.apply_authorized(&session_id, &authorization, transaction) { + Ok(commit) => Ok(AppResponse::Committed(Box::new(commit))), + Err(error) => Err(session_protocol_error_with_heads(service, &session_id, &error)), + } } AppRequest::Focus => Ok(AppResponse::Focused), } } +fn session_protocol_error_with_heads( + service: &mut SessionService, session_id: &SessionId, error: &SessionError, +) -> ProtocolError { + let mut protocol_error = session_protocol_error(error); + if protocol_error.details.is_none() + && matches!(protocol_error.code.as_str(), "stale_heads" | "precondition_failed") + && let Ok(status) = service.status(session_id) + { + protocol_error.details = Some(serde_json::json!({ "current_heads": status.snapshot.heads })); + } + protocol_error +} + /// Converts a session failure into the shared protocol error contract. #[must_use] pub fn session_protocol_error(error: &SessionError) -> ProtocolError { @@ -468,6 +479,7 @@ pub fn session_protocol_error(error: &SessionError) -> ProtocolError { SessionError::NotFound(_) => "session_not_found", SessionError::SessionSelectionRequired { open_sessions: 0 } => "app_session_unavailable", SessionError::SessionSelectionRequired { .. } => "session_selection_required", + SessionError::InvalidContext(_) => "invalid_context", SessionError::ActorMismatch { .. } => "actor_mismatch", SessionError::StaleHeads => "stale_heads", SessionError::ProposalLimit(_) @@ -495,10 +507,16 @@ pub fn session_protocol_error(error: &SessionError) -> ProtocolError { | FileError::RecoveryNotFound { .. } | FileError::InvalidRecovery(_) | FileError::RecoveryAhead { .. } - | FileError::Engine(_) | FileError::Io { .. } => "document_file_error", + FileError::Engine(EngineError::StaleHeads) => "stale_heads", + FileError::Engine(EngineError::Precondition(_)) => "precondition_failed", + FileError::Engine(EngineError::Permission(_)) => "permission_denied", + FileError::Engine(_) => "document_engine_error", FileError::Sync(_) => "sync_error", }, + SessionError::Engine(EngineError::StaleHeads) => "stale_heads", + SessionError::Engine(EngineError::Precondition(_)) => "precondition_failed", + SessionError::Engine(EngineError::Permission(_)) => "permission_denied", SessionError::Engine(_) => "document_engine_error", }; let details = match error { @@ -774,6 +792,8 @@ mod tests { let status = dispatch(&mut service, AppRequest::Status).unwrap(); assert!(matches!(status, AppResponse::Status(statuses) if statuses.len() == 1)); + let context = dispatch(&mut service, AppRequest::Context { session_id: None }).unwrap(); + assert!(matches!(context, AppResponse::Context(context) if context.session_id == opened.session_id)); let snapshot = dispatch(&mut service, AppRequest::Inspect { session_id: None }).unwrap(); assert!(matches!(snapshot, AppResponse::Snapshot(snapshot) if snapshot == opened.status.snapshot)); let query = dispatch( diff --git a/crates/inkfinite-core/src/proto/mod.rs b/crates/inkfinite-core/src/proto/mod.rs index 725e247..6ea8127 100644 --- a/crates/inkfinite-core/src/proto/mod.rs +++ b/crates/inkfinite-core/src/proto/mod.rs @@ -15,7 +15,7 @@ use ts_rs::TS; pub const PROTOCOL_ID: &str = "inkfinite.protocol"; /// Current transport-independent protocol version. -pub const PROTOCOL_VERSION: u32 = 1; +pub const PROTOCOL_VERSION: u32 = 2; /// Stable identifier for a transaction. #[derive(Clone, Debug, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, Deserialize, TS)] @@ -307,6 +307,22 @@ pub enum RecordId { Asset(AssetId), } +/// Complete materialized record returned by a detailed query. +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case", tag = "kind", content = "record")] +pub enum QueryRecord { + /// Page record. + Page(Box), + /// Layer record. + Layer(Box), + /// Shape record. + Shape(Box), + /// Binding record. + Binding(Box), + /// Asset record. + Asset(Box), +} + /// Materialized patch returned after a successful commit. #[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize, TS)] pub struct DocumentPatch { @@ -400,6 +416,12 @@ pub struct Query { pub parent_id: Option, /// Restrict shapes to those intersecting these document bounds. pub bounds: Option, + /// Include complete matching records in the response. + #[serde(default)] + pub include_records: bool, + /// Return at most this many matches after deterministic sorting. + #[serde(default)] + pub limit: Option, } /// Materialized query result suitable for machine clients. @@ -411,6 +433,15 @@ pub struct QueryResult { pub records: Vec, /// Bounds for matching shapes, in the same order as their shape records. pub bounds: BTreeMap, + /// Complete records when the query requested them. + #[serde(default)] + pub details: Vec, + /// Number of matches before applying the requested limit. + #[serde(default)] + pub total: usize, + /// Whether the result omitted matches because of the requested limit. + #[serde(default)] + pub truncated: bool, } /// Result of saving an open document session. diff --git a/crates/inkfinite-core/src/session.rs b/crates/inkfinite-core/src/session.rs index 6d4f003..5f3486f 100644 --- a/crates/inkfinite-core/src/session.rs +++ b/crates/inkfinite-core/src/session.rs @@ -6,7 +6,7 @@ //! Adapters can expose this service over Tauri, local IPC, or a CLI without //! moving document bytes into the frontend. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::fmt::Write as _; use std::path::Path; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -17,14 +17,16 @@ use thiserror::Error; use crate::engine::{EngineError, SyncApplyResult, validate_document}; use crate::file::{DocumentFile, FileError}; use crate::proto::{ - ApplyAuthorization, CommitResult, DocumentPath, Proposal, ProposalId, Query, QueryResult, SaveResult, SessionId, - TransactionDraft, TransactionId, Warning, + ApplyAuthorization, Bounds, CommitResult, DocumentPath, Proposal, ProposalId, Query, QueryResult, SaveResult, + SessionId, TransactionDraft, TransactionId, Warning, }; use crate::sync::{PeerSyncStatus, SyncMessage}; -use crate::{ActorId, DocumentId, DocumentSnapshot, Origin, Timestamp, blank_document}; +use crate::{ActorId, ChangeHash, DocumentId, DocumentSnapshot, Origin, PageId, ShapeId, Timestamp, blank_document}; /// Maximum number of pending proposals held by one live session. pub const MAX_PROPOSALS_PER_SESSION: usize = 32; +/// Maximum number of completed proposal outcomes retained for agent polling. +pub const MAX_PROPOSAL_OUTCOMES_PER_SESSION: usize = 64; /// 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. @@ -93,6 +95,54 @@ pub struct SessionCommit { pub status: SessionStatus, } +/// Review state exposed to an agent without granting review authority. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProposalReviewState { + /// The proposal is waiting for a desktop review decision. + Pending, + /// A human accepted the proposal in the desktop UI. + Accepted, + /// A human rejected the proposal in the desktop UI. + Rejected, + /// The proposal was not reviewed before its bounded lifetime elapsed. + Expired, +} + +/// Pollable proposal state for agent workflows. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ProposalStatus { + /// Stable proposal identifier. + pub proposal_id: ProposalId, + /// Current review state. + pub state: ProposalReviewState, + /// Current document heads when this status was observed or recorded. + pub heads: Vec, + /// Full proposal while review is still pending. + pub proposal: Option, +} + +/// Current desktop editing context exposed to read-only agent workflows. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SessionContext { + /// Session whose editor state is represented. + pub session_id: SessionId, + /// Canonical path currently held by the desktop. + pub path: DocumentPath, + /// Actor used to build proposals for this session. + pub actor_id: ActorId, + /// Current causal heads. + pub heads: Vec, + /// Page currently visible in the editor. + pub page_id: Option, + /// Shapes selected by the user in stable UI order. + pub selection_ids: Vec, + /// Visible world-space rectangle, when the renderer has reported one. + pub viewport: Option, + /// Wall-clock time of the latest editor context update. + pub updated_at: Timestamp, +} + /// Result returned after a successful save or save-as. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct SessionSaved { @@ -123,6 +173,9 @@ pub enum SessionError { /// Number of sessions currently available for selection. open_sessions: usize, }, + /// The frontend reported editor context that does not belong to the session document. + #[error("invalid editor context: {0}")] + InvalidContext(String), /// The actor does not own the session's local mutation stream. #[error("actor {actual} does not own session actor {expected}")] ActorMismatch { @@ -211,6 +264,12 @@ struct DocumentSession { file: DocumentFile, proposals: BTreeMap, expired_proposals: BTreeSet, + proposal_outcomes: BTreeMap, + proposal_outcome_order: VecDeque, + page_id: Option, + selection_ids: Vec, + viewport: Option, + context_updated_at: Timestamp, authorizations: BTreeMap, } @@ -293,6 +352,91 @@ impl SessionService { .collect() } + /// Returns the latest page, selection, viewport, actor, and heads reported by the desktop editor. + /// + /// # Errors + /// + /// Returns a session lookup or snapshot error. + pub fn context(&mut self, session_id: &SessionId) -> Result { + let session = self.session_mut(session_id)?; + let snapshot = session.file.snapshot()?; + Ok(SessionContext { + session_id: session_id.clone(), + path: DocumentPath(session.file.path().to_string_lossy().into_owned()), + actor_id: session.file.actor_id().clone(), + heads: snapshot.heads, + page_id: session.page_id.clone(), + selection_ids: session.selection_ids.clone(), + viewport: session.viewport, + updated_at: session.context_updated_at, + }) + } + + /// Records the current frontend page, selection, and world-space viewport. + /// + /// # Errors + /// + /// Returns a session, snapshot, or context validation error. + pub fn update_context( + &mut self, session_id: &SessionId, page_id: Option, selection_ids: Vec, + viewport: Option, + ) -> Result<(), SessionError> { + let session = self.session_mut(session_id)?; + if page_id != session.page_id || selection_ids != session.selection_ids { + let snapshot = session.file.snapshot()?; + if let Some(page_id) = &page_id + && !snapshot.document.pages.contains_key(page_id) + { + return Err(SessionError::InvalidContext(format!("page {page_id} does not exist"))); + } + if let Some(shape_id) = selection_ids + .iter() + .find(|shape_id| !snapshot.document.shapes.contains_key(*shape_id)) + { + return Err(SessionError::InvalidContext(format!("shape {shape_id} does not exist"))); + } + if page_id.is_none() && !selection_ids.is_empty() { + return Err(SessionError::InvalidContext( + "a selection requires an active page".into(), + )); + } + if let Some(page_id) = &page_id { + for selected_shape_id in &selection_ids { + let mut shape_id = selected_shape_id; + let layer_id = loop { + let shape = &snapshot.document.shapes[shape_id]; + match &shape.parent { + crate::ShapeParent::Layer(layer_id) => break layer_id, + crate::ShapeParent::Shape(parent_id) => shape_id = parent_id, + } + }; + if snapshot.document.layers[layer_id].page_id != *page_id { + return Err(SessionError::InvalidContext(format!( + "shape {selected_shape_id} is not on page {page_id}" + ))); + } + } + } + } + if let Some(bounds) = viewport + && (!bounds.x.is_finite() + || !bounds.y.is_finite() + || !bounds.width.is_finite() + || !bounds.height.is_finite() + || bounds.width < 0.0 + || bounds.height < 0.0) + { + return Err(SessionError::InvalidContext( + "viewport must be finite with non-negative width and height".into(), + )); + } + session.page_id = page_id; + session.selection_ids = selection_ids; + session.viewport = viewport; + session.context_updated_at = timestamp_now(); + Ok(()) + } + /// Resolves an explicit session, or the only open session when omitted. /// /// # Errors @@ -418,6 +562,12 @@ impl SessionService { }; let commit = session.file.commit(transaction)?; session.proposals.remove(proposal_id); + session.record_proposal_outcome(ProposalStatus { + proposal_id: proposal_id.clone(), + state: ProposalReviewState::Accepted, + heads: commit.heads.clone(), + proposal: None, + }); let status = session.status(session_id)?; Ok(SessionCommit { commit, status }) } @@ -431,6 +581,13 @@ impl SessionService { let session = self.session_mut(session_id)?; session.expire_state(); if session.proposals.remove(proposal_id).is_some() { + let heads = session.file.snapshot()?.heads; + session.record_proposal_outcome(ProposalStatus { + proposal_id: proposal_id.clone(), + state: ProposalReviewState::Rejected, + heads, + proposal: None, + }); Ok(()) } else if session.expired_proposals.remove(proposal_id) { Err(SessionError::ProposalExpired(proposal_id.clone())) @@ -439,6 +596,31 @@ impl SessionService { } } + /// Returns the current or retained review state for a proposal. + /// + /// # Errors + /// + /// Returns a session, snapshot, or proposal lookup error. + pub fn proposal_status( + &mut self, session_id: &SessionId, proposal_id: &ProposalId, + ) -> Result { + let session = self.session_mut(session_id)?; + session.expire_state(); + if let Some(pending) = session.proposals.get(proposal_id) { + return Ok(ProposalStatus { + proposal_id: proposal_id.clone(), + state: ProposalReviewState::Pending, + heads: session.file.snapshot()?.heads, + proposal: Some(pending.proposal.clone()), + }); + } + session + .proposal_outcomes + .get(proposal_id) + .cloned() + .ok_or_else(|| SessionError::ProposalNotFound(proposal_id.clone())) + } + /// Issues a one-time authorization that the desktop UI can pass to a /// direct live apply request. /// @@ -650,7 +832,7 @@ impl SessionService { self.sessions.is_empty() } - fn insert(&mut self, file: DocumentFile) -> Result { + fn insert(&mut self, mut file: DocumentFile) -> Result { let path = file.path().to_owned(); if let Some((session_id, _)) = self.sessions.iter().find(|(_, session)| session.file.path() == path) { return Err(SessionError::AlreadyOpen { @@ -661,10 +843,17 @@ impl SessionService { self.next_session_number = self.next_session_number.saturating_add(1); let session_id = SessionId(format!("session:{}", self.next_session_number)); + let page_id = file.snapshot()?.document.page_ids.first().cloned(); let mut session = DocumentSession { file, proposals: BTreeMap::new(), expired_proposals: BTreeSet::new(), + proposal_outcomes: BTreeMap::new(), + proposal_outcome_order: VecDeque::new(), + page_id, + selection_ids: Vec::new(), + viewport: None, + context_updated_at: timestamp_now(), authorizations: BTreeMap::new(), }; let status = session.status(&session_id)?; @@ -690,7 +879,15 @@ impl DocumentSession { .collect::>(); for proposal_id in expired { self.proposals.remove(&proposal_id); - self.expired_proposals.insert(proposal_id); + self.expired_proposals.insert(proposal_id.clone()); + if let Ok(snapshot) = self.file.snapshot() { + self.record_proposal_outcome(ProposalStatus { + proposal_id, + state: ProposalReviewState::Expired, + heads: snapshot.heads, + proposal: None, + }); + } } while self.expired_proposals.len() > MAX_PROPOSALS_PER_SESSION { let Some(oldest) = self.expired_proposals.iter().next().cloned() else { break }; @@ -699,6 +896,18 @@ impl DocumentSession { self.authorizations.retain(|_, grant| now < grant.expires_at); } + fn record_proposal_outcome(&mut self, status: ProposalStatus) { + let proposal_id = status.proposal_id.clone(); + if !self.proposal_outcomes.contains_key(&proposal_id) { + self.proposal_outcome_order.push_back(proposal_id.clone()); + } + self.proposal_outcomes.insert(proposal_id, status); + while self.proposal_outcome_order.len() > MAX_PROPOSAL_OUTCOMES_PER_SESSION { + let Some(oldest) = self.proposal_outcome_order.pop_front() else { break }; + self.proposal_outcomes.remove(&oldest); + } + } + fn status(&mut self, session_id: &SessionId) -> Result { let snapshot = self.file.snapshot()?; let dirty = self.file.is_dirty()?; @@ -849,6 +1058,32 @@ mod tests { static TEST_COUNTER: AtomicU64 = AtomicU64::new(0); + #[test] + fn editor_context_tracks_page_selection_viewport_actor_and_heads() { + let root = test_directory(); + let path = root.join("context.inkfinite"); + let actor = ActorId::from("actor:context"); + let mut service = SessionService::new(); + let opened = service + .create(&path, DocumentId::from("document:context"), actor.clone(), None) + .expect("create session"); + let page_id = opened.status.snapshot.document.page_ids[0].clone(); + let viewport = Bounds { x: -100.0, y: -50.0, width: 200.0, height: 100.0 }; + + service + .update_context(&opened.session_id, Some(page_id.clone()), Vec::new(), Some(viewport)) + .expect("update context"); + let context = service.context(&opened.session_id).expect("read context"); + + assert_eq!(context.page_id, Some(page_id)); + assert_eq!(context.viewport, Some(viewport)); + assert_eq!(context.actor_id, actor); + assert_eq!(context.heads, opened.status.snapshot.heads); + + service.close(&opened.session_id).expect("close session"); + remove_test_directory(root); + } + #[test] fn stale_save_is_rejected_without_changing_the_session() { let root = test_directory(); @@ -977,6 +1212,10 @@ mod tests { service .reject_proposal(&opened.session_id, &proposal.id) .expect("reject proposal"); + let review = service + .proposal_status(&opened.session_id, &proposal.id) + .expect("retain rejected status"); + assert_eq!(review.state, ProposalReviewState::Rejected); assert_eq!(fs::read(&path).expect("read unchanged canonical file"), before_bytes); assert_eq!( service.status(&opened.session_id).expect("final status").snapshot, @@ -1027,6 +1266,12 @@ mod tests { .accept_proposal(&opened.session_id, &proposal.id, Some(&[1_u32])) .expect("accept selected operation"); + let review = service + .proposal_status(&opened.session_id, &proposal.id) + .expect("retain accepted status"); + assert_eq!(review.state, ProposalReviewState::Accepted); + assert_eq!(review.heads, accepted.commit.heads); + assert_eq!(accepted.commit.patch.changed.len(), 1); let status = service .status(&opened.session_id) diff --git a/packages/bindings/src/index.ts b/packages/bindings/src/index.ts index 8c2e220..09e843b 100644 --- a/packages/bindings/src/index.ts +++ b/packages/bindings/src/index.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`. -export * from "./model.js"; -export * from "./protocol.js"; -export * from "./registry.js"; -export * from "./transaction.js"; +export * from './model.js'; +export * from './protocol.js'; +export * from './registry.js'; +export * from './transaction.js'; diff --git a/packages/bindings/src/transaction.ts b/packages/bindings/src/transaction.ts index 3cce8a6..64198ca 100644 --- a/packages/bindings/src/transaction.ts +++ b/packages/bindings/src/transaction.ts @@ -311,6 +311,11 @@ expected_versions: { [key in ShapeId]: RecordVersion }, }; */ export type RecordId = { "kind": "page", "id": PageId } | { "kind": "layer", "id": LayerId } | { "kind": "shape", "id": ShapeId } | { "kind": "binding", "id": BindingId } | { "kind": "asset", "id": AssetId }; +/** + * Complete materialized record returned by a detailed query. + */ +export type QueryRecord = { "kind": "page", "record": PageRecord } | { "kind": "layer", "record": LayerRecord } | { "kind": "shape", "record": ShapeRecord } | { "kind": "binding", "record": BindingRecord } | { "kind": "asset", "record": AssetRecord }; + /** * Materialized patch returned after a successful commit. */ @@ -428,60 +433,80 @@ warnings: Array, }; /** * Optional semantic filters for a document query. */ -export type Query = { +export type Query = { /** * Match one exact record ID, regardless of record kind. */ -id: string | null, +id: string | null, /** * Match a page, layer, shape, or asset display name. */ -name: string | null, +name: string | null, /** * Match one exact semantic role. */ -role: string | null, +role: string | null, /** * Match one exact tag. */ -tag: string | null, +tag: string | null, /** * Match one exact shape registry key. */ -shape_kind: string | null, +shape_kind: string | null, /** * Restrict the query to one page. */ -page_id: PageId | null, +page_id: PageId | null, /** * Restrict the query to one layer. */ -layer_id: LayerId | null, +layer_id: LayerId | null, /** * Restrict shapes to one direct parent. */ -parent_id: string | null, +parent_id: string | null, /** * Restrict shapes to those intersecting these document bounds. */ -bounds: Bounds | null, }; +bounds: Bounds | null, +/** + * Include complete matching records in the response. + */ +include_records: boolean, +/** + * Return at most this many matches after deterministic sorting. + */ +limit: number | null, }; /** * Materialized query result suitable for machine clients. */ -export type QueryResult = { +export type QueryResult = { /** * Causal heads inspected by the query. */ -heads: Array, +heads: Array, /** * Matching records in deterministic order. */ -records: Array, +records: Array, /** * Bounds for matching shapes, in the same order as their shape records. */ -bounds: { [key in ShapeId]: Bounds }, }; +bounds: { [key in ShapeId]: Bounds }, +/** + * Complete records when the query requested them. + */ +details: Array, +/** + * Number of matches before applying the requested limit. + */ +total: number, +/** + * Whether the result omitted matches because of the requested limit. + */ +truncated: boolean, }; /** * Result of saving an open document session. diff --git a/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts b/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts index f970b4e..83601d7 100644 --- a/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts @@ -78,6 +78,9 @@ export function createCanvasController( let proposalMessage = $state(null); let unsubscribeProposal: (() => void) | null = null; let unsubscribeFileMenu: (() => void) | null = null; + let unsubscribeAgentContext: (() => void) | null = null; + let agentContextTimer: ReturnType | null = null; + let lastAgentContext = ''; let removeBeforeUnload: (() => void) | null = null; let stencilPaletteOpen = $state(false); let interchangeBusy = $state(false); @@ -98,6 +101,7 @@ export function createCanvasController( updateMarquee(marqueeBounds); } renderer?.markDirty(); + scheduleAgentContext(); }; if (typeof window !== 'undefined') { window.addEventListener('resize', handleResize); @@ -173,6 +177,34 @@ export function createCanvasController( return measureViewport(canvas); } + function scheduleAgentContext() { + if (!desktopRepo || agentContextTimer) return; + agentContextTimer = setTimeout(() => { + agentContextTimer = null; + if (!desktopRepo) return; + const state = store.getState(); + const viewport = getViewport(); + const width = viewport.width / state.camera.zoom; + const height = viewport.height / state.camera.zoom; + const context = { + pageId: state.ui.currentPageId, + selectionIds: [...state.ui.selectionIds], + viewport: { + x: state.camera.x - width / 2, + y: state.camera.y - height / 2, + width, + height + } + }; + const serialized = JSON.stringify(context); + if (serialized === lastAgentContext) return; + lastAgentContext = serialized; + void desktopRepo + .updateAgentContext(context) + .catch((error) => console.error('Failed to publish desktop agent context', error)); + }, 100); + } + const camera = new CameraController(store, getViewport); function getOverlayViewport(): Viewport { @@ -555,6 +587,8 @@ export function createCanvasController( proposal = update.proposal; proposalMessage = update.message ?? null; }); + unsubscribeAgentContext = store.subscribe(scheduleAgentContext); + scheduleAgentContext(); } unsubscribeFileMenu = platformSession.subscribeFileMenu?.((action) => { @@ -613,6 +647,8 @@ export function createCanvasController( renderer?.dispose(); inputAdapter?.dispose(); unsubscribeProposal?.(); + unsubscribeAgentContext?.(); + if (agentContextTimer) clearTimeout(agentContextTimer); unsubscribeFileMenu?.(); platformSession?.dispose?.(); if (platform === 'desktop') { diff --git a/packages/ui/src/lib/editor/components/StatusBar.svelte b/packages/ui/src/lib/editor/components/StatusBar.svelte index 102ef5c..795cad4 100644 --- a/packages/ui/src/lib/editor/components/StatusBar.svelte +++ b/packages/ui/src/lib/editor/components/StatusBar.svelte @@ -245,8 +245,8 @@

About Inkfinite

- Inkfinite is an infinite canvas prototype. The goal is to build a cross-platform editor - with a framework-agnostic core so the same engine powers both the web and desktop apps. + Inkfinite is an open canvas for sketching, diagramming, and working with coding agents. + The web and desktop apps share the same editor and document model.

Quick Tips

@@ -390,8 +390,8 @@ padding: var(--ink-space-5); } - :global(.about-dialog) { - --ink-dialog-radius: 1rem; + :global(.dialog__content.about-dialog) { + border-radius: 1rem; } .about h1 { diff --git a/packages/ui/src/lib/editor/platform.ts b/packages/ui/src/lib/editor/platform.ts index 5dca153..232296b 100644 --- a/packages/ui/src/lib/editor/platform.ts +++ b/packages/ui/src/lib/editor/platform.ts @@ -31,6 +31,13 @@ export type LiveProposal = { /** Change notification used by the desktop review surface. */ export type ProposalUpdate = { proposal: LiveProposal | null; message?: string }; +/** Editor-only state shared with read-only agent context queries. */ +export type AgentEditorContext = { + pageId: string | null; + selectionIds: string[]; + viewport: { x: number; y: number; width: number; height: number } | null; +}; + /** File command selected from a desktop application's native menu. */ export type NativeFileMenuAction = | 'new' @@ -78,6 +85,8 @@ export interface DesktopDocumentRepo extends PersistentDocRepo { ): Promise; rejectProposal(proposalId: string): Promise; authorizeApply(): Promise<{ token: string; session_id: string; expires_at: number }>; + /** Publishes the current page, selection, and visible world-space rectangle. */ + updateAgentContext(context: AgentEditorContext): Promise; } /** Connected persistence services used for one mounted editor. */ diff --git a/schemas/protocol-request.schema.json b/schemas/protocol-request.schema.json index f58c113..898d129 100644 --- a/schemas/protocol-request.schema.json +++ b/schemas/protocol-request.schema.json @@ -1233,6 +1233,11 @@ "null" ] }, + "include_records": { + "default": false, + "description": "Include complete matching records in the response.", + "type": "boolean" + }, "layer_id": { "anyOf": [ { @@ -1244,6 +1249,16 @@ ], "description": "Restrict the query to one layer." }, + "limit": { + "default": null, + "description": "Return at most this many matches after deterministic sorting.", + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, "name": { "description": "Match a page, layer, shape, or asset display name.", "type": [ diff --git a/schemas/protocol-response.schema.json b/schemas/protocol-response.schema.json index fcf3fd7..66165f8 100644 --- a/schemas/protocol-response.schema.json +++ b/schemas/protocol-response.schema.json @@ -1480,6 +1480,96 @@ ], "type": "object" }, + "QueryRecord": { + "description": "Complete materialized record returned by a detailed query.", + "oneOf": [ + { + "description": "Page record.", + "properties": { + "kind": { + "const": "page", + "type": "string" + }, + "record": { + "$ref": "#/$defs/PageRecord" + } + }, + "required": [ + "kind", + "record" + ], + "type": "object" + }, + { + "description": "Layer record.", + "properties": { + "kind": { + "const": "layer", + "type": "string" + }, + "record": { + "$ref": "#/$defs/LayerRecord" + } + }, + "required": [ + "kind", + "record" + ], + "type": "object" + }, + { + "description": "Shape record.", + "properties": { + "kind": { + "const": "shape", + "type": "string" + }, + "record": { + "$ref": "#/$defs/ShapeRecord" + } + }, + "required": [ + "kind", + "record" + ], + "type": "object" + }, + { + "description": "Binding record.", + "properties": { + "kind": { + "const": "binding", + "type": "string" + }, + "record": { + "$ref": "#/$defs/BindingRecord" + } + }, + "required": [ + "kind", + "record" + ], + "type": "object" + }, + { + "description": "Asset record.", + "properties": { + "kind": { + "const": "asset", + "type": "string" + }, + "record": { + "$ref": "#/$defs/AssetRecord" + } + }, + "required": [ + "kind", + "record" + ], + "type": "object" + } + ] + }, "QueryResult": { "description": "Materialized query result suitable for machine clients.", "properties": { @@ -1490,6 +1580,14 @@ "description": "Bounds for matching shapes, in the same order as their shape records.", "type": "object" }, + "details": { + "default": [], + "description": "Complete records when the query requested them.", + "items": { + "$ref": "#/$defs/QueryRecord" + }, + "type": "array" + }, "heads": { "description": "Causal heads inspected by the query.", "items": { @@ -1503,6 +1601,18 @@ "$ref": "#/$defs/RecordId" }, "type": "array" + }, + "total": { + "default": 0, + "description": "Number of matches before applying the requested limit.", + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "truncated": { + "default": false, + "description": "Whether the result omitted matches because of the requested limit.", + "type": "boolean" } }, "required": [ diff --git a/schemas/transaction-draft.schema.json b/schemas/transaction-draft.schema.json index 38ae44c..b536ed9 100644 --- a/schemas/transaction-draft.schema.json +++ b/schemas/transaction-draft.schema.json @@ -1,894 +1,1769 @@ { - "$comment": "Generated by Inkfinite's binding generator. Do not edit this file manually.", - "$defs": { - "ActorId": { "description": "Stable identifier for a human, agent, or system actor.", "type": "string" }, - "AssetId": { "description": "Stable identifier for an embedded or linked asset.", "type": "string" }, - "AssetPatch": { - "description": "Mutable asset fields.", - "properties": { - "name": { "description": "Replacement display name.", "type": ["string", "null"] }, - "provenance_source": { - "description": "Replacement attribution source label.", - "type": ["string", "null"] - } - }, - "type": "object" - }, - "AssetRecord": { - "description": "Durable image, font, or other binary asset.", - "properties": { - "digest": { "description": "Content digest including its algorithm prefix.", "type": "string" }, - "id": { "$ref": "#/$defs/AssetId", "description": "Stable record identifier." }, - "media_type": { "description": "IANA media type.", "type": "string" }, - "name": { "description": "User-visible asset name.", "type": "string" }, - "provenance": { "$ref": "#/$defs/Provenance", "description": "Attribution for the asset." }, - "source": { "$ref": "#/$defs/AssetSource", "description": "Stored or linked content." }, - "version": { - "$ref": "#/$defs/RecordVersion", - "description": "Version used by optimistic operation preconditions." - } - }, - "required": ["id", "name", "media_type", "digest", "source", "provenance", "version"], - "type": "object" - }, - "AssetSource": { - "description": "Storage form for asset contents.", - "oneOf": [ - { - "description": "Bytes stored inside the canonical document.", - "properties": { - "bytes": { - "description": "Raw asset bytes.", - "items": { "format": "uint8", "maximum": 255, "minimum": 0, "type": "integer" }, - "type": "array" - }, - "kind": { "const": "embedded", "type": "string" } - }, - "required": ["kind", "bytes"], - "type": "object" - }, - { - "description": "Stable external URI retained for formats that cannot embed an asset.", - "properties": { - "kind": { "const": "external", "type": "string" }, - "uri": { "description": "URI used to resolve the content.", "type": "string" } - }, - "required": ["kind", "uri"], - "type": "object" - } - ] - }, - "BindingAnchor": { - "description": "Attachment point on a bound shape.", - "oneOf": [ - { - "description": "Attach to the calculated center.", - "properties": { "kind": { "const": "center", "type": "string" } }, - "required": ["kind"], - "type": "object" - }, - { - "description": "Attach at normalized shape coordinates.", - "properties": { - "kind": { "const": "edge", "type": "string" }, - "x": { - "description": "Normalized horizontal coordinate.", - "format": "double", - "type": "number" - }, - "y": { "description": "Normalized vertical coordinate.", "format": "double", "type": "number" } - }, - "required": ["kind", "x", "y"], - "type": "object" - } - ] - }, - "BindingId": { "description": "Stable identifier for a binding.", "type": "string" }, - "BindingKind": { "description": "Registry key for a binding definition.", "type": "string" }, - "BindingRecord": { - "description": "Durable relationship between two shapes.", - "properties": { - "anchor": { "$ref": "#/$defs/BindingAnchor", "description": "Attachment point on the target." }, - "id": { "$ref": "#/$defs/BindingId", "description": "Stable record identifier." }, - "kind": { "$ref": "#/$defs/BindingKind", "description": "Registry key describing binding behavior." }, - "source_handle": { "description": "Named source handle, such as `start` or `end`.", "type": "string" }, - "source_shape_id": { - "$ref": "#/$defs/ShapeId", - "description": "Shape that owns the binding, such as an arrow." - }, - "target_shape_id": { "$ref": "#/$defs/ShapeId", "description": "Shape to which the source is bound." }, - "version": { - "$ref": "#/$defs/RecordVersion", - "description": "Version used by optimistic operation preconditions." - } - }, - "required": ["id", "kind", "source_shape_id", "target_shape_id", "source_handle", "anchor", "version"], - "type": "object" - }, - "ChangeHash": { "description": "Opaque causal hash supplied by the CRDT implementation.", "type": "string" }, - "ContainerLayout": { - "description": "Optional automatic layout applied by a container shape.", - "oneOf": [ - { - "description": "Children retain their explicit transforms.", - "properties": { "kind": { "const": "free", "type": "string" } }, - "required": ["kind"], - "type": "object" - }, - { - "description": "Children flow along one axis.", - "properties": { - "alignment": { - "$ref": "#/$defs/LayoutAlignment", - "description": "Alignment on the cross axis." - }, - "direction": { "$ref": "#/$defs/StackDirection", "description": "Flow direction." }, - "gap": { - "description": "Space between adjacent children.", - "format": "double", - "type": "number" - }, - "kind": { "const": "stack", "type": "string" }, - "padding": { - "$ref": "#/$defs/Insets", - "description": "Space between children and container edges." - } - }, - "required": ["kind", "direction", "gap", "padding", "alignment"], - "type": "object" - }, - { - "description": "Children flow through a fixed number of columns.", - "properties": { - "alignment": { "$ref": "#/$defs/LayoutAlignment", "description": "Alignment within cells." }, - "column_gap": { - "description": "Horizontal gap between cells.", - "format": "double", - "type": "number" - }, - "columns": { - "description": "Positive number of grid columns.", - "format": "uint32", - "minimum": 0, - "type": "integer" - }, - "kind": { "const": "grid", "type": "string" }, - "padding": { - "$ref": "#/$defs/Insets", - "description": "Space between children and container edges." - }, - "row_gap": { - "description": "Vertical gap between cells.", - "format": "double", - "type": "number" - } - }, - "required": ["kind", "columns", "column_gap", "row_gap", "padding", "alignment"], - "type": "object" - } - ] - }, - "Insets": { - "description": "Padding inside a layout container.", - "properties": { - "bottom": { "description": "Bottom inset.", "format": "double", "type": "number" }, - "left": { "description": "Left inset.", "format": "double", "type": "number" }, - "right": { "description": "Right inset.", "format": "double", "type": "number" }, - "top": { "description": "Top inset.", "format": "double", "type": "number" } - }, - "required": ["top", "right", "bottom", "left"], - "type": "object" - }, - "LayerContentsDisposition": { - "description": "Policy for children of a deleted non-empty layer.", - "oneOf": [ - { - "description": "Move root shapes to the specified layer while preserving their order.", - "properties": { - "destination_layer_id": { "$ref": "#/$defs/LayerId" }, - "kind": { "const": "move_to", "type": "string" } - }, - "required": ["kind", "destination_layer_id"], - "type": "object" - }, - { - "description": "Delete every descendant explicitly.", - "properties": { "kind": { "const": "delete", "type": "string" } }, - "required": ["kind"], - "type": "object" - } - ] - }, - "LayerId": { "description": "Stable identifier for a layer.", "type": "string" }, - "LayerPatch": { - "description": "Mutable layer fields.", - "properties": { - "locked": { "description": "Replacement locked state.", "type": ["boolean", "null"] }, - "name": { "description": "Replacement display name.", "type": ["string", "null"] }, - "opacity": { - "anyOf": [{ "$ref": "#/$defs/Opacity" }, { "type": "null" }], - "description": "Replacement inherited opacity." - }, - "visible": { "description": "Replacement visibility.", "type": ["boolean", "null"] } - }, - "type": "object" - }, - "LayerRecord": { - "description": "Durable layer record and its ordered root-shape list.", - "properties": { - "id": { "$ref": "#/$defs/LayerId", "description": "Stable record identifier." }, - "locked": { "description": "Whether descendants can be selected or changed.", "type": "boolean" }, - "name": { "description": "User-visible layer name.", "type": "string" }, - "opacity": { "$ref": "#/$defs/Opacity", "description": "Opacity inherited by descendants." }, - "page_id": { "$ref": "#/$defs/PageId", "description": "Page that owns this layer." }, - "shape_ids": { - "description": "Root shape IDs in back-to-front draw order.", - "items": { "$ref": "#/$defs/ShapeId" }, - "type": "array" - }, - "version": { - "$ref": "#/$defs/RecordVersion", - "description": "Version used by optimistic operation preconditions." - }, - "visible": { - "description": "Whether descendants participate in rendering and hit testing.", - "type": "boolean" - } - }, - "required": ["id", "page_id", "name", "shape_ids", "visible", "locked", "opacity", "version"], - "type": "object" - }, - "LayoutAlignment": { - "description": "Cross-axis alignment for laid-out children.", - "oneOf": [ - { "const": "start", "description": "Align children to the start edge.", "type": "string" }, - { "const": "center", "description": "Center children on the cross axis.", "type": "string" }, - { "const": "end", "description": "Align children to the end edge.", "type": "string" }, - { "const": "stretch", "description": "Stretch children across the cross axis.", "type": "string" } - ] - }, - "LayoutAxis": { - "description": "Axis used by alignment and distribution operations.", - "oneOf": [ - { "const": "horizontal", "description": "Horizontal document axis.", "type": "string" }, - { "const": "vertical", "description": "Vertical document axis.", "type": "string" } - ] - }, - "Opacity": { - "description": "Opacity constrained to the inclusive range from zero to one.", - "format": "float", - "maximum": 1.0, - "minimum": 0.0, - "type": "number" - }, - "Operation": { - "description": "Durable document operation. Ordered insertions use sibling anchors only.", - "oneOf": [ - { - "description": "Insert a page into the document's ordered page list.", - "properties": { - "anchor": { - "$ref": "#/$defs/SiblingAnchor", - "description": "Placement relative to an existing page." - }, - "page": { "$ref": "#/$defs/PageRecord", "description": "Complete new page record." }, - "type": { "const": "create_page", "type": "string" } - }, - "required": ["type", "page", "anchor"], - "type": "object" - }, - { - "description": "Rename a page.", - "properties": { - "expected_version": { - "anyOf": [{ "$ref": "#/$defs/RecordVersion" }, { "type": "null" }], - "description": "Optional optimistic record version." - }, - "name": { "description": "Replacement name.", "type": "string" }, - "page_id": { "$ref": "#/$defs/PageId", "description": "Page to rename." }, - "type": { "const": "rename_page", "type": "string" } - }, - "required": ["type", "page_id", "name"], - "type": "object" - }, - { - "description": "Delete a page and its owned records.", - "properties": { - "expected_version": { - "anyOf": [{ "$ref": "#/$defs/RecordVersion" }, { "type": "null" }], - "description": "Optional optimistic record version." - }, - "page_id": { "$ref": "#/$defs/PageId", "description": "Page to delete." }, - "type": { "const": "delete_page", "type": "string" } - }, - "required": ["type", "page_id"], - "type": "object" - }, - { - "description": "Insert a layer into a page's ordered layer list.", - "properties": { - "anchor": { - "$ref": "#/$defs/SiblingAnchor2", - "description": "Placement relative to an existing layer." - }, - "layer": { "$ref": "#/$defs/LayerRecord", "description": "Complete new layer record." }, - "type": { "const": "create_layer", "type": "string" } - }, - "required": ["type", "layer", "anchor"], - "type": "object" - }, - { - "description": "Change mutable layer fields.", - "properties": { - "expected_version": { - "anyOf": [{ "$ref": "#/$defs/RecordVersion" }, { "type": "null" }], - "description": "Optional optimistic record version." - }, - "layer_id": { "$ref": "#/$defs/LayerId", "description": "Layer to change." }, - "patch": { "$ref": "#/$defs/LayerPatch", "description": "Requested field replacements." }, - "type": { "const": "patch_layer", "type": "string" } - }, - "required": ["type", "layer_id", "patch"], - "type": "object" - }, - { - "description": "Move a layer within its page.", - "properties": { - "anchor": { - "$ref": "#/$defs/SiblingAnchor2", - "description": "New placement relative to a sibling layer." - }, - "expected_version": { - "anyOf": [{ "$ref": "#/$defs/RecordVersion" }, { "type": "null" }], - "description": "Optional optimistic record version." - }, - "layer_id": { "$ref": "#/$defs/LayerId", "description": "Layer to move." }, - "type": { "const": "reorder_layer", "type": "string" } - }, - "required": ["type", "layer_id", "anchor"], - "type": "object" - }, - { - "description": "Delete a layer using an explicit child disposition.", - "properties": { - "contents": { - "$ref": "#/$defs/LayerContentsDisposition", - "description": "Required handling for existing shapes." - }, - "expected_version": { - "anyOf": [{ "$ref": "#/$defs/RecordVersion" }, { "type": "null" }], - "description": "Optional optimistic record version." - }, - "layer_id": { "$ref": "#/$defs/LayerId", "description": "Layer to delete." }, - "type": { "const": "delete_layer", "type": "string" } - }, - "required": ["type", "layer_id", "contents"], - "type": "object" - }, - { - "description": "Insert a shape into its parent's ordered child list.", - "properties": { - "anchor": { - "$ref": "#/$defs/SiblingAnchor3", - "description": "Placement relative to an existing shape child." - }, - "shape": { "$ref": "#/$defs/ShapeRecord", "description": "Complete new shape record." }, - "type": { "const": "create_shape", "type": "string" } - }, - "required": ["type", "shape", "anchor"], - "type": "object" - }, - { - "description": "Change mutable shape fields without moving it.", - "properties": { - "expected_version": { - "anyOf": [{ "$ref": "#/$defs/RecordVersion" }, { "type": "null" }], - "description": "Optional optimistic record version." - }, - "patch": { "$ref": "#/$defs/ShapePatch", "description": "Requested field replacements." }, - "shape_id": { "$ref": "#/$defs/ShapeId", "description": "Shape to change." }, - "type": { "const": "patch_shape", "type": "string" } - }, - "required": ["type", "shape_id", "patch"], - "type": "object" - }, - { - "description": "Move a shape to another layer or container and place it by sibling anchor.", - "properties": { - "anchor": { - "$ref": "#/$defs/SiblingAnchor3", - "description": "Placement relative to a child of the replacement parent." - }, - "expected_version": { - "anyOf": [{ "$ref": "#/$defs/RecordVersion" }, { "type": "null" }], - "description": "Optional optimistic record version." - }, - "parent": { "$ref": "#/$defs/ShapeParent", "description": "Replacement parent." }, - "shape_id": { "$ref": "#/$defs/ShapeId", "description": "Shape to move." }, - "type": { "const": "reparent_shape", "type": "string" } - }, - "required": ["type", "shape_id", "parent", "anchor"], - "type": "object" - }, - { - "description": "Delete a shape and its owned descendants.", - "properties": { - "expected_version": { - "anyOf": [{ "$ref": "#/$defs/RecordVersion" }, { "type": "null" }], - "description": "Optional optimistic record version." - }, - "shape_id": { "$ref": "#/$defs/ShapeId", "description": "Shape to delete." }, - "type": { "const": "delete_shape", "type": "string" } - }, - "required": ["type", "shape_id"], - "type": "object" - }, - { - "description": "Create a relationship between two shapes.", - "properties": { - "binding": { "$ref": "#/$defs/BindingRecord", "description": "Complete new binding record." }, - "type": { "const": "create_binding", "type": "string" } - }, - "required": ["type", "binding"], - "type": "object" - }, - { - "description": "Delete a binding.", - "properties": { - "binding_id": { "$ref": "#/$defs/BindingId", "description": "Binding to delete." }, - "expected_version": { - "anyOf": [{ "$ref": "#/$defs/RecordVersion" }, { "type": "null" }], - "description": "Optional optimistic record version." - }, - "type": { "const": "delete_binding", "type": "string" } - }, - "required": ["type", "binding_id"], - "type": "object" - }, - { - "description": "Add an asset to the document.", - "properties": { - "asset": { "$ref": "#/$defs/AssetRecord", "description": "Complete new asset record." }, - "type": { "const": "create_asset", "type": "string" } - }, - "required": ["type", "asset"], - "type": "object" - }, - { - "description": "Change mutable asset metadata.", - "properties": { - "asset_id": { "$ref": "#/$defs/AssetId", "description": "Asset to change." }, - "expected_version": { - "anyOf": [{ "$ref": "#/$defs/RecordVersion" }, { "type": "null" }], - "description": "Optional optimistic record version." - }, - "patch": { "$ref": "#/$defs/AssetPatch", "description": "Requested field replacements." }, - "type": { "const": "patch_asset", "type": "string" } - }, - "required": ["type", "asset_id", "patch"], - "type": "object" - }, - { - "description": "Delete an asset that is no longer referenced.", - "properties": { - "asset_id": { "$ref": "#/$defs/AssetId", "description": "Asset to delete." }, - "expected_version": { - "anyOf": [{ "$ref": "#/$defs/RecordVersion" }, { "type": "null" }], - "description": "Optional optimistic record version." - }, - "type": { "const": "delete_asset", "type": "string" } - }, - "required": ["type", "asset_id"], - "type": "object" - }, - { - "description": "Align two or more shapes using their materialized bounds.", - "properties": { - "alignment": { - "$ref": "#/$defs/ShapeAlignment", - "description": "Alignment line shared by the shapes." - }, - "expected_versions": { - "additionalProperties": { "$ref": "#/$defs/RecordVersion" }, - "description": "Optional optimistic versions keyed by shape ID.", - "type": "object" - }, - "shape_ids": { - "description": "Shapes to align.", - "items": { "$ref": "#/$defs/ShapeId" }, - "type": "array" - }, - "type": { "const": "align_shapes", "type": "string" } - }, - "required": ["type", "shape_ids", "alignment", "expected_versions"], - "type": "object" - }, - { - "description": "Distribute three or more shapes with equal gaps between their bounds.", - "properties": { - "axis": { - "$ref": "#/$defs/LayoutAxis", - "description": "Axis on which to distribute the shapes." - }, - "expected_versions": { - "additionalProperties": { "$ref": "#/$defs/RecordVersion" }, - "description": "Optional optimistic versions keyed by shape ID.", - "type": "object" - }, - "shape_ids": { - "description": "Shapes to distribute.", - "items": { "$ref": "#/$defs/ShapeId" }, - "type": "array" - }, - "type": { "const": "distribute_shapes", "type": "string" } - }, - "required": ["type", "shape_ids", "axis", "expected_versions"], - "type": "object" - } - ] - }, - "Origin": { - "description": "Origin of a durable record or transaction.", - "oneOf": [ - { "const": "human", "description": "Direct edit made by a person.", "type": "string" }, - { "const": "agent", "description": "Edit proposed or applied by an agent.", "type": "string" }, - { "const": "sync", "description": "Change received from a trusted peer.", "type": "string" }, - { "const": "system", "description": "Deterministic repair or other internal change.", "type": "string" } - ] - }, - "PageId": { "description": "Stable identifier for a page.", "type": "string" }, - "PageRecord": { - "description": "Durable page record and its ordered layer list.", - "properties": { - "id": { "$ref": "#/$defs/PageId", "description": "Stable record identifier." }, - "layer_ids": { - "description": "Layer IDs in back-to-front draw order.", - "items": { "$ref": "#/$defs/LayerId" }, - "type": "array" - }, - "name": { "description": "User-visible page name.", "type": "string" }, - "version": { - "$ref": "#/$defs/RecordVersion", - "description": "Version used by optimistic operation preconditions." - } - }, - "required": ["id", "name", "layer_ids", "version"], - "type": "object" - }, - "Provenance": { - "description": "Attribution retained with durable content.", - "properties": { - "actor_id": { - "$ref": "#/$defs/ActorId", - "description": "Actor responsible for the record's current form." - }, - "origin": { "$ref": "#/$defs/Origin", "description": "Path by which the record entered the document." }, - "source": { - "description": "Optional source identifier, such as an external reference or proposal ID.", - "type": ["string", "null"] - }, - "timestamp": { - "$ref": "#/$defs/Timestamp", - "description": "Time at which this provenance entry was recorded." - } - }, - "required": ["actor_id", "origin", "timestamp"], - "type": "object" - }, - "RecordVersion": { - "description": "Monotonic version of a durable record within the document history.", - "format": "uint64", - "minimum": 0, - "type": "integer" - }, - "SemanticMetadata": { - "description": "Human- and agent-readable meaning attached to a shape.", - "properties": { - "agent_editable": { - "description": "Whether an agent may propose or apply edits to this shape.", - "type": "boolean" - }, - "description": { "description": "Optional longer description.", "type": ["string", "null"] }, - "locked": { "description": "Whether direct edits to this shape are prohibited.", "type": "boolean" }, - "name": { "description": "Optional display name.", "type": ["string", "null"] }, - "provenance": { "$ref": "#/$defs/Provenance", "description": "Attribution for the record." }, - "role": { - "description": "Optional semantic selector such as `architecture.service`.", - "type": ["string", "null"] - }, - "tags": { - "description": "Searchable, user-defined tags.", - "items": { "type": "string" }, - "type": "array" - } - }, - "required": ["tags", "locked", "agent_editable", "provenance"], - "type": "object" - }, - "ShapeAlignment": { - "description": "Edge or center line used to align a group of shapes.", - "oneOf": [ - { "const": "left", "description": "Align left bounds.", "type": "string" }, - { "const": "center", "description": "Align horizontal centers.", "type": "string" }, - { "const": "right", "description": "Align right bounds.", "type": "string" }, - { "const": "top", "description": "Align top bounds.", "type": "string" }, - { "const": "middle", "description": "Align vertical centers.", "type": "string" }, - { "const": "bottom", "description": "Align bottom bounds.", "type": "string" } - ] - }, - "ShapeId": { "description": "Stable identifier for a shape.", "type": "string" }, - "ShapeKind": { "description": "Registry key for a shape definition.", "type": "string" }, - "ShapeParent": { - "description": "Parent that owns a shape's sole draw-order entry.", - "oneOf": [ - { - "description": "The shape is a root child of a layer.", - "properties": { - "id": { "$ref": "#/$defs/LayerId" }, - "kind": { "const": "layer", "type": "string" } - }, - "required": ["kind", "id"], - "type": "object" - }, - { - "description": "The shape is a child of a container shape.", - "properties": { - "id": { "$ref": "#/$defs/ShapeId" }, - "kind": { "const": "shape", "type": "string" } - }, - "required": ["kind", "id"], - "type": "object" - } - ] - }, - "ShapePatch": { - "description": "Mutable shape fields. `Some(None)` clears an optional field.", - "properties": { - "layout": { - "anyOf": [{ "$ref": "#/$defs/ContainerLayout" }, { "type": "null" }], - "description": "Replacement or removal of container layout." - }, - "metadata": { - "anyOf": [{ "$ref": "#/$defs/SemanticMetadata" }, { "type": "null" }], - "description": "Replacement semantic metadata." - }, - "properties": { - "additionalProperties": true, - "description": "Replacement kind-specific properties.", - "type": ["object", "null"] - }, - "style": { - "anyOf": [{ "$ref": "#/$defs/ShapeStyle" }, { "type": "null" }], - "description": "Replacement common style." - }, - "transform": { - "anyOf": [{ "$ref": "#/$defs/Transform" }, { "type": "null" }], - "description": "Replacement relative transform." - } - }, - "type": "object" - }, - "ShapeRecord": { - "description": "Durable shape record shared by all built-in shape definitions.", - "properties": { - "child_ids": { - "description": "Ordered children when this shape is a container.", - "items": { "$ref": "#/$defs/ShapeId" }, - "type": "array" - }, - "id": { "$ref": "#/$defs/ShapeId", "description": "Stable record identifier." }, - "kind": { - "$ref": "#/$defs/ShapeKind", - "description": "Registry key. Built-in values are exposed as `*_KIND` constants." - }, - "layout": { - "anyOf": [{ "$ref": "#/$defs/ContainerLayout" }, { "type": "null" }], - "description": "Optional automatic layout for container shapes." - }, - "metadata": { - "$ref": "#/$defs/SemanticMetadata", - "description": "Human- and agent-readable semantics and permissions." - }, - "parent": { - "$ref": "#/$defs/ShapeParent", - "description": "Parent relation; ordering comes only from the parent's child list." - }, - "properties": { - "additionalProperties": true, - "description": "Kind-specific serialized properties validated by the registry.", - "type": "object" - }, - "style": { "$ref": "#/$defs/ShapeStyle", "description": "Visual properties common to all kinds." }, - "transform": { "$ref": "#/$defs/Transform", "description": "Transform relative to `parent`." }, - "version": { - "$ref": "#/$defs/RecordVersion", - "description": "Version used by optimistic operation preconditions." - } - }, - "required": [ - "id", - "kind", - "parent", - "transform", - "child_ids", - "properties", - "metadata", - "style", - "version" - ], - "type": "object" - }, - "ShapeStyle": { - "description": "Common visual style shared by all shape kinds.", - "properties": { - "fill_opacity": { - "anyOf": [{ "$ref": "#/$defs/Opacity" }, { "type": "null" }], - "description": "Optional opacity override for fills." - }, - "opacity": { "$ref": "#/$defs/Opacity", "description": "Opacity applied to the complete shape." }, - "stroke_opacity": { - "anyOf": [{ "$ref": "#/$defs/Opacity" }, { "type": "null" }], - "description": "Optional opacity override for strokes." - } - }, - "required": ["opacity"], - "type": "object" - }, - "SiblingAnchor": { - "description": "Anchor used to place an item in an ordered child list without numeric indexes.", - "oneOf": [ - { - "description": "Place the item before every existing sibling.", - "properties": { "position": { "const": "first", "type": "string" } }, - "required": ["position"], - "type": "object" - }, - { - "description": "Place the item after every existing sibling.", - "properties": { "position": { "const": "last", "type": "string" } }, - "required": ["position"], - "type": "object" - }, - { - "description": "Place the item immediately before the identified sibling.", - "properties": { - "position": { "const": "before", "type": "string" }, - "sibling_id": { "$ref": "#/$defs/PageId" } - }, - "required": ["position", "sibling_id"], - "type": "object" - }, - { - "description": "Place the item immediately after the identified sibling.", - "properties": { - "position": { "const": "after", "type": "string" }, - "sibling_id": { "$ref": "#/$defs/PageId" } - }, - "required": ["position", "sibling_id"], - "type": "object" - } - ] - }, - "SiblingAnchor2": { - "description": "Anchor used to place an item in an ordered child list without numeric indexes.", - "oneOf": [ - { - "description": "Place the item before every existing sibling.", - "properties": { "position": { "const": "first", "type": "string" } }, - "required": ["position"], - "type": "object" - }, - { - "description": "Place the item after every existing sibling.", - "properties": { "position": { "const": "last", "type": "string" } }, - "required": ["position"], - "type": "object" - }, - { - "description": "Place the item immediately before the identified sibling.", - "properties": { - "position": { "const": "before", "type": "string" }, - "sibling_id": { "$ref": "#/$defs/LayerId" } - }, - "required": ["position", "sibling_id"], - "type": "object" - }, - { - "description": "Place the item immediately after the identified sibling.", - "properties": { - "position": { "const": "after", "type": "string" }, - "sibling_id": { "$ref": "#/$defs/LayerId" } - }, - "required": ["position", "sibling_id"], - "type": "object" - } - ] - }, - "SiblingAnchor3": { - "description": "Anchor used to place an item in an ordered child list without numeric indexes.", - "oneOf": [ - { - "description": "Place the item before every existing sibling.", - "properties": { "position": { "const": "first", "type": "string" } }, - "required": ["position"], - "type": "object" - }, - { - "description": "Place the item after every existing sibling.", - "properties": { "position": { "const": "last", "type": "string" } }, - "required": ["position"], - "type": "object" - }, - { - "description": "Place the item immediately before the identified sibling.", - "properties": { - "position": { "const": "before", "type": "string" }, - "sibling_id": { "$ref": "#/$defs/ShapeId" } - }, - "required": ["position", "sibling_id"], - "type": "object" - }, - { - "description": "Place the item immediately after the identified sibling.", - "properties": { - "position": { "const": "after", "type": "string" }, - "sibling_id": { "$ref": "#/$defs/ShapeId" } - }, - "required": ["position", "sibling_id"], - "type": "object" - } - ] - }, - "StackDirection": { - "description": "Stack direction for container layout.", - "oneOf": [ - { "const": "horizontal", "description": "Place children from left to right.", "type": "string" }, - { "const": "vertical", "description": "Place children from top to bottom.", "type": "string" } - ] - }, - "Timestamp": { "description": "Milliseconds since the Unix epoch.", "format": "int64", "type": "integer" }, - "TransactionId": { "description": "Stable identifier for a transaction.", "type": "string" }, - "Transform": { - "description": "Transform relative to a shape's parent container or layer.", - "properties": { - "rotation": { "description": "Clockwise rotation in radians.", "format": "double", "type": "number" }, - "scale_x": { "description": "Horizontal scale.", "format": "double", "type": "number" }, - "scale_y": { "description": "Vertical scale.", "format": "double", "type": "number" }, - "translation": { "$ref": "#/$defs/Vec2", "description": "Translation in parent coordinates." } - }, - "required": ["translation", "rotation", "scale_x", "scale_y"], - "type": "object" - }, - "Vec2": { - "description": "Two-dimensional point or vector in document coordinates.", - "properties": { - "x": { "description": "Horizontal component.", "format": "double", "type": "number" }, - "y": { "description": "Vertical component.", "format": "double", "type": "number" } - }, - "required": ["x", "y"], - "type": "object" - } - }, - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "One durable edit submitted to the transaction engine.", - "properties": { - "actor_id": { "$ref": "#/$defs/ActorId", "description": "Actor responsible for the transaction." }, - "base_heads": { - "description": "Causal heads inspected by the caller.", - "items": { "$ref": "#/$defs/ChangeHash" }, - "type": "array" - }, - "description": { "description": "Human-readable explanation retained in history.", "type": "string" }, - "id": { "$ref": "#/$defs/TransactionId", "description": "Stable transaction identifier." }, - "operations": { - "description": "Ordered operations committed as one CRDT change.", - "items": { "$ref": "#/$defs/Operation" }, - "type": "array" - }, - "origin": { "$ref": "#/$defs/Origin", "description": "Path by which the transaction entered the engine." }, - "timestamp": { "$ref": "#/$defs/Timestamp", "description": "Client-recorded transaction time." } - }, - "required": ["id", "actor_id", "origin", "base_heads", "description", "operations", "timestamp"], - "title": "TransactionDraft", - "type": "object" + "$comment": "Generated by Inkfinite's binding generator. Do not edit this file manually.", + "$defs": { + "ActorId": { + "description": "Stable identifier for a human, agent, or system actor.", + "type": "string" + }, + "AssetId": { + "description": "Stable identifier for an embedded or linked asset.", + "type": "string" + }, + "AssetPatch": { + "description": "Mutable asset fields.", + "properties": { + "name": { + "description": "Replacement display name.", + "type": [ + "string", + "null" + ] + }, + "provenance_source": { + "description": "Replacement attribution source label.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "AssetRecord": { + "description": "Durable image, font, or other binary asset.", + "properties": { + "digest": { + "description": "Content digest including its algorithm prefix.", + "type": "string" + }, + "id": { + "$ref": "#/$defs/AssetId", + "description": "Stable record identifier." + }, + "media_type": { + "description": "IANA media type.", + "type": "string" + }, + "name": { + "description": "User-visible asset name.", + "type": "string" + }, + "provenance": { + "$ref": "#/$defs/Provenance", + "description": "Attribution for the asset." + }, + "source": { + "$ref": "#/$defs/AssetSource", + "description": "Stored or linked content." + }, + "version": { + "$ref": "#/$defs/RecordVersion", + "description": "Version used by optimistic operation preconditions." + } + }, + "required": [ + "id", + "name", + "media_type", + "digest", + "source", + "provenance", + "version" + ], + "type": "object" + }, + "AssetSource": { + "description": "Storage form for asset contents.", + "oneOf": [ + { + "description": "Bytes stored inside the canonical document.", + "properties": { + "bytes": { + "description": "Raw asset bytes.", + "items": { + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": "integer" + }, + "type": "array" + }, + "kind": { + "const": "embedded", + "type": "string" + } + }, + "required": [ + "kind", + "bytes" + ], + "type": "object" + }, + { + "description": "Stable external URI retained for formats that cannot embed an asset.", + "properties": { + "kind": { + "const": "external", + "type": "string" + }, + "uri": { + "description": "URI used to resolve the content.", + "type": "string" + } + }, + "required": [ + "kind", + "uri" + ], + "type": "object" + } + ] + }, + "BindingAnchor": { + "description": "Attachment point on a bound shape.", + "oneOf": [ + { + "description": "Attach to the calculated center.", + "properties": { + "kind": { + "const": "center", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "description": "Attach at normalized shape coordinates.", + "properties": { + "kind": { + "const": "edge", + "type": "string" + }, + "x": { + "description": "Normalized horizontal coordinate.", + "format": "double", + "type": "number" + }, + "y": { + "description": "Normalized vertical coordinate.", + "format": "double", + "type": "number" + } + }, + "required": [ + "kind", + "x", + "y" + ], + "type": "object" + } + ] + }, + "BindingId": { + "description": "Stable identifier for a binding.", + "type": "string" + }, + "BindingKind": { + "description": "Registry key for a binding definition.", + "type": "string" + }, + "BindingRecord": { + "description": "Durable relationship between two shapes.", + "properties": { + "anchor": { + "$ref": "#/$defs/BindingAnchor", + "description": "Attachment point on the target." + }, + "id": { + "$ref": "#/$defs/BindingId", + "description": "Stable record identifier." + }, + "kind": { + "$ref": "#/$defs/BindingKind", + "description": "Registry key describing binding behavior." + }, + "source_handle": { + "description": "Named source handle, such as `start` or `end`.", + "type": "string" + }, + "source_shape_id": { + "$ref": "#/$defs/ShapeId", + "description": "Shape that owns the binding, such as an arrow." + }, + "target_shape_id": { + "$ref": "#/$defs/ShapeId", + "description": "Shape to which the source is bound." + }, + "version": { + "$ref": "#/$defs/RecordVersion", + "description": "Version used by optimistic operation preconditions." + } + }, + "required": [ + "id", + "kind", + "source_shape_id", + "target_shape_id", + "source_handle", + "anchor", + "version" + ], + "type": "object" + }, + "ChangeHash": { + "description": "Opaque causal hash supplied by the CRDT implementation.", + "type": "string" + }, + "ContainerLayout": { + "description": "Optional automatic layout applied by a container shape.", + "oneOf": [ + { + "description": "Children retain their explicit transforms.", + "properties": { + "kind": { + "const": "free", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "description": "Children flow along one axis.", + "properties": { + "alignment": { + "$ref": "#/$defs/LayoutAlignment", + "description": "Alignment on the cross axis." + }, + "direction": { + "$ref": "#/$defs/StackDirection", + "description": "Flow direction." + }, + "gap": { + "description": "Space between adjacent children.", + "format": "double", + "type": "number" + }, + "kind": { + "const": "stack", + "type": "string" + }, + "padding": { + "$ref": "#/$defs/Insets", + "description": "Space between children and container edges." + } + }, + "required": [ + "kind", + "direction", + "gap", + "padding", + "alignment" + ], + "type": "object" + }, + { + "description": "Children flow through a fixed number of columns.", + "properties": { + "alignment": { + "$ref": "#/$defs/LayoutAlignment", + "description": "Alignment within cells." + }, + "column_gap": { + "description": "Horizontal gap between cells.", + "format": "double", + "type": "number" + }, + "columns": { + "description": "Positive number of grid columns.", + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "kind": { + "const": "grid", + "type": "string" + }, + "padding": { + "$ref": "#/$defs/Insets", + "description": "Space between children and container edges." + }, + "row_gap": { + "description": "Vertical gap between cells.", + "format": "double", + "type": "number" + } + }, + "required": [ + "kind", + "columns", + "column_gap", + "row_gap", + "padding", + "alignment" + ], + "type": "object" + } + ] + }, + "Insets": { + "description": "Padding inside a layout container.", + "properties": { + "bottom": { + "description": "Bottom inset.", + "format": "double", + "type": "number" + }, + "left": { + "description": "Left inset.", + "format": "double", + "type": "number" + }, + "right": { + "description": "Right inset.", + "format": "double", + "type": "number" + }, + "top": { + "description": "Top inset.", + "format": "double", + "type": "number" + } + }, + "required": [ + "top", + "right", + "bottom", + "left" + ], + "type": "object" + }, + "LayerContentsDisposition": { + "description": "Policy for children of a deleted non-empty layer.", + "oneOf": [ + { + "description": "Move root shapes to the specified layer while preserving their order.", + "properties": { + "destination_layer_id": { + "$ref": "#/$defs/LayerId" + }, + "kind": { + "const": "move_to", + "type": "string" + } + }, + "required": [ + "kind", + "destination_layer_id" + ], + "type": "object" + }, + { + "description": "Delete every descendant explicitly.", + "properties": { + "kind": { + "const": "delete", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + } + ] + }, + "LayerId": { + "description": "Stable identifier for a layer.", + "type": "string" + }, + "LayerPatch": { + "description": "Mutable layer fields.", + "properties": { + "locked": { + "description": "Replacement locked state.", + "type": [ + "boolean", + "null" + ] + }, + "name": { + "description": "Replacement display name.", + "type": [ + "string", + "null" + ] + }, + "opacity": { + "anyOf": [ + { + "$ref": "#/$defs/Opacity" + }, + { + "type": "null" + } + ], + "description": "Replacement inherited opacity." + }, + "visible": { + "description": "Replacement visibility.", + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "LayerRecord": { + "description": "Durable layer record and its ordered root-shape list.", + "properties": { + "id": { + "$ref": "#/$defs/LayerId", + "description": "Stable record identifier." + }, + "locked": { + "description": "Whether descendants can be selected or changed.", + "type": "boolean" + }, + "name": { + "description": "User-visible layer name.", + "type": "string" + }, + "opacity": { + "$ref": "#/$defs/Opacity", + "description": "Opacity inherited by descendants." + }, + "page_id": { + "$ref": "#/$defs/PageId", + "description": "Page that owns this layer." + }, + "shape_ids": { + "description": "Root shape IDs in back-to-front draw order.", + "items": { + "$ref": "#/$defs/ShapeId" + }, + "type": "array" + }, + "version": { + "$ref": "#/$defs/RecordVersion", + "description": "Version used by optimistic operation preconditions." + }, + "visible": { + "description": "Whether descendants participate in rendering and hit testing.", + "type": "boolean" + } + }, + "required": [ + "id", + "page_id", + "name", + "shape_ids", + "visible", + "locked", + "opacity", + "version" + ], + "type": "object" + }, + "LayoutAlignment": { + "description": "Cross-axis alignment for laid-out children.", + "oneOf": [ + { + "const": "start", + "description": "Align children to the start edge.", + "type": "string" + }, + { + "const": "center", + "description": "Center children on the cross axis.", + "type": "string" + }, + { + "const": "end", + "description": "Align children to the end edge.", + "type": "string" + }, + { + "const": "stretch", + "description": "Stretch children across the cross axis.", + "type": "string" + } + ] + }, + "LayoutAxis": { + "description": "Axis used by alignment and distribution operations.", + "oneOf": [ + { + "const": "horizontal", + "description": "Horizontal document axis.", + "type": "string" + }, + { + "const": "vertical", + "description": "Vertical document axis.", + "type": "string" + } + ] + }, + "Opacity": { + "description": "Opacity constrained to the inclusive range from zero to one.", + "format": "float", + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + }, + "Operation": { + "description": "Durable document operation. Ordered insertions use sibling anchors only.", + "oneOf": [ + { + "description": "Insert a page into the document's ordered page list.", + "properties": { + "anchor": { + "$ref": "#/$defs/SiblingAnchor", + "description": "Placement relative to an existing page." + }, + "page": { + "$ref": "#/$defs/PageRecord", + "description": "Complete new page record." + }, + "type": { + "const": "create_page", + "type": "string" + } + }, + "required": [ + "type", + "page", + "anchor" + ], + "type": "object" + }, + { + "description": "Rename a page.", + "properties": { + "expected_version": { + "anyOf": [ + { + "$ref": "#/$defs/RecordVersion" + }, + { + "type": "null" + } + ], + "description": "Optional optimistic record version." + }, + "name": { + "description": "Replacement name.", + "type": "string" + }, + "page_id": { + "$ref": "#/$defs/PageId", + "description": "Page to rename." + }, + "type": { + "const": "rename_page", + "type": "string" + } + }, + "required": [ + "type", + "page_id", + "name" + ], + "type": "object" + }, + { + "description": "Delete a page and its owned records.", + "properties": { + "expected_version": { + "anyOf": [ + { + "$ref": "#/$defs/RecordVersion" + }, + { + "type": "null" + } + ], + "description": "Optional optimistic record version." + }, + "page_id": { + "$ref": "#/$defs/PageId", + "description": "Page to delete." + }, + "type": { + "const": "delete_page", + "type": "string" + } + }, + "required": [ + "type", + "page_id" + ], + "type": "object" + }, + { + "description": "Insert a layer into a page's ordered layer list.", + "properties": { + "anchor": { + "$ref": "#/$defs/SiblingAnchor2", + "description": "Placement relative to an existing layer." + }, + "layer": { + "$ref": "#/$defs/LayerRecord", + "description": "Complete new layer record." + }, + "type": { + "const": "create_layer", + "type": "string" + } + }, + "required": [ + "type", + "layer", + "anchor" + ], + "type": "object" + }, + { + "description": "Change mutable layer fields.", + "properties": { + "expected_version": { + "anyOf": [ + { + "$ref": "#/$defs/RecordVersion" + }, + { + "type": "null" + } + ], + "description": "Optional optimistic record version." + }, + "layer_id": { + "$ref": "#/$defs/LayerId", + "description": "Layer to change." + }, + "patch": { + "$ref": "#/$defs/LayerPatch", + "description": "Requested field replacements." + }, + "type": { + "const": "patch_layer", + "type": "string" + } + }, + "required": [ + "type", + "layer_id", + "patch" + ], + "type": "object" + }, + { + "description": "Move a layer within its page.", + "properties": { + "anchor": { + "$ref": "#/$defs/SiblingAnchor2", + "description": "New placement relative to a sibling layer." + }, + "expected_version": { + "anyOf": [ + { + "$ref": "#/$defs/RecordVersion" + }, + { + "type": "null" + } + ], + "description": "Optional optimistic record version." + }, + "layer_id": { + "$ref": "#/$defs/LayerId", + "description": "Layer to move." + }, + "type": { + "const": "reorder_layer", + "type": "string" + } + }, + "required": [ + "type", + "layer_id", + "anchor" + ], + "type": "object" + }, + { + "description": "Delete a layer using an explicit child disposition.", + "properties": { + "contents": { + "$ref": "#/$defs/LayerContentsDisposition", + "description": "Required handling for existing shapes." + }, + "expected_version": { + "anyOf": [ + { + "$ref": "#/$defs/RecordVersion" + }, + { + "type": "null" + } + ], + "description": "Optional optimistic record version." + }, + "layer_id": { + "$ref": "#/$defs/LayerId", + "description": "Layer to delete." + }, + "type": { + "const": "delete_layer", + "type": "string" + } + }, + "required": [ + "type", + "layer_id", + "contents" + ], + "type": "object" + }, + { + "description": "Insert a shape into its parent's ordered child list.", + "properties": { + "anchor": { + "$ref": "#/$defs/SiblingAnchor3", + "description": "Placement relative to an existing shape child." + }, + "shape": { + "$ref": "#/$defs/ShapeRecord", + "description": "Complete new shape record." + }, + "type": { + "const": "create_shape", + "type": "string" + } + }, + "required": [ + "type", + "shape", + "anchor" + ], + "type": "object" + }, + { + "description": "Change mutable shape fields without moving it.", + "properties": { + "expected_version": { + "anyOf": [ + { + "$ref": "#/$defs/RecordVersion" + }, + { + "type": "null" + } + ], + "description": "Optional optimistic record version." + }, + "patch": { + "$ref": "#/$defs/ShapePatch", + "description": "Requested field replacements." + }, + "shape_id": { + "$ref": "#/$defs/ShapeId", + "description": "Shape to change." + }, + "type": { + "const": "patch_shape", + "type": "string" + } + }, + "required": [ + "type", + "shape_id", + "patch" + ], + "type": "object" + }, + { + "description": "Move a shape to another layer or container and place it by sibling anchor.", + "properties": { + "anchor": { + "$ref": "#/$defs/SiblingAnchor3", + "description": "Placement relative to a child of the replacement parent." + }, + "expected_version": { + "anyOf": [ + { + "$ref": "#/$defs/RecordVersion" + }, + { + "type": "null" + } + ], + "description": "Optional optimistic record version." + }, + "parent": { + "$ref": "#/$defs/ShapeParent", + "description": "Replacement parent." + }, + "shape_id": { + "$ref": "#/$defs/ShapeId", + "description": "Shape to move." + }, + "type": { + "const": "reparent_shape", + "type": "string" + } + }, + "required": [ + "type", + "shape_id", + "parent", + "anchor" + ], + "type": "object" + }, + { + "description": "Delete a shape and its owned descendants.", + "properties": { + "expected_version": { + "anyOf": [ + { + "$ref": "#/$defs/RecordVersion" + }, + { + "type": "null" + } + ], + "description": "Optional optimistic record version." + }, + "shape_id": { + "$ref": "#/$defs/ShapeId", + "description": "Shape to delete." + }, + "type": { + "const": "delete_shape", + "type": "string" + } + }, + "required": [ + "type", + "shape_id" + ], + "type": "object" + }, + { + "description": "Create a relationship between two shapes.", + "properties": { + "binding": { + "$ref": "#/$defs/BindingRecord", + "description": "Complete new binding record." + }, + "type": { + "const": "create_binding", + "type": "string" + } + }, + "required": [ + "type", + "binding" + ], + "type": "object" + }, + { + "description": "Delete a binding.", + "properties": { + "binding_id": { + "$ref": "#/$defs/BindingId", + "description": "Binding to delete." + }, + "expected_version": { + "anyOf": [ + { + "$ref": "#/$defs/RecordVersion" + }, + { + "type": "null" + } + ], + "description": "Optional optimistic record version." + }, + "type": { + "const": "delete_binding", + "type": "string" + } + }, + "required": [ + "type", + "binding_id" + ], + "type": "object" + }, + { + "description": "Add an asset to the document.", + "properties": { + "asset": { + "$ref": "#/$defs/AssetRecord", + "description": "Complete new asset record." + }, + "type": { + "const": "create_asset", + "type": "string" + } + }, + "required": [ + "type", + "asset" + ], + "type": "object" + }, + { + "description": "Change mutable asset metadata.", + "properties": { + "asset_id": { + "$ref": "#/$defs/AssetId", + "description": "Asset to change." + }, + "expected_version": { + "anyOf": [ + { + "$ref": "#/$defs/RecordVersion" + }, + { + "type": "null" + } + ], + "description": "Optional optimistic record version." + }, + "patch": { + "$ref": "#/$defs/AssetPatch", + "description": "Requested field replacements." + }, + "type": { + "const": "patch_asset", + "type": "string" + } + }, + "required": [ + "type", + "asset_id", + "patch" + ], + "type": "object" + }, + { + "description": "Delete an asset that is no longer referenced.", + "properties": { + "asset_id": { + "$ref": "#/$defs/AssetId", + "description": "Asset to delete." + }, + "expected_version": { + "anyOf": [ + { + "$ref": "#/$defs/RecordVersion" + }, + { + "type": "null" + } + ], + "description": "Optional optimistic record version." + }, + "type": { + "const": "delete_asset", + "type": "string" + } + }, + "required": [ + "type", + "asset_id" + ], + "type": "object" + }, + { + "description": "Align two or more shapes using their materialized bounds.", + "properties": { + "alignment": { + "$ref": "#/$defs/ShapeAlignment", + "description": "Alignment line shared by the shapes." + }, + "expected_versions": { + "additionalProperties": { + "$ref": "#/$defs/RecordVersion" + }, + "description": "Optional optimistic versions keyed by shape ID.", + "type": "object" + }, + "shape_ids": { + "description": "Shapes to align.", + "items": { + "$ref": "#/$defs/ShapeId" + }, + "type": "array" + }, + "type": { + "const": "align_shapes", + "type": "string" + } + }, + "required": [ + "type", + "shape_ids", + "alignment", + "expected_versions" + ], + "type": "object" + }, + { + "description": "Distribute three or more shapes with equal gaps between their bounds.", + "properties": { + "axis": { + "$ref": "#/$defs/LayoutAxis", + "description": "Axis on which to distribute the shapes." + }, + "expected_versions": { + "additionalProperties": { + "$ref": "#/$defs/RecordVersion" + }, + "description": "Optional optimistic versions keyed by shape ID.", + "type": "object" + }, + "shape_ids": { + "description": "Shapes to distribute.", + "items": { + "$ref": "#/$defs/ShapeId" + }, + "type": "array" + }, + "type": { + "const": "distribute_shapes", + "type": "string" + } + }, + "required": [ + "type", + "shape_ids", + "axis", + "expected_versions" + ], + "type": "object" + } + ] + }, + "Origin": { + "description": "Origin of a durable record or transaction.", + "oneOf": [ + { + "const": "human", + "description": "Direct edit made by a person.", + "type": "string" + }, + { + "const": "agent", + "description": "Edit proposed or applied by an agent.", + "type": "string" + }, + { + "const": "sync", + "description": "Change received from a trusted peer.", + "type": "string" + }, + { + "const": "system", + "description": "Deterministic repair or other internal change.", + "type": "string" + } + ] + }, + "PageId": { + "description": "Stable identifier for a page.", + "type": "string" + }, + "PageRecord": { + "description": "Durable page record and its ordered layer list.", + "properties": { + "id": { + "$ref": "#/$defs/PageId", + "description": "Stable record identifier." + }, + "layer_ids": { + "description": "Layer IDs in back-to-front draw order.", + "items": { + "$ref": "#/$defs/LayerId" + }, + "type": "array" + }, + "name": { + "description": "User-visible page name.", + "type": "string" + }, + "version": { + "$ref": "#/$defs/RecordVersion", + "description": "Version used by optimistic operation preconditions." + } + }, + "required": [ + "id", + "name", + "layer_ids", + "version" + ], + "type": "object" + }, + "Provenance": { + "description": "Attribution retained with durable content.", + "properties": { + "actor_id": { + "$ref": "#/$defs/ActorId", + "description": "Actor responsible for the record's current form." + }, + "origin": { + "$ref": "#/$defs/Origin", + "description": "Path by which the record entered the document." + }, + "source": { + "description": "Optional source identifier, such as an external reference or proposal ID.", + "type": [ + "string", + "null" + ] + }, + "timestamp": { + "$ref": "#/$defs/Timestamp", + "description": "Time at which this provenance entry was recorded." + } + }, + "required": [ + "actor_id", + "origin", + "timestamp" + ], + "type": "object" + }, + "RecordVersion": { + "description": "Monotonic version of a durable record within the document history.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "SemanticMetadata": { + "description": "Human- and agent-readable meaning attached to a shape.", + "properties": { + "agent_editable": { + "description": "Whether an agent may propose or apply edits to this shape.", + "type": "boolean" + }, + "description": { + "description": "Optional longer description.", + "type": [ + "string", + "null" + ] + }, + "locked": { + "description": "Whether direct edits to this shape are prohibited.", + "type": "boolean" + }, + "name": { + "description": "Optional display name.", + "type": [ + "string", + "null" + ] + }, + "provenance": { + "$ref": "#/$defs/Provenance", + "description": "Attribution for the record." + }, + "role": { + "description": "Optional semantic selector such as `architecture.service`.", + "type": [ + "string", + "null" + ] + }, + "tags": { + "description": "Searchable, user-defined tags.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "tags", + "locked", + "agent_editable", + "provenance" + ], + "type": "object" + }, + "ShapeAlignment": { + "description": "Edge or center line used to align a group of shapes.", + "oneOf": [ + { + "const": "left", + "description": "Align left bounds.", + "type": "string" + }, + { + "const": "center", + "description": "Align horizontal centers.", + "type": "string" + }, + { + "const": "right", + "description": "Align right bounds.", + "type": "string" + }, + { + "const": "top", + "description": "Align top bounds.", + "type": "string" + }, + { + "const": "middle", + "description": "Align vertical centers.", + "type": "string" + }, + { + "const": "bottom", + "description": "Align bottom bounds.", + "type": "string" + } + ] + }, + "ShapeId": { + "description": "Stable identifier for a shape.", + "type": "string" + }, + "ShapeKind": { + "description": "Registry key for a shape definition.", + "type": "string" + }, + "ShapeParent": { + "description": "Parent that owns a shape's sole draw-order entry.", + "oneOf": [ + { + "description": "The shape is a root child of a layer.", + "properties": { + "id": { + "$ref": "#/$defs/LayerId" + }, + "kind": { + "const": "layer", + "type": "string" + } + }, + "required": [ + "kind", + "id" + ], + "type": "object" + }, + { + "description": "The shape is a child of a container shape.", + "properties": { + "id": { + "$ref": "#/$defs/ShapeId" + }, + "kind": { + "const": "shape", + "type": "string" + } + }, + "required": [ + "kind", + "id" + ], + "type": "object" + } + ] + }, + "ShapePatch": { + "description": "Mutable shape fields. `Some(None)` clears an optional field.", + "properties": { + "layout": { + "anyOf": [ + { + "$ref": "#/$defs/ContainerLayout" + }, + { + "type": "null" + } + ], + "description": "Replacement or removal of container layout." + }, + "metadata": { + "anyOf": [ + { + "$ref": "#/$defs/SemanticMetadata" + }, + { + "type": "null" + } + ], + "description": "Replacement semantic metadata." + }, + "properties": { + "additionalProperties": true, + "description": "Replacement kind-specific properties.", + "type": [ + "object", + "null" + ] + }, + "style": { + "anyOf": [ + { + "$ref": "#/$defs/ShapeStyle" + }, + { + "type": "null" + } + ], + "description": "Replacement common style." + }, + "transform": { + "anyOf": [ + { + "$ref": "#/$defs/Transform" + }, + { + "type": "null" + } + ], + "description": "Replacement relative transform." + } + }, + "type": "object" + }, + "ShapeRecord": { + "description": "Durable shape record shared by all built-in shape definitions.", + "properties": { + "child_ids": { + "description": "Ordered children when this shape is a container.", + "items": { + "$ref": "#/$defs/ShapeId" + }, + "type": "array" + }, + "id": { + "$ref": "#/$defs/ShapeId", + "description": "Stable record identifier." + }, + "kind": { + "$ref": "#/$defs/ShapeKind", + "description": "Registry key. Built-in values are exposed as `*_KIND` constants." + }, + "layout": { + "anyOf": [ + { + "$ref": "#/$defs/ContainerLayout" + }, + { + "type": "null" + } + ], + "description": "Optional automatic layout for container shapes." + }, + "metadata": { + "$ref": "#/$defs/SemanticMetadata", + "description": "Human- and agent-readable semantics and permissions." + }, + "parent": { + "$ref": "#/$defs/ShapeParent", + "description": "Parent relation; ordering comes only from the parent's child list." + }, + "properties": { + "additionalProperties": true, + "description": "Kind-specific serialized properties validated by the registry.", + "type": "object" + }, + "style": { + "$ref": "#/$defs/ShapeStyle", + "description": "Visual properties common to all kinds." + }, + "transform": { + "$ref": "#/$defs/Transform", + "description": "Transform relative to `parent`." + }, + "version": { + "$ref": "#/$defs/RecordVersion", + "description": "Version used by optimistic operation preconditions." + } + }, + "required": [ + "id", + "kind", + "parent", + "transform", + "child_ids", + "properties", + "metadata", + "style", + "version" + ], + "type": "object" + }, + "ShapeStyle": { + "description": "Common visual style shared by all shape kinds.", + "properties": { + "fill_opacity": { + "anyOf": [ + { + "$ref": "#/$defs/Opacity" + }, + { + "type": "null" + } + ], + "description": "Optional opacity override for fills." + }, + "opacity": { + "$ref": "#/$defs/Opacity", + "description": "Opacity applied to the complete shape." + }, + "stroke_opacity": { + "anyOf": [ + { + "$ref": "#/$defs/Opacity" + }, + { + "type": "null" + } + ], + "description": "Optional opacity override for strokes." + } + }, + "required": [ + "opacity" + ], + "type": "object" + }, + "SiblingAnchor": { + "description": "Anchor used to place an item in an ordered child list without numeric indexes.", + "oneOf": [ + { + "description": "Place the item before every existing sibling.", + "properties": { + "position": { + "const": "first", + "type": "string" + } + }, + "required": [ + "position" + ], + "type": "object" + }, + { + "description": "Place the item after every existing sibling.", + "properties": { + "position": { + "const": "last", + "type": "string" + } + }, + "required": [ + "position" + ], + "type": "object" + }, + { + "description": "Place the item immediately before the identified sibling.", + "properties": { + "position": { + "const": "before", + "type": "string" + }, + "sibling_id": { + "$ref": "#/$defs/PageId" + } + }, + "required": [ + "position", + "sibling_id" + ], + "type": "object" + }, + { + "description": "Place the item immediately after the identified sibling.", + "properties": { + "position": { + "const": "after", + "type": "string" + }, + "sibling_id": { + "$ref": "#/$defs/PageId" + } + }, + "required": [ + "position", + "sibling_id" + ], + "type": "object" + } + ] + }, + "SiblingAnchor2": { + "description": "Anchor used to place an item in an ordered child list without numeric indexes.", + "oneOf": [ + { + "description": "Place the item before every existing sibling.", + "properties": { + "position": { + "const": "first", + "type": "string" + } + }, + "required": [ + "position" + ], + "type": "object" + }, + { + "description": "Place the item after every existing sibling.", + "properties": { + "position": { + "const": "last", + "type": "string" + } + }, + "required": [ + "position" + ], + "type": "object" + }, + { + "description": "Place the item immediately before the identified sibling.", + "properties": { + "position": { + "const": "before", + "type": "string" + }, + "sibling_id": { + "$ref": "#/$defs/LayerId" + } + }, + "required": [ + "position", + "sibling_id" + ], + "type": "object" + }, + { + "description": "Place the item immediately after the identified sibling.", + "properties": { + "position": { + "const": "after", + "type": "string" + }, + "sibling_id": { + "$ref": "#/$defs/LayerId" + } + }, + "required": [ + "position", + "sibling_id" + ], + "type": "object" + } + ] + }, + "SiblingAnchor3": { + "description": "Anchor used to place an item in an ordered child list without numeric indexes.", + "oneOf": [ + { + "description": "Place the item before every existing sibling.", + "properties": { + "position": { + "const": "first", + "type": "string" + } + }, + "required": [ + "position" + ], + "type": "object" + }, + { + "description": "Place the item after every existing sibling.", + "properties": { + "position": { + "const": "last", + "type": "string" + } + }, + "required": [ + "position" + ], + "type": "object" + }, + { + "description": "Place the item immediately before the identified sibling.", + "properties": { + "position": { + "const": "before", + "type": "string" + }, + "sibling_id": { + "$ref": "#/$defs/ShapeId" + } + }, + "required": [ + "position", + "sibling_id" + ], + "type": "object" + }, + { + "description": "Place the item immediately after the identified sibling.", + "properties": { + "position": { + "const": "after", + "type": "string" + }, + "sibling_id": { + "$ref": "#/$defs/ShapeId" + } + }, + "required": [ + "position", + "sibling_id" + ], + "type": "object" + } + ] + }, + "StackDirection": { + "description": "Stack direction for container layout.", + "oneOf": [ + { + "const": "horizontal", + "description": "Place children from left to right.", + "type": "string" + }, + { + "const": "vertical", + "description": "Place children from top to bottom.", + "type": "string" + } + ] + }, + "Timestamp": { + "description": "Milliseconds since the Unix epoch.", + "format": "int64", + "type": "integer" + }, + "TransactionId": { + "description": "Stable identifier for a transaction.", + "type": "string" + }, + "Transform": { + "description": "Transform relative to a shape's parent container or layer.", + "properties": { + "rotation": { + "description": "Clockwise rotation in radians.", + "format": "double", + "type": "number" + }, + "scale_x": { + "description": "Horizontal scale.", + "format": "double", + "type": "number" + }, + "scale_y": { + "description": "Vertical scale.", + "format": "double", + "type": "number" + }, + "translation": { + "$ref": "#/$defs/Vec2", + "description": "Translation in parent coordinates." + } + }, + "required": [ + "translation", + "rotation", + "scale_x", + "scale_y" + ], + "type": "object" + }, + "Vec2": { + "description": "Two-dimensional point or vector in document coordinates.", + "properties": { + "x": { + "description": "Horizontal component.", + "format": "double", + "type": "number" + }, + "y": { + "description": "Vertical component.", + "format": "double", + "type": "number" + } + }, + "required": [ + "x", + "y" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "One durable edit submitted to the transaction engine.", + "properties": { + "actor_id": { + "$ref": "#/$defs/ActorId", + "description": "Actor responsible for the transaction." + }, + "base_heads": { + "description": "Causal heads inspected by the caller.", + "items": { + "$ref": "#/$defs/ChangeHash" + }, + "type": "array" + }, + "description": { + "description": "Human-readable explanation retained in history.", + "type": "string" + }, + "id": { + "$ref": "#/$defs/TransactionId", + "description": "Stable transaction identifier." + }, + "operations": { + "description": "Ordered operations committed as one CRDT change.", + "items": { + "$ref": "#/$defs/Operation" + }, + "type": "array" + }, + "origin": { + "$ref": "#/$defs/Origin", + "description": "Path by which the transaction entered the engine." + }, + "timestamp": { + "$ref": "#/$defs/Timestamp", + "description": "Client-recorded transaction time." + } + }, + "required": [ + "id", + "actor_id", + "origin", + "base_heads", + "description", + "operations", + "timestamp" + ], + "title": "TransactionDraft", + "type": "object" }