Compare commits

..
Author SHA1 Message Date
sorokin.oleg.edu e80708677e fix(desktop): invalidate filtered catalog on changed hosted head
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
2026-09-06 08:14:39 +03:00
sorokin.oleg.edu 12e3020c97 fix(desktop): identify stale hosted catalog results 2026-09-06 08:07:10 +03:00
sorokin.oleg.edu b04c4b37f4 fix(desktop): detach hosted destination for invalid local source 2026-09-06 08:01:44 +03:00
sorokin.oleg.edu 798b277748 Document hosted desktop workflows and local save behavior 2026-09-06 07:48:14 +03:00
sorokin.oleg.edu 8a75294be2 Allow review of explicitly mapped existing OA Legacy ports 2026-09-06 07:39:04 +03:00
sorokin.oleg.edu 43370de3ac Normalize constructor review members without losing local history 2026-09-06 07:35:14 +03:00
sorokin.oleg.edu f6a59de85b Record verified hosted Windows trial and reproducible checks 2026-09-06 07:22:45 +03:00
sorokin.oleg.edu 6fcc238c52 Polish hosted request controls and record full desktop checks 2026-09-06 07:19:26 +03:00
sorokin.oleg.edu 53a509553d Add optional hosted desktop editing with conditional source saves 2026-09-06 07:16:55 +03:00
sorokin.oleg.edu 1086e54ecc Record integrated semantics and native authoring verification 2026-09-06 06:57:20 +03:00
sorokin.oleg.edu f4cb486243 Record cross-platform behavior scroll identity verification 2026-09-06 06:55:50 +03:00
sorokin.oleg.edu 8c600087dc Bind behavior scroll state to its document and owner 2026-09-06 06:51:59 +03:00
sorokin.oleg.edu 1b0b072c4b Record process camera regression gates and native retest binaries 2026-09-06 06:45:03 +03:00
sorokin.oleg.edu cf337851ff Preserve process camera and viewport while opening authoring tools 2026-09-06 06:41:57 +03:00
sorokin.oleg.edu 767634aea5 Record scalar review correction checks and frozen platform binaries 2026-09-06 06:32:45 +03:00
sorokin.oleg.edu e7fa061e23 Fix scoped semantic drafts and dependent review controls 2026-09-06 06:27:25 +03:00
sorokin.oleg.edu d114a5d40c Record integrated semantic Windows and REST contract verification 2026-09-06 06:16:34 +03:00
sorokin.oleg.edu c6db9d41d6 Keep integrated HTTP regression modules rustfmt clean 2026-09-06 06:14:09 +03:00
sorokin.oleg.edu 332e46bb9e Integrate semantic review core source API and scalar desktop workflow 2026-09-06 06:10:14 +03:00
sorokin.oleg.edu 6ad3cb92df Clarify grouped constructor retirement scope 2026-09-06 06:08:43 +03:00
sorokin.oleg.edu 036276813c Add typed scalar semantic review to desktop workflow 2026-09-06 06:07:39 +03:00
sorokin.oleg.edu 6bb2583b4a Allow first review of equal constructor discriminators 2026-09-06 06:01:48 +03:00
sorokin.oleg.edu 1a07475cfc Record combined behavior checks and current native evidence 2026-09-06 05:53:45 +03:00
sorokin.oleg.edu d130013155 feat(server): expose typed semantic transition review 2026-09-06 05:46:51 +03:00
sorokin.oleg.edu 60d162c259 Merge commit '1a3164b' into codex/data-contract-integration 2026-09-06 05:45:58 +03:00
sorokin.oleg.edu ae1602ad7a Preserve scenario viewport when editing a diagram message 2026-09-06 05:43:56 +03:00
sorokin.oleg.edu 1a3164bf16 Record exchange continuation review fix and updated freeze
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
2026-09-06 05:43:48 +03:00
sorokin.oleg.edu 8bc564b4b2 Fix exchange-only process continuation with explicit append path 2026-09-06 05:42:01 +03:00
sorokin.oleg.edu dfc14bcd1f Record direct process editing verification and native gates 2026-09-06 05:35:47 +03:00
sorokin.oleg.edu 29df024a7f Clarify applicable semantic review replay guarantees 2026-09-06 05:34:35 +03:00
sorokin.oleg.edu 7a1e12bc7c Add direct process and chain occurrence editing 2026-09-06 05:33:42 +03:00
sorokin.oleg.edu c6c07cc3a3 Improve scenario tool feedback and keep draft actions visible 2026-09-06 05:32:34 +03:00
sorokin.oleg.edu 02f99a9ac5 Specify source five typed semantic review contract 2026-09-06 05:29:55 +03:00
sorokin.oleg.edu c746ab63c9 test(server): expect schema five from current exports 2026-09-06 05:28:25 +03:00
sorokin.oleg.edu 346fb24692 Implement closed typed semantic review with immutable proposals 2026-09-06 05:20:54 +03:00
sorokin.oleg.edu a306d6b2a8 Plan direct process and chain editing milestone 2026-09-06 05:12:38 +03:00
sorokin.oleg.edu b2d713e774 Record native property browser acceptance and remaining runtime gates 2026-09-06 05:10:47 +03:00
sorokin.oleg.edu ab960cbebb feat(source): persist typed semantic reviews in schema five 2026-09-06 04:46:41 +03:00
sorokin.oleg.edu ab02b1b255 Freeze closed typed R3 semantic review implementation contract 2026-09-06 04:25:28 +03:00
103 changed files with 26463 additions and 1078 deletions
-73
View File
@@ -1,73 +0,0 @@
# Agent working agreement
## Working method
- Follow `PROJECT_GOAL.md` and the user's current instructions. Work autonomously within scope. Resolve discoverable questions yourself; record, test, and revise reversible assumptions. Ask only when a consequential decision needs information or authorization not already available. Continue independent useful work while blocked elsewhere.
- Map unfamiliar domains through primary sources, comparable systems, and real user workflows. Identify underlying needs, valuable concepts, and avoidable friction; evaluate improvements rather than merely reproducing features. Synthesize findings into decisions or experiments, and stop research when another action offers greater value.
- Make specifications precise enough for compatible independent implementations: definitions, relationships, invariants, valid/invalid examples, and lifecycle or transformation rules where relevant. Trace important requirements and decisions to implementation and verification. Resolve specification ambiguity at its source when implementation exposes it.
- For consequential uncertain choices, compare materially different candidates against explicit user-derived criteria. Ask independent critics to challenge assumptions and attempt to falsify proposals; refine or replace candidates as evidence warrants. Record the choice, evidence, tradeoffs, rejected approaches, and reasons to revisit it. Use proportionate effort for reversible local choices.
- Plan milestones that resolve uncertainty or deliver useful capability, with explicit dependencies. Test foundational assumptions before substantial downstream work. At checkpoints, assess progress toward the actual objective and replan when evidence invalidates the strategy or progress stalls.
- Define observable success and suitable evaluators before repeated improvement: invariants, examples, round trips, transformations, performance checks, realistic user tasks, or explicit review rubrics. Ensure the evaluator measures the intended outcome. For experiments, record the question, expectations, method, result, and implications; isolate exploratory prototypes from production architecture.
- Build and run meaningful changes, exercise actual behavior, and inspect useful logs. Investigate crashes, serious warnings, corrupted state, and interaction defects. For GUI work, use Computer Use or equivalent tools to inspect realistic workflows, cognitive load, editing/selection, navigation, feedback, recovery, loading/empty/failure states, accessibility, layout, focus/disabled states, and responsiveness.
- Match independent reviews to the artifact and risks. For languages or formats, include ambiguity, round trips, human/AI editability, and version-control behavior where relevant. Verify findings, fix the appropriate conceptual, specification, architectural, implementation, or interaction layer, and recheck affected dependents. Reuse valid evidence; repeat checks for concrete risks, changes, findings, or required gates. Compilation, screenshots, and worker reports alone do not establish correctness or usability.
- Favor the simplest maintainable design satisfying known requirements and justified extensibility. Avoid speculative abstraction, unrelated refactoring, and repeating approaches already invalidated by evidence.
## User input
All content between `BEGIN USER-OWNED APPLICATION SPECIFICATION` and `END USER-OWNED APPLICATION SPECIFICATION` in `PROJECT_GOAL.md` belongs to the user. Its headings and structure are unrestricted. Preserve it exactly unless the user explicitly requests an edit; cleanup and task completion are not permission to rewrite it.
Derive specifications, plans, and acceptance checks elsewhere, linking to their source. Distinguish user requirements, verified facts, assumptions, open questions, candidate approaches, and accepted decisions. Interpretations must not silently replace the user's intent. When input changes, reconcile affected work. Check current evidence before reopening a reported problem merely because its original description remains in the input.
## Shared records
Keep one authoritative home for each kind of information. Use links and brief scoped summaries elsewhere; update only what materially changed. Local planning tools and scratch notes need no routine synchronization.
| Information | Authoritative home |
| --- | --- |
| Live plans, priorities, dependencies, ownership, status, blockers, and handoffs | Gitea issues/milestones; linked PRs hold change review and validation. |
| Specifications, architecture, interfaces, and build/run documentation | Established version-controlled project documents. |
| Durable decisions and reusable evidence | `harness/DECISIONS.md` and relevant topic notes, or existing equivalents. |
| Navigation and integrated-state checkpoint | `harness/STATE.md`: links, verified code commit/release, capabilities, and material limitations. No live task ledger. |
| Shared entrypoint | Gitea Wiki: overview and links, without duplicating versioned documentation. |
| Published deliverables | Gitea releases, tied to source commits and verification. |
Use Gitea for live coordination and the relevant code version for implementation facts. Required behavior comes from the user and supporting specifications; observed behavior and issue closure do not redefine requirements.
## Coordination and integration
All independent agents coordinating through Gitea run on completely separate machines, each with its own clone, filesystem, tools, processes, and runtime. Gitea and pushed Git history are the shared communication and delivery channels. Local files (including `harness`), unpushed commits, worktrees, test results, and localhost services are not accessible to peers. Matching branch names or paths do not imply synchronized contents.
1. On starting or resuming, including after compaction, inspect the working branch, local changes, applicable instructions, relevant shared records, and existing implementation. Recover context from authoritative records. Check for newer shared instructions on the actual remote default branch without overwriting local work. Read what is relevant; avoid repeated full-history scans and constant polling.
2. Search before creating tasks. Claim a meaningful outcome in its Gitea issue with a stable session identity, scope, remote repository/branch, and acceptance criteria. Keep small steps within that task. Recheck after claiming; comments are not atomic locks. Respect existing ownership and resolve overlapping claims before overlapping work.
3. Independent sessions are peers. Each owns its contribution and delegated work on its own machine. Record one integration/release owner when a milestone needs shared coordination. Use distinct remote work branches, partition files/interfaces, and coordinate changes to shared specifications and documents. Worktrees provide local isolation only; they do not share work between machines.
4. At meaningful checkpoints or handoffs, record changed results, commit/PR links, verification commands and environment, blockers, and next actions. Identify the tested code version and label uncommitted or unpublished work as local-only. Before asking peers to consume results, push the required code and versioned documentation, including relevant `harness` updates; publish other required artifacts in Gitea and link them. Use repository-relative paths with commit references, reproducible commands, and necessary environment details; local paths and localhost URLs alone are not handoffs. Do not assume another agent is running or has read a message; silence alone does not establish abandonment. Record evidence and preserve existing work when reassigning a task.
5. Follow project review and merge conventions. Fetch the relevant remote branches and inspect the identified commits before reviewing or integrating another machine's work. Resolve conflicts while preserving others' edits, and verify the combined result locally; report material environment differences. Local instruction-file edits reach peers only after publication and retrieval. Do not overwrite others' work or force-push shared history to resolve coordination problems.
6. Close work when its acceptance criteria are met and the result is integrated or delivered as appropriate. Mark duplicates and superseded tasks explicitly with replacement links. Then choose further useful unowned work while the requested objective remains incomplete.
Use Gitea MCP for shared coordination. If unavailable, report the failure, buffer necessary handoff information locally, and continue already-owned or clearly non-overlapping work. Reconcile with current Gitea state when access returns; do not claim an ownership update, review, or publication succeeded without confirmation.
## Delegation and reasoning
Use GPT-6 Astra exclusively. Gitea peers are independent remote sessions, not subagents controlled by this session. Spawned subagents use the execution environment actually provided by the agent tools; do not assume they share files, and publish their results before Gitea peers need them. Delegate bounded independent research, design, implementation, or review when it improves progress, judgment, or preserves the parent's context. Give subagents the relevant context, ownership boundaries, result format, and evidence requirements. Use fresh critics when practical. The parent owns synthesis and integration and normally consolidates results into Gitea. Choose concurrency according to useful independent work and available resources, including other sessions; there is no agent-count quota.
Optimize reasoning effort for verified outcomes per cost, including review, retries, and repair:
- Main agent: default to high for planning, synthesis, integration, and acceptance decisions; medium for dispatching a settled plan and routine coordination.
- Subagents: default to medium for bounded implementation and targeted research; low for straightforward retrieval, mechanical edits, and running established checks; high for complex debugging, correctness review, adversarial analysis, or ambiguous design.
- Use extra-high selectively for difficult foundational semantic, methodological, formal, or cross-domain decisions. Reserve max for exceptional unresolved problems where additional reasoning is likely to justify its cost. Retain higher settings when evidence supports their value.
Allocate by task difficulty and consequences, not role or length alone. Start higher when a clearly difficult decision has expensive downstream consequences. Escalate for concrete reasoning difficulties; resolve missing evidence or unclear requirements at their source. Set subagent effort explicitly through supported controls to avoid unintended inheritance. Do not claim an unapplied change or alter global configuration merely to enforce these preferences.
## Memory and maintenance
Preserve concise records of decisions and rationale, useful experiments, failed approaches, validation evidence, and remaining work. Link substantial evidence rather than copying logs into indexes. Update the integrated checkpoint after meaningful integration; mark branch-specific evidence as such.
Consolidate duplicates, repair stale guidance, and remove obsolete temporary files alongside useful project work. Before removing material, check references, active ownership, and ongoing use; preserve unique knowledge and update affected links. Prefer closing, superseding, or archiving history over deletion. User input is excluded from autonomous cleanup.
If legacy local task ledgers or plans duplicate Gitea, migrate their live content once into existing or appropriately scoped issues, preserve useful decisions/evidence, and mark the old records superseded. Record the migration so later sessions do not repeat it. Do not create empty documents or maintain records for ceremony.
## Delivery
Publish versioned Gitea releases at meaningful working milestones; mark unfinished versions as prereleases. Check existing releases/tags and coordinate one owner and version before publishing. Build from an identified integrated commit and verify the packaged result and launch steps.
Include runnable builds or appropriate artifacts, concise release notes, build/run/deployment instructions as applicable, known limitations, tested platforms, and verification evidence. Confirm publication and artifact availability. Use existing authorized API access or project tooling when MCP lacks a needed release/upload operation. Do not silently replace published versions with different contents.
Generated
+6
View File
@@ -2155,10 +2155,16 @@ dependencies = [
name = "oa-desktop"
version = "0.2.0"
dependencies = [
"axum",
"iced",
"iced_runtime",
"oa-core",
"oa-server",
"reqwest",
"rfd",
"serde",
"serde_json",
"tempfile",
"tokio",
]
-111
View File
@@ -1,111 +0,0 @@
# Project goal
## Objective
Take the existing project to the complete, coherent, usable, and verified outcome defined by the user input below. Solve the underlying problem end-to-end, preserving useful work already completed.
Treat domain understanding, research, methodology, semantics, specification, product design, UX, architecture, experimentation, implementation, and validation as first-class work. Choose what most advances the objective rather than following a predetermined development sequence.
Follow `AGENTS.md` for execution, coordination, record ownership, and delivery. Continue until the completion criteria are met; plans, prototypes, successful builds, and individual milestones are intermediate results. Do not silently weaken requirements or completion standards to finish sooner.
## Completion criteria
Apply these criteria to the scope requested by the user:
- Required capabilities address the intended problem and support realistic user workflows.
- Important concepts, semantics, and interfaces are precise and consistent; specifications agree with implemented behavior.
- Consequential decisions have evidence and rationale. Major assumptions have been validated, explicitly accepted, or isolated without compromising required behavior.
- The result builds, runs, and operates on the required platforms. Relevant tests and evaluators pass; integrations, edge cases, and failure states have been exercised against the actual result.
- User-facing behavior and usability have been exercised directly, using representative data and workflows.
- A fresh independent review covers requirements, domain/semantic consistency, usability, architecture, correctness, regressions, runtime behavior, documentation, and remaining risks. Valid material findings are addressed, blocking and high-impact issues resolved, and affected behavior reverified; no known blocker or high-severity defect remains.
- Deliverables are maintainable, appropriately extensible, documented, and published as required by `AGENTS.md`. Obsolete scaffolding is removed; documentation and shared state accurately describe the result. Another engineer or agent can reproduce the verification and continue the work.
If required work or verification is blocked, record the specific gap and continue independent useful work without claiming completion.
## Final handoff
Link the delivered result and summarize capabilities, discoveries, architecture and decisions, relevant rejected approaches, verification and independent review, remaining limitations, and exact build/run/deployment/verification instructions. Refer to authoritative records instead of repeating the project history.
## USER-OWNED APPLICATION SPECIFICATION
The block below accepts all project-specific requirements, context, priorities, and feedback, with any headings or organization the user chooses.
<!-- BEGIN USER-OWNED APPLICATION SPECIFICATION -->
## Application specification
### General description
Create a modern clone of ARCADIA Capella using Rust Iced framework. It should be cross platform (supporting Windows and Linux at least). Use Rust exclusively (wherever possible & practical).
Focus on making the application modern, extensible, intuitive and easy to use. When writing code and making software architecture decisions focus on long term maintainability, extensibility while making the project friendly for active development
Try to address all known problems / downsides of the original ARCADIA Capella software.
Version 1 MVP in the form of working Operational Analysis layer is done.
Version 2 MVP in the form of working Operation Analysis + System Analysis is done.
You should make the 1st release version of the project with fully feature complete Operation Analysis + System Architecture, according to this application specification.
Remaining ARCADIA layers will be added in future releases.
Transitioning of model elements between layers (in both directions) should be easy and intuitive.
Keep in mind that the target audience for the application are system engineers. And you should satisfy their needs and use cases
The application should be a general systems modeling tool, like ARCADIA Capella or SysML v2
You should implement most of the ARCADIA semantics, while adressing original ARCADIA Capella problems / downsides
The original ARCADIA Capella UI is very bloated, clunky, unintuitive and hard to use. In addition, it lacks a number of quality of life features. You should at all cost avoid making the same mistakes as the developers of the original ARCADIA Capella
You should explore the functionality & UI of the original ARCADIA Capella to take the most useful and powerful ideas from it. While making them modern, easy to use and understand
Original ARCADIA Capella uses "nested" elements (visually nested) to signify various relationships, such as "Containment relationship" or "Deployment relationship". Instead of using visually noisy arrows / graph edges. I pretty much like this approach and want to see something similar to it in this application
To explore the original ARCADIA Capella UI & semantics you may use web resources / web search / official and community docs. Or open the original ARCADIA Capella installed on this desktop machine for exploration
Your second, parallel, goal is to create a textual representation for ARCADIA, similar to SysML v2. So a user can either edit a system model using UI (graphical representation) or text. Both graphical and textual representations should be fully equivalent and have full parity. You may take inspiration from SysML v2. Both graphical and textual representations should have one overarching, concrete, unambiguous spec in reST format, similar to SysML v2 strictness
The application should support both forms of editing
The textual representation should be easily editable and understandble for both humans and AI
The textual representation should be easily handled by version control tools such as Git
The underlying semantics of the textual / graphical representation should support traceability of various development artifacts (for example - code, CAD files, electrical schematics, Docx files and anything in between)
The textual representation editor should have syntax highlighting and linting
The project should also have a backend server / service that you can deploy. The backend server purpose is to give programmatic access to stored system models. So, there also should be a clear REST API for querying, editing and working with models. The purpose of the REST API is to allow automation, scripting and AI-driven workflows
An example could be - writing custom plugins for various domain specific software (version control software, CAD software etc...) to establish traceability between development artifacts and a system model
The desktop application should contain built-in guide / help / tutorial with clear and concise info on modeling concepts, underlying semantics, common modeling scenarios and application features / how to use the app. It should be easy to navigate and explore
@Computer
### Semantic model / metamodel
Requirements:
- Textual notation.
- Graphical notation.
- Constraints and calculations.
- Quantities and units.
- State and behavior modeling.
- Reusable model libraries.
- Strong, standardized specification.
- Strong, standardized API.
#### Textual notation
Requirements:
- Should not have visual noise and clutter.
- Should have means to reduce cognitive load when developing system models.
- Should be easily editable by humans. And AI agents.
- System engineers should be able to easily develop system models (both big and small) using textual notation only.
### UI
The focus should be on reducing system engineer's cognitive load when creating / working with a system model. Regardless of the model size (big or small).
Long, homogeneous lists in the UI that require user to scroll far or search for a desired option for too long - is a bad design.
## Active issues / current guidance
Right now, the behavior & scenarios UI is hard to use. It contains static / uneditable diagrams, which is highly dubious. In addition, user workflow with the behavior & scenarios UI differ significantly from the other UIs - you can't edit diagrams and instead suppose to click on menus for model development
---
The current project state is nowhere near the usability of the original ARCADIA Capella (despite its downfalls) or its functionality / semantics. You should focus on that by studying original ARCADIA Capella very closely and implementing its features in a modern, ergonomic and easy to use way
<!-- END USER-OWNED APPLICATION SPECIFICATION -->
+9 -5
View File
@@ -9,7 +9,7 @@ The normative [language and editing contract](docs/language.rst) defines support
Install stable Rust and your platform's native compiler. The workspace declares Rust 1.90; the recorded development builds use Rust 1.98.1. Windows requires Visual Studio Build Tools with **Desktop development with C++**. Ubuntu/Debian development dependencies are:
```sh
sudo apt-get install build-essential pkg-config libxkbcommon-dev libwayland-dev libvulkan-dev libx11-dev libxrandr-dev libxi-dev
sudo apt-get install build-essential pkg-config libxkbcommon-dev libxkbcommon-x11-0 libwayland-dev libvulkan-dev libx11-dev libxrandr-dev libxi-dev
```
From the repository root:
@@ -102,7 +102,7 @@ Reparenting a nested element through Properties moves its declaration, descendan
## Save and reopen
Ctrl+S saves, Ctrl+Shift+S saves as, and Ctrl+O opens. An `.arcadia` suffix selects text; other suffixes select JSON. Opening detects content rather than relying on the filename. Windows uses native dialogs; Linux uses a path-entry dialog with overwrite confirmation and no portal requirement.
Ctrl+S saves to the current local or hosted destination, Ctrl+Shift+S opens Save as, and Ctrl+O opens a local file. An `.arcadia` suffix selects text; other suffixes select JSON. Opening detects content rather than relying on the filename. Windows uses native dialogs; Linux uses a path-entry dialog with overwrite confirmation and no portal requirement.
**Save all** validates a snapshot and saves the source bundle, comparing previously read dependency bytes to reject detected external changes. Each file is replaced atomically; a staged transaction journal allows explicit recovery of interrupted multi-file saves. This is not an atomic multi-file filesystem operation or distributed locking. If recovery is needed, **Recover interrupted save** appears in Textual model. Dirty New/Open/Close operations offer save/discard/cancel. There is no periodic autosave of unsaved edits. Share or commit the entry file and all included source files together.
@@ -118,14 +118,18 @@ The default is `127.0.0.1:8787`, with a platform-local data directory. `--data-d
The API queries and edits complete models, canonical source, multi-file source projects, elements, relationships, artifacts and traces. Transactions and transition previews use the same core validation and source writer as the desktop. Writes require the returned ETag in `If-Match`. One revision protects the accepted model and source bundle together, including comment-only edits. The server retains the current revision only.
Desktop files and hosted resources transfer explicitly; the desktop has no direct server connection. Use **Save as** to export `system.json`, import it, then edit using the API. The model interchange endpoint accepts a complete JSON model or standalone canonical source:
Use **Hosted models...** in the desktop top bar to connect, **Publish as new** to upload the current authored source project, or select a catalog entry and choose **Open selected**. Diagrams, properties and text share the same local editing history. **Save to server** / Ctrl+S explicitly sends valid changes; invalid drafts and failed writes retain your work. A revision conflict preserves local work and checks the server for review, with read-only source files and an explicit, undoable **Reload server version**. **Save as local...** switches back to a local destination after successfully saving an `.arcadia` source project; JSON export keeps the hosted destination. See the [hosted desktop walkthrough](docs/server.md#edit-hosted-models-in-the-desktop) for conflict recovery and connection setup.
The desktop keeps bearer credentials in memory and accepts HTTPS, with HTTP allowed only on localhost/loopback. Hosted editing does not require a cloud account and does not provide automatic synchronization or merges.
Scripts can also import a complete JSON model or standalone canonical source through the model interchange endpoint:
```sh
curl --fail-with-body -X POST http://127.0.0.1:8787/api/v1/projects/import \
-H 'Content-Type: application/json' --data-binary @system.json
```
The response supplies the service `resource_id` and ETag. After API changes, export `/api/v1/projects/<resource-id>/source` to a new `.arcadia` file and open it in the desktop. This preserves model UUIDs, aliases, diagrams, traces and artifact metadata. Importing creates a separate hosted resource. Artifact URIs and revisions are opaque references for code, CAD, schematics, documents and other evidence; the application does not fetch or execute them.
The response supplies the service `resource_id` and ETag. Refresh the desktop catalog to open that resource; after API changes to an open model, use **Check server** to review them. Alternatively, export `/api/v1/projects/<resource-id>/source` to a new `.arcadia` file for local interchange. This preserves model UUIDs, aliases, diagrams, traces and artifact metadata. Importing creates a separate hosted resource. Artifact URIs and revisions are opaque references for code, CAD, schematics, documents and other evidence; the application does not fetch or execute them.
To preserve authored files and comments, use `POST /api/v1/projects/import-source-project` with a JSON bundle containing `entry` and a `files` map of relative paths to source strings. `GET` and conditional `PUT /api/v1/projects/<resource-id>/source-project` exchange that complete accepted bundle. Include resolution uses only the submitted files; the server never opens the client's disk paths. See the [REST contract](docs/api.rst) for payloads, located diagnostics and admission limits.
@@ -142,4 +146,4 @@ cargo build --release --workspace --locked
- `crates/oa-desktop`: Iced state/drafts, typed forms, architecture/behavior views, source highlighting and file workflows.
- `crates/oa-server`: REST transport, conditional writes, bounded queries and atomic local store.
The [architecture guide](docs/architecture.md) explains extension boundaries. Windows/Linux CI is configured; remote CI was not run in this environment. LA/PA, simulation, native Capella interchange, complete SysML/Capella conformance, direct hosted editing, semantic merge tooling and production-scale collaboration remain outside this prototype.
The [architecture guide](docs/architecture.md) explains extension boundaries. Windows/Linux CI is configured; remote CI was not run in this environment. LA/PA, simulation, native Capella interchange, complete SysML/Capella conformance, semantic merge tooling and production-scale collaboration remain outside this prototype.
@@ -507,6 +507,7 @@ fn workshop() -> Project {
let baseline = TraceBaseline {
oa: p.properties(t.oa).unwrap(),
sa: p.properties(t.sa).unwrap(),
semantic: None,
};
p.traces.get_mut(&id).unwrap().baseline = Some(baseline);
}
+1
View File
@@ -111,6 +111,7 @@ fn trace(p: &mut Project, oa: Id, sa: Id, kind: TraceKind) {
baseline: Some(TraceBaseline {
oa: p.properties(oa).unwrap(),
sa: p.properties(sa).unwrap(),
semantic: None,
}),
},
);
+67 -10
View File
@@ -1,4 +1,6 @@
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3 migration.
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3/V4 migration.
#[path = "language/semantic_source.rs"]
mod semantic_source;
#[path = "migration_v2/source.rs"]
mod v2_source;
use crate::*;
@@ -387,7 +389,7 @@ fn enum_field(key: &str) -> bool {
)
}
/// Parse V4, or explicitly migrate a valid V1/V2/V3 source candidate. Parsing never
/// Parse V5, or explicitly migrate a valid V1/V2/V3/V4 source candidate. Parsing never
/// adopts a model and never overwrites an editor draft.
pub fn parse(source: &str) -> Result<Project, Vec<Diagnostic>> {
parse_limited(source, ParseLimits::UNLIMITED)
@@ -552,7 +554,7 @@ fn offset_for(source: &str, line: usize, column: usize) -> usize {
/// nested records are ordinary language fields; JSON documents are not embedded.
pub fn format(project: &Project) -> String {
let mut out = std::format!(
"arcadia 4;\nproject {} {} id \"{}\" {{\n description {};\n\n",
"arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n description {};\n\n",
project.alias,
quote(&project.name),
project.id,
@@ -743,6 +745,10 @@ fn write_value(
indent: usize,
aliases: &BTreeMap<Id, String>,
) {
if key == "semantic" {
semantic_source::write(out, value, indent);
return;
}
match value {
Value::Null => out.push_str("none"),
Value::Bool(v) => out.push_str(if *v { "true" } else { "false" }),
@@ -786,6 +792,7 @@ enum Ast {
Literal(Value, usize),
Word(String, usize),
Reference(String, usize),
HistoricalUuid(Id, usize),
Array(Vec<Ast>, usize),
Object(BTreeMap<String, Ast>, usize),
}
@@ -795,12 +802,20 @@ impl Ast {
Self::Literal(_, p)
| Self::Word(_, p)
| Self::Reference(_, p)
| Self::HistoricalUuid(_, p)
| Self::Array(_, p)
| Self::Object(_, p) => *p,
}
}
fn resolve(&self, key: &str, aliases: &BTreeMap<String, Id>) -> Result<Value, Error> {
match self {
Self::HistoricalUuid(_, _) => Err(Error {
code: "invalid_source",
offset: self.offset(),
message:
"A uuid literal is allowed only in a typed historical semantic reference slot."
.into(),
}),
Self::Word(word, offset) => {
if !enum_field(key) {
return Err(Error {
@@ -968,6 +983,7 @@ pub(crate) struct EditValue {
pub(crate) enum EditKind {
Atom,
Reference(String),
HistoricalUuid(Id),
Record(BTreeMap<String, EditField>),
List(Vec<EditValue>),
}
@@ -1080,7 +1096,7 @@ fn edit_positions(
let span = Span {
start: ast.offset(),
end: match ast {
Ast::Reference(..) => self.tokens[index + 1].span.end,
Ast::Reference(..) | Ast::HistoricalUuid(..) => self.tokens[index + 1].span.end,
_ => self.ends[index],
},
};
@@ -1090,6 +1106,7 @@ fn edit_positions(
EditKind::List(items.iter().map(|item| self.value(item)).collect())
}
Ast::Reference(alias, _) => EditKind::Reference(alias.clone()),
Ast::HistoricalUuid(id, _) => EditKind::HistoricalUuid(*id),
_ => EditKind::Atom,
};
EditValue { span, kind }
@@ -1270,6 +1287,16 @@ impl Parser<'_> {
let token = self.current();
let offset = token.span.start;
match self.text() {
"uuid" => {
self.cursor += 1;
let value = self.quoted()?;
let id = Id::parse_str(&value).map_err(|_| Error {
code: "invalid_source",
offset,
message: "Expected a UUID after uuid.".into(),
})?;
Ok(Ast::HistoricalUuid(id, offset))
}
"@" => {
self.cursor += 1;
Ok(Ast::Reference(self.identifier()?, offset))
@@ -1356,11 +1383,11 @@ impl Parser<'_> {
fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result<SourceUnit, Error> {
let mut header = if entry {
self.take("arcadia")?;
if !matches!(self.text(), "3" | "4") {
if !matches!(self.text(), "3" | "4" | "5") {
return Err(self
.error("Unsupported schema version: expected 'arcadia 3;' or 'arcadia 4;'."));
.error("Unsupported schema version: expected 'arcadia 3;', 'arcadia 4;' or 'arcadia 5;'."));
}
let schema_version = if self.text() == "3" { 3 } else { 4 };
let schema_version = self.text().parse().expect("checked source version");
self.cursor += 1;
self.take(";")?;
let start = self.current().span.start;
@@ -1557,7 +1584,12 @@ pub(crate) fn resolve_declarations(
let mut fields = declaration
.fields
.iter()
.map(|(k, v)| Ok((k.clone(), v.resolve(k, aliases)?)))
.map(|(k, v)| {
let value = if matches!(declaration.kind, DeclKind::Trace) && k == "baseline" {
semantic_source::resolve_baseline(v, aliases, project.schema_version)?
} else { v.resolve(k, aliases)? };
Ok((k.clone(), value))
})
.collect::<Result<Map<_, _>, Error>>()?;
let d = declaration;
let conversion_error = |message: String| Error {
@@ -1729,8 +1761,12 @@ pub(crate) fn resolve_declarations(
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
let trace = serde_json::from_value(Value::Object(record))
.map_err(|e| conversion_error(e.to_string()))?;
let trace = if project.schema_version < 5 {
let old: crate::persistence::v4::Trace4 = serde_json::from_value(Value::Object(record)).map_err(|e| conversion_error(e.to_string()))?;
Trace::from(old)
} else {
serde_json::from_value(Value::Object(record)).map_err(|e| conversion_error(e.to_string()))?
};
project.traces.insert(d.id, trace);
}
}
@@ -1801,6 +1837,22 @@ pub(crate) fn resolve_declarations(
}
return Ok(migrated);
}
if project.schema_version == 4 {
return crate::persistence::v4::finish(project, limits).map_err(|error| {
(
usize::MAX,
Error {
code: if matches!(error, crate::persistence::FileError::Limit(_)) {
"model_limit"
} else {
"migration_error"
},
offset: 0,
message: error.to_string(),
},
)
});
}
limits.check_model(&project).map_err(|message| {
(
usize::MAX,
@@ -2595,3 +2647,8 @@ pub(crate) mod legacy {
}
}
}
/// Closed historical facet identity for the targeted source writer.
pub(crate) fn semantic_row_key(source: &str, origins: &BTreeMap<Id, Id>) -> Option<String> {
semantic_source::row_key(source, origins)
}
@@ -0,0 +1,203 @@
//! Typed historical source leaves; live alias resolution remains in the parent.
use super::*;
use crate::semantic::{HistoricalSourceKind, SemanticBaseline, historical_source_slots};
fn error(ast: &Ast, message: impl Into<String>) -> Error {
Error {
code: "invalid_source",
offset: ast.offset(),
message: message.into(),
}
}
fn raw(ast: &Ast) -> Result<Value, Error> {
Ok(match ast {
Ast::Literal(value, _) => value.clone(),
Ast::Word(value, _) => Value::String(value.clone()),
Ast::HistoricalUuid(value, _) => Value::String(value.to_string()),
Ast::Reference(_, _) => {
return Err(error(
ast,
"Historical semantic references require uuid \"...\", not @alias.",
));
}
Ast::Array(values, _) => Value::Array(values.iter().map(raw).collect::<Result<_, _>>()?),
Ast::Object(fields, _) => Value::Object(
fields
.iter()
.map(|(key, value)| Ok((key.clone(), raw(value)?)))
.collect::<Result<_, Error>>()?,
),
})
}
fn validate_tokens(
ast: &Ast,
path: &mut Vec<String>,
slots: &BTreeMap<Vec<String>, HistoricalSourceKind>,
) -> Result<(), Error> {
match ast {
Ast::Object(fields, _) => {
for (key, value) in fields {
path.push(key.clone());
validate_tokens(value, path, slots)?;
path.pop();
}
}
Ast::Array(values, _) => {
for (index, value) in values.iter().enumerate() {
path.push(index.to_string());
validate_tokens(value, path, slots)?;
path.pop();
}
}
_ => match (slots.get(path), ast) {
(Some(HistoricalSourceKind::Reference), Ast::HistoricalUuid(..))
| (Some(HistoricalSourceKind::Enum), Ast::Word(..)) => {}
(Some(HistoricalSourceKind::Reference), _) => {
return Err(error(
ast,
"This historical reference requires uuid \"...\".",
));
}
(Some(HistoricalSourceKind::Enum), _) => {
return Err(error(
ast,
"This typed historical enum requires an unquoted enum word.",
));
}
(None, Ast::HistoricalUuid(..)) => {
return Err(error(
ast,
"A uuid literal is allowed only in a typed historical reference slot.",
));
}
(None, Ast::Word(..)) => {
return Err(error(
ast,
"This historical text value requires a quoted string.",
));
}
_ => {}
},
}
Ok(())
}
pub(super) fn resolve_baseline(
ast: &Ast,
aliases: &BTreeMap<String, Id>,
version: u32,
) -> Result<Value, Error> {
let Ast::Object(fields, _) = ast else {
return ast.resolve("baseline", aliases);
};
let mut resolved = Map::new();
for (key, value) in fields {
if key != "semantic" {
resolved.insert(key.clone(), value.resolve(key, aliases)?);
continue;
}
if version < 5 {
return Err(error(
value,
"Semantic trace baselines require schema/source5; the older baseline contains prose only.",
));
}
if matches!(value, Ast::Literal(Value::Null, _)) {
resolved.insert(key.clone(), Value::Null);
continue;
}
let json = raw(value)?;
let baseline: SemanticBaseline = serde_json::from_value(json.clone())
.map_err(|cause| error(value, format!("Invalid semantic baseline: {cause}")))?;
let slots = historical_source_slots(&baseline).into_iter().collect();
validate_tokens(value, &mut vec![], &slots)?;
resolved.insert(key.clone(), json);
}
Ok(Value::Object(resolved))
}
pub(super) fn write(out: &mut String, value: &Value, indent: usize) {
if value.is_null() {
out.push_str("none");
return;
}
let baseline: SemanticBaseline =
serde_json::from_value(value.clone()).expect("typed semantic baseline");
let slots = historical_source_slots(&baseline).into_iter().collect();
write_at(out, value, indent, &mut vec![], &slots);
}
fn write_at(
out: &mut String,
value: &Value,
indent: usize,
path: &mut Vec<String>,
slots: &BTreeMap<Vec<String>, HistoricalSourceKind>,
) {
match value {
Value::Null => out.push_str("none"),
Value::Bool(value) => out.push_str(if *value { "true" } else { "false" }),
Value::Number(value) => out.push_str(&value.to_string()),
Value::String(value) => match slots.get(path) {
Some(HistoricalSourceKind::Reference) => {
out.push_str("uuid ");
out.push_str(&quote(value));
}
Some(HistoricalSourceKind::Enum) => out.push_str(value),
None => out.push_str(&quote(value)),
},
Value::Array(values) => {
out.push('[');
for (index, value) in values.iter().enumerate() {
if index != 0 {
out.push_str(", ");
}
path.push(index.to_string());
write_at(out, value, indent, path, slots);
path.pop();
}
out.push(']');
}
Value::Object(fields) => {
out.push_str("{\n");
for (key, value) in fields {
out.push_str(&" ".repeat(indent + 1));
out.push_str(key);
out.push(' ');
path.push(key.clone());
write_at(out, value, indent + 1, path, slots);
path.pop();
out.push_str(";\n");
}
out.push_str(&" ".repeat(indent));
out.push('}');
}
}
}
/// Recognize only complete typed facet rows. Their closed selector, with real
/// identity provenance applied, owns comments when review rows reorder.
pub(super) fn row_key(source: &str, origins: &BTreeMap<Id, Id>) -> Option<String> {
let mut parser = Parser {
source,
tokens: strict_tokens(source).ok()?,
cursor: 0,
value_depth: 0,
limits: ParseLimits::UNLIMITED,
};
let ast = parser.value().ok()?;
let facet = serde_json::from_value(raw(&ast).ok()?).ok()?;
let mut baseline = SemanticBaseline {
facets: vec![facet],
};
crate::semantic::visit_semantic_references(&mut baseline, |id| {
if let Some(original) = origins.get(id) {
*id = *original;
}
});
serde_json::to_string(&baseline.facets[0].selector())
.ok()
.map(|key| format!("semantic:{key}"))
}
+3 -3
View File
@@ -3,7 +3,7 @@ use crate::source_project::{ProjectLimits, SourceBundle, compile_bundle, lint_bu
fn project_source(body: &str) -> String {
format!(
"arcadia 4; project model \"Model\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}"
"arcadia 5; project model \"Model\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}"
)
}
@@ -177,7 +177,7 @@ fn nested_fragment_id_stabilization_preserves_comments_and_indexes_real_bytes()
files: BTreeMap::from([
(
"project.arcadia".into(),
"arcadia 4; project model \"Model\" { include \"children.arcadia\"; }".into(),
"arcadia 5; project model \"Model\" { include \"children.arcadia\"; }".into(),
),
(
"children.arcadia".into(),
@@ -300,7 +300,7 @@ fn deep_declaration_parsing_and_positions_use_a_bounded_rust_stack() {
#[test]
fn tolerant_nested_analysis_retains_closed_children_before_an_unfinished_sibling() {
let source = "arcadia 4; project model \"航海\" {\noa_activity outer_one \"Outer\" {\n oa_activity ready \"Ready\" { description \"} is text\"; }\n oa_activity unfinished \"Unfinished\" { description \"unterminated";
let source = "arcadia 5; project model \"航海\" {\noa_activity outer_one \"Outer\" {\n oa_activity ready \"Ready\" { description \"} is text\"; }\n oa_activity unfinished \"Unfinished\" { description \"unterminated";
let analysis = analyze(source);
let ready = analysis
.declarations
+2
View File
@@ -7,6 +7,7 @@ pub mod ports;
pub mod projection;
pub mod references;
pub mod routing;
pub mod semantic;
pub mod session;
pub mod source_files;
pub mod source_project;
@@ -14,6 +15,7 @@ pub mod transitions;
pub mod validation;
pub use model::*;
pub use ports::*;
pub use semantic::*;
pub use session::*;
mod demo;
mod state_validation;
+3 -1
View File
@@ -13,7 +13,9 @@ generic port/optional FE endpoint contracts therefore remain explicit.
`adapter.rs` budgets generated data, validates V2 and transforms only the changed
port/endpoint payloads into an explicit schema 3 candidate. The schema 3 reader
then performs the data-contract upgrade and validates schema 4. Serialization
then performs the data-contract upgrade into the strict schema 4 envelope. The
schema 4 review adapter preserves its prose-only trace baselines and returns
schema 5 with semantic review absent. Serialization
between compatibility DTOs copies unchanged fields; original input bytes are
never deserialized into the new `Project`. V1 normalization also constructs this
V2 DTO before the shared stages. Combined generation stays within one budget.
+5 -3
View File
@@ -24,7 +24,7 @@ pub(crate) struct Project3 {
#[serde(deserialize_with = "crate::unique_id_map")]
pub artifact_links: BTreeMap<Id, ArtifactLink>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub traces: BTreeMap<Id, Trace>,
pub traces: BTreeMap<Id, crate::persistence::v4::Trace4>,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
@@ -486,8 +486,10 @@ pub(crate) fn migrate(old: Project3, limits: ParseLimits) -> Result<DecodeResult
));
}
}
value["schema_version"] = SCHEMA_VERSION.into();
let mut project: Project = serde_json::from_value(value)?;
value["schema_version"] = 4.into();
let normalized: crate::persistence::v4::Project4 = serde_json::from_value(value)?;
let mut project = normalized.into_current()?;
crate::persistence::v4::review_defaults(&project, &mut report);
let mut references = project
.elements
.values()
+117
View File
@@ -0,0 +1,117 @@
//! Frozen schema4 review envelopes. Other model records are unchanged in schema5.
use crate::language::ParseLimits;
use crate::persistence::{DecodeResult, FileError, MigrationReport, SemanticDefault};
use crate::*;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct TraceBaseline4 {
pub oa: TracedProperties,
pub sa: TracedProperties,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Trace4 {
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
pub oa: Id,
pub sa: Id,
pub kind: TraceKind,
pub baseline: Option<TraceBaseline4>,
}
impl From<Trace4> for Trace {
fn from(old: Trace4) -> Self {
Self {
id: old.id,
alias: old.alias,
name: old.name,
description: old.description,
oa: old.oa,
sa: old.sa,
kind: old.kind,
baseline: old.baseline.map(|baseline| TraceBaseline {
oa: baseline.oa,
sa: baseline.sa,
semantic: None,
}),
}
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Project4 {
pub schema_version: u32,
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
#[serde(deserialize_with = "crate::unique_id_map")]
pub elements: BTreeMap<Id, Element>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub relations: BTreeMap<Id, Relation>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub diagrams: BTreeMap<Id, Diagram>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub artifact_links: BTreeMap<Id, ArtifactLink>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub traces: BTreeMap<Id, Trace4>,
}
impl Project4 {
pub(crate) fn into_current(self) -> Result<Project, FileError> {
if self.schema_version != 4 {
return Err(FileError::Invalid("Expected schema_version 4.".into()));
}
Ok(Project {
schema_version: SCHEMA_VERSION,
id: self.id,
alias: self.alias,
name: self.name,
description: self.description,
elements: self.elements,
relations: self.relations,
diagrams: self.diagrams,
artifact_links: self.artifact_links,
traces: self
.traces
.into_iter()
.map(|(id, trace)| (id, trace.into()))
.collect(),
})
}
}
pub(crate) fn review_defaults(project: &Project, report: &mut MigrationReport) {
report.semantic_defaults.extend(
project
.traces
.values()
.filter(|trace| trace.baseline.is_some())
.map(|trace| SemanticDefault {
object: trace.id,
field: "baseline.semantic".into(),
description: "Original prose review preserved; semantic facets remain unreviewed."
.into(),
}),
);
}
pub(crate) fn finish(mut project: Project, limits: ParseLimits) -> Result<DecodeResult, FileError> {
project.schema_version = SCHEMA_VERSION;
limits.check_model(&project).map_err(FileError::Limit)?;
let mut report = MigrationReport::data_upgrade(4);
review_defaults(&project, &mut report);
Ok(DecodeResult {
project,
migration: Some(report),
})
}
pub(crate) fn migrate(old: Project4, limits: ParseLimits) -> Result<DecodeResult, FileError> {
finish(old.into_current()?, limits)
}
+5 -3
View File
@@ -6,7 +6,7 @@ use std::{
use uuid::Uuid;
pub type Id = Uuid;
pub const SCHEMA_VERSION: u32 = 4;
pub const SCHEMA_VERSION: u32 = 5;
pub const MAX_COORDINATE: f32 = 1_000_000.;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@@ -1492,13 +1492,15 @@ pub struct TracedProperties {
pub name: String,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TraceBaseline {
pub oa: TracedProperties,
pub sa: TracedProperties,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub semantic: Option<crate::semantic::SemanticBaseline>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Trace {
pub id: Id,
+5 -2
View File
@@ -7,6 +7,8 @@ pub(crate) mod v2;
pub(crate) use v2::adapter::migrate as migrate_v2_limited;
#[path = "migration_v3/mod.rs"]
pub(crate) mod v3;
#[path = "migration_v4.rs"]
pub(crate) mod v4;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -148,13 +150,14 @@ pub fn decode_with_report_limited(
}
Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?,
Some(3) => v3::migrate(serde_json::from_slice(bytes)?, limits)?,
Some(4) => DecodeResult {
Some(4) => v4::migrate(serde_json::from_slice(bytes)?, limits)?,
Some(5) => DecodeResult {
project: serde_json::from_slice(bytes)?,
migration: None,
},
_ => {
return Err(FileError::Invalid(
"Unsupported or missing schema_version; expected 1, 2, 3 or 4.".into(),
"Unsupported or missing schema_version; expected 1, 2, 3, 4 or 5.".into(),
));
}
}
+7
View File
@@ -222,6 +222,13 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
if t.sa == old {
t.sa = new;
}
if let Some(baseline) = t.baseline.as_mut().and_then(|b| b.semantic.as_mut()) {
crate::semantic::visit_semantic_references(baseline, |id| {
if *id == old {
*id = new;
}
});
}
}
}
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
+16
View File
@@ -0,0 +1,16 @@
//! Closed typed facts for deliberately reviewed OA/SA semantics.
//!
//! Historical facts are independently acknowledged observations, not a synthetic
//! live model. Their UUIDs are not live references and survive ordinary deletion.
mod catalog;
mod historical;
mod linked;
mod mutate;
mod review;
mod types;
pub use catalog::*;
pub(crate) use historical::{HistoricalSourceKind, historical_source_slots};
pub use historical::{validate_semantic_baseline, visit_semantic_references};
pub use linked::*;
pub use review::*;
pub use types::*;
File diff suppressed because it is too large Load Diff
+404
View File
@@ -0,0 +1,404 @@
use super::types::*;
use crate::model::*;
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HistoricalSourceKind {
Reference,
Enum,
}
const MAX_FACTS: usize = 16_384;
const MAX_LEAVES: usize = 1_000_000;
const MAX_PATH: usize = 128;
struct Walk<'a> {
path: Vec<String>,
slots: Vec<(Vec<String>, HistoricalSourceKind)>,
errors: Vec<String>,
leaves: usize,
visitor: &'a mut dyn FnMut(&mut Id),
}
impl Walk<'_> {
fn at(&mut self, key: impl ToString, f: impl FnOnce(&mut Self)) {
if self.leaves >= MAX_LEAVES {
return;
}
self.leaves += 1;
self.path.push(key.to_string());
f(self);
self.path.pop();
}
fn slot(&mut self, kind: HistoricalSourceKind) {
self.slots.push((self.path.clone(), kind));
}
fn error(&mut self, message: &str) {
self.errors
.push(format!("{}: {message}", self.path.join(".")));
}
}
trait Historical {
fn walk(&mut self, w: &mut Walk<'_>);
}
impl Historical for Id {
fn walk(&mut self, w: &mut Walk<'_>) {
w.slot(HistoricalSourceKind::Reference);
(w.visitor)(self);
}
}
macro_rules! plain {
($($ty:ty),*) => { $( impl Historical for $ty { fn walk(&mut self, _: &mut Walk<'_>) {} } )* };
}
plain!(bool, u32, i64);
impl Historical for String {
fn walk(&mut self, w: &mut Walk<'_>) {
if !crate::validation::valid_text(self) {
w.error("historical text contains an invalid character");
}
if self.len() > 1_000_000 {
w.error("historical text work limit exceeded");
}
}
}
impl Historical for f64 {
fn walk(&mut self, w: &mut Walk<'_>) {
if !self.is_finite() {
w.error("historical real must be finite");
}
}
}
macro_rules! enums {
($($ty:ty),*) => { $( impl Historical for $ty {
fn walk(&mut self, w: &mut Walk<'_>) { w.slot(HistoricalSourceKind::Enum); }
} )* };
}
enums!(
ExchangeItemKind,
ExchangeMechanism,
ParameterDirection,
FieldStorage,
ComponentPortKind,
ComponentPortOrientation,
PortDirection,
ScenarioViewpoint,
ControlKind,
FragmentOperator,
TimeEventKind,
StateKind,
TransmissionProtocol,
AcquisitionProtocol,
TransitionKind,
StateActionKind,
ArtifactRelation,
ReferenceCollection,
OrderedCollection,
IncidentEnd,
DataConstructor,
PortConstructor,
OccurrenceConstructor,
EventConstructor,
MessageConstructor
);
impl<T: Historical> Historical for Option<T> {
fn walk(&mut self, w: &mut Walk<'_>) {
if let Some(v) = self {
v.walk(w);
}
}
}
impl<T: Historical> Historical for Vec<T> {
fn walk(&mut self, w: &mut Walk<'_>) {
for (i, v) in self.iter_mut().enumerate().take(MAX_LEAVES) {
w.at(i, |w| v.walk(w));
}
if self.len() > MAX_LEAVES {
w.error("historical collection work limit exceeded");
}
}
}
impl<T: Historical> Historical for Observed<T> {
fn walk(&mut self, w: &mut Walk<'_>) {
match self {
Self::NotApplicable => w.slot(HistoricalSourceKind::Enum),
Self::Value(v) => w.at("Value", |w| v.walk(w)),
}
}
}
impl<T: Historical> Historical for Reviewed<T> {
fn walk(&mut self, w: &mut Walk<'_>) {
if matches!(
(&self.oa, &self.sa),
(Observed::NotApplicable, Observed::NotApplicable)
) {
w.error("a reviewed facet must apply on at least one side");
}
w.at("oa", |w| self.oa.walk(w));
w.at("sa", |w| self.sa.walk(w));
}
}
impl<T: Historical> Historical for KeyPair<T> {
fn walk(&mut self, w: &mut Walk<'_>) {
if self.oa.is_none() && self.sa.is_none() {
w.error("key pair cannot be empty on both sides");
}
w.at("oa", |w| self.oa.walk(w));
w.at("sa", |w| self.sa.walk(w));
}
}
impl Historical for AnnotationKey {
fn walk(&mut self, w: &mut Walk<'_>) {
if self.path.len() > MAX_PATH {
w.error("historical annotation path limit exceeded");
}
w.at("path", |w| self.path.walk(w));
w.at("field", |w| self.field.walk(w));
}
}
impl Historical for MessageValue {
fn walk(&mut self, w: &mut Walk<'_>) {
if self.path.len() > MAX_PATH {
w.error("historical annotation path limit exceeded");
}
w.at("path", |w| self.path.walk(w));
w.at("field", |w| self.field.walk(w));
w.at("expression", |w| self.expression.walk(w));
}
}
impl Historical for LifelineBinding {
fn walk(&mut self, w: &mut Walk<'_>) {
w.at("referenced", |w| self.referenced.walk(w));
w.at("local", |w| self.local.walk(w));
}
}
impl Historical for DataValue {
fn walk(&mut self, w: &mut Walk<'_>) {
match self {
Self::Real(value) => w.at("Real", |w| value.walk(w)),
Self::String(value) => w.at("String", |w| value.walk(w)),
Self::Enumeration(value) => w.at("Enumeration", |w| value.walk(w)),
Self::Boolean(_) | Self::Integer(_) => {}
}
}
}
impl Historical for TypeRef {
fn walk(&mut self, w: &mut Walk<'_>) {
match self {
Self::Named(id) => w.at("Named", |w| id.walk(w)),
_ => w.slot(HistoricalSourceKind::Enum),
}
}
}
impl Historical for ArtifactReview {
fn walk(&mut self, w: &mut Walk<'_>) {
match self {
Self::Presence(v) => w.at("Presence", |w| v.walk(w)),
Self::Name(v) => w.at("Name", |w| v.walk(w)),
Self::Description(v) => w.at("Description", |w| v.walk(w)),
Self::Owner(v) => w.at("Owner", |w| v.walk(w)),
Self::Relation(v) => w.at("Relation", |w| v.walk(w)),
Self::Uri(v) => w.at("Uri", |w| v.walk(w)),
Self::Revision(v) => w.at("Revision", |w| v.walk(w)),
Self::Fragment(v) => w.at("Fragment", |w| v.walk(w)),
}
}
}
macro_rules! family_visitor {
($( $family:ident, $selector:ident, $review:ident { $( $leaf:ident: $ty:ty ),* } )*) => {
impl Historical for FacetReview {
fn walk(&mut self, w: &mut Walk<'_>) {
match self {
$( Self::$family(v) => w.at(stringify!($family), |w| match v {
$( $review::$leaf(v) => w.at(stringify!($leaf), |w| v.walk(w)), )*
}), )*
Self::Member { collection, keys, review } => w.at("Member", |w| {
w.at("collection", |w| collection.walk(w)); w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
}),
Self::Order { collection, review } => w.at("Order", |w| {
w.at("collection", |w| collection.walk(w)); w.at("review", |w| review.walk(w));
for side in [&review.oa, &review.sa] { if let Observed::Value(order) = side {
if order.iter().collect::<BTreeSet<_>>().len() != order.len() { w.error("duplicate historical order member"); }
}}
}),
Self::Literal { keys, review } => w.at("Literal", |w| {
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
}),
Self::Annotation { keys, review } => w.at("Annotation", |w| {
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
for (key, value) in [(&keys.oa, &review.oa), (&keys.sa, &review.sa)] {
if let Observed::Value(Some(row)) = value {
if key.as_ref() != Some(&AnnotationKey::from(row)) { w.error("historical annotation key and payload disagree"); }
}
}
}),
Self::Binding { keys, review } => w.at("Binding", |w| {
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
for (key, value) in [(&keys.oa, &review.oa), (&keys.sa, &review.sa)] {
if let Observed::Value(Some(row)) = value {
if *key != Some(row.referenced) { w.error("historical binding key and payload disagree"); }
}
}
}),
Self::Child { keys, review } => w.at("Child", |w| {
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
}),
Self::Incident { keys, end, review } => w.at("Incident", |w| {
w.at("keys", |w| keys.walk(w)); w.at("end", |w| end.walk(w)); w.at("review", |w| review.walk(w));
}),
Self::Artifact { keys, review } => w.at("Artifact", |w| {
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
}),
}
}
}
};
}
scalar_families!(family_visitor);
fn walk_baseline<'a>(
baseline: &mut SemanticBaseline,
visitor: &'a mut dyn FnMut(&mut Id),
) -> Walk<'a> {
let mut walk = Walk {
path: vec!["facets".into()],
slots: vec![],
errors: vec![],
leaves: 0,
visitor,
};
if baseline.facets.len() > MAX_FACTS {
walk.error("historical facet count limit exceeded");
}
for (i, facet) in baseline.facets.iter_mut().enumerate().take(MAX_FACTS) {
walk.at(i, |w| facet.walk(w));
}
if walk.leaves >= MAX_LEAVES {
walk.error("historical reference work limit exceeded");
}
walk
}
/// Historical UUIDs are visited only on explicit identity remapping, never deletion.
pub fn visit_semantic_references(
baseline: &mut SemanticBaseline,
mut visitor: impl FnMut(&mut Id),
) {
walk_baseline(baseline, &mut visitor);
}
pub(crate) fn historical_source_slots(
baseline: &SemanticBaseline,
) -> Vec<(Vec<String>, HistoricalSourceKind)> {
walk_baseline(&mut baseline.clone(), &mut |_| {}).slots
}
/// Checks persisted facts without demanding that historical references still exist.
pub fn validate_semantic_baseline(baseline: &SemanticBaseline) -> Result<(), Vec<String>> {
let mut errors = walk_baseline(&mut baseline.clone(), &mut |_| {}).errors;
let mut slots = BTreeSet::new();
for selector in baseline
.facets
.iter()
.take(MAX_FACTS)
.map(FacetReview::selector)
{
if selector_slots(&selector)
.into_iter()
.any(|slot| !slots.insert(slot))
{
errors.push(format!(
"duplicate or overlapping historical facet: {selector:?}"
));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
pub(super) fn selector_slots(selector: &FacetSelector) -> Vec<FacetSelector> {
fn split<T: Clone>(keys: &KeyPair<T>) -> Vec<KeyPair<T>> {
let mut result = vec![];
if let Some(oa) = &keys.oa {
result.push(KeyPair {
oa: Some(oa.clone()),
sa: None,
});
}
if let Some(sa) = &keys.sa {
result.push(KeyPair {
oa: None,
sa: Some(sa.clone()),
});
}
result
}
match selector {
FacetSelector::Member { collection, keys } => split(keys)
.into_iter()
.map(|keys| FacetSelector::Member {
collection: *collection,
keys,
})
.collect(),
FacetSelector::Literal(keys) => split(keys)
.into_iter()
.map(FacetSelector::Literal)
.collect(),
FacetSelector::Annotation(keys) => split(keys)
.into_iter()
.map(FacetSelector::Annotation)
.collect(),
FacetSelector::Binding(keys) => split(keys)
.into_iter()
.map(FacetSelector::Binding)
.collect(),
FacetSelector::Child(keys) => split(keys).into_iter().map(FacetSelector::Child).collect(),
FacetSelector::Incident { keys, end } => split(keys)
.into_iter()
.map(|keys| FacetSelector::Incident { keys, end: *end })
.collect(),
FacetSelector::Artifact { keys, facet } => split(keys)
.into_iter()
.map(|keys| FacetSelector::Artifact {
keys,
facet: *facet,
})
.collect(),
value => vec![value.clone()],
}
}
fn pair_overlap<T: Eq>(a: &KeyPair<T>, b: &KeyPair<T>) -> bool {
a.oa.as_ref()
.zip(b.oa.as_ref())
.is_some_and(|(a, b)| a == b)
|| a.sa
.as_ref()
.zip(b.sa.as_ref())
.is_some_and(|(a, b)| a == b)
}
/// Slot identity is each actual side's key, not equality of the complete pair.
pub(crate) fn keyed_slots_overlap(a: &FacetSelector, b: &FacetSelector) -> bool {
match (a, b) {
(
FacetSelector::Member {
collection: a,
keys: ak,
},
FacetSelector::Member {
collection: b,
keys: bk,
},
) => a == b && pair_overlap(ak, bk),
(FacetSelector::Literal(a), FacetSelector::Literal(b)) => pair_overlap(a, b),
(FacetSelector::Annotation(a), FacetSelector::Annotation(b)) => pair_overlap(a, b),
(FacetSelector::Binding(a), FacetSelector::Binding(b)) => pair_overlap(a, b),
(FacetSelector::Child(a), FacetSelector::Child(b)) => pair_overlap(a, b),
(
FacetSelector::Incident { keys: ak, end: a },
FacetSelector::Incident { keys: bk, end: b },
) => a == b && pair_overlap(ak, bk),
(
FacetSelector::Artifact { keys: ak, facet: a },
FacetSelector::Artifact { keys: bk, facet: b },
) => a == b && pair_overlap(ak, bk),
_ => a == b,
}
}
+174
View File
@@ -0,0 +1,174 @@
use super::*;
use crate::model::*;
use crate::transitions::Drift;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, VecDeque};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LinkedSemanticImpact {
pub objects: KeyPair<Id>,
pub traces: Vec<Id>,
pub drift: Drift,
pub unreviewed: bool,
}
const MAX_LINKED_OBJECTS: usize = 4_096;
const MAX_LINKED_EDGES: usize = 32_768;
/// Derived dependency diagnostics, never another historical baseline. Follow
/// direct references/children, then only reusable dictionary/event definitions.
/// Association endpoints and ordinary architecture neighborhoods do not expand.
pub fn linked_semantic_impacts(
project: &Project,
trace: &Trace,
references: &BTreeMap<Id, Id>,
) -> Result<Vec<LinkedSemanticImpact>, String> {
let mut children: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
for element in project.elements.values() {
if let Some(parent) = element.parent {
children.entry(parent).or_default().push(element.id);
}
}
let mut contracts: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
for relation in project.relations.values() {
if let RelationBody::InterfaceItemAllocation { source, .. } = relation.body {
contracts.entry(source).or_default().push(relation.id);
}
}
let direct = |id| -> Vec<Id> {
let mut out = vec![];
if let Some(element) = project.elements.get(&id) {
out.extend(
crate::references::element_references(element)
.into_iter()
.filter(|r| Some(*r) != element.parent),
);
}
if let Some(relation) = project.relations.get(&id) {
out.extend(crate::references::relation_references(relation));
}
out.extend(children.get(&id).into_iter().flatten().copied());
out.extend(contracts.get(&id).into_iter().flatten().copied());
out
};
let expandable = |id| {
project.elements.get(&id).is_some_and(|e| {
matches!(
e.kind(),
Kind::DataType
| Kind::DataField
| Kind::ExchangeItem
| Kind::Interface
| Kind::Event
)
}) || project
.relations
.get(&id)
.is_some_and(|r| matches!(r.body, RelationBody::InterfaceItemAllocation { .. }))
};
let mut edges = 0usize;
let mut traverse = |root| -> Result<BTreeSet<Id>, String> {
let mut seen = BTreeSet::from([root]);
let first = direct(root);
edges += first.len();
if edges > MAX_LINKED_EDGES {
return Err(
"Linked semantic dependency work limit exceeded; narrow the review scope".into(),
);
}
let mut queue: VecDeque<_> = first.into();
while let Some(id) = queue.pop_front() {
if !seen.insert(id) {
continue;
}
if seen.len() > MAX_LINKED_OBJECTS || edges > MAX_LINKED_EDGES {
return Err(
"Linked semantic dependency work limit exceeded; narrow the review scope"
.into(),
);
}
if expandable(id) {
let next = direct(id);
edges += next.len();
if edges > MAX_LINKED_EDGES {
return Err(
"Linked semantic dependency work limit exceeded; narrow the review scope"
.into(),
);
}
queue.extend(next);
}
}
seen.remove(&root);
Ok(seen)
};
let oa = traverse(trace.oa)?;
let sa = traverse(trace.sa)?;
let mut traced: BTreeMap<(Id, Id), Vec<&Trace>> = BTreeMap::new();
for t in project.traces.values() {
traced.entry((t.oa, t.sa)).or_default().push(t);
}
let mut pairs = vec![];
let mut used = BTreeSet::new();
for id in oa {
let target = references.get(&id).copied();
if let Some(id) = target {
used.insert(id);
}
pairs.push(KeyPair {
oa: Some(id),
sa: target,
});
}
for id in sa.difference(&used) {
pairs.push(KeyPair {
oa: None,
sa: Some(*id),
});
}
let mut result = vec![];
for objects in pairs {
if objects.oa == Some(trace.oa) && objects.sa == Some(trace.sa) {
continue;
}
let links = objects
.oa
.zip(objects.sa)
.and_then(|pair| traced.get(&pair));
let mut flags = (false, false, links.is_none());
let mut ids = vec![];
for linked in links.into_iter().flatten() {
ids.push(linked.id);
let facts = super::catalog::own_semantic_facets(project, linked, references)?;
if facts.is_empty() {
flags.2 = true;
}
for fact in facts {
match fact.drift {
Drift::OaChanged => flags.0 = true,
Drift::SaChanged => flags.1 = true,
Drift::BothChanged => {
flags.0 = true;
flags.1 = true;
}
Drift::Unreviewed => flags.2 = true,
_ => {}
}
}
}
let drift = match flags {
(true, true, _) => Drift::BothChanged,
(true, false, _) => Drift::OaChanged,
(false, true, _) => Drift::SaChanged,
(false, false, true) => Drift::Unreviewed,
_ => Drift::Unchanged,
};
result.push(LinkedSemanticImpact {
objects,
traces: ids,
drift,
unreviewed: flags.2,
});
}
Ok(result)
}
+592
View File
@@ -0,0 +1,592 @@
use super::catalog::{collection, order_collection, sides, write_scalar};
use super::*;
use crate::model::*;
use crate::transitions::TransitionDirection;
use std::collections::{BTreeMap, BTreeSet};
pub(crate) fn set_collection(
p: &mut Project,
id: Id,
which: ReferenceCollection,
list: Vec<Id>,
) -> Result<(), String> {
if list.iter().collect::<BTreeSet<_>>().len() != list.len() {
return Err("Collection contains duplicate members".into());
}
use ReferenceCollection as C;
if which == C::Steps {
let e = p.elements.get_mut(&id).ok_or("Missing sequence")?;
if let Some(steps) = e.steps_mut() {
*steps = list;
return Ok(());
}
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Branch { occurrences, .. },
..
}) = e.shared_mut()
{
*occurrences = list;
return Ok(());
}
return Err("Target has no ordered steps".into());
}
let slot = match which {
C::Steps => unreachable!(),
C::FragmentBranches => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) => Some(branches),
_ => None,
},
C::FunctionInputItems => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
Some(SharedBody::Port {
contract: PortContract::FunctionInput { incoming_items },
..
}) => Some(incoming_items),
_ => None,
},
C::FunctionOutputItems => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
Some(SharedBody::Port {
contract: PortContract::FunctionOutput { outgoing_items },
..
}) => Some(outgoing_items),
_ => None,
},
C::SignalItems => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
Some(SharedBody::Event {
body: EventBody::Signal { items },
}) => Some(items),
_ => None,
},
C::ExchangeItems => match p.relations.get_mut(&id).map(|r| &mut r.body) {
Some(
RelationBody::Interaction { items, .. }
| RelationBody::FunctionalExchange { items, .. }
| RelationBody::BoundaryExchange { items, .. },
) => Some(items),
_ => None,
},
C::TransitionTriggers => match p.relations.get_mut(&id).map(|r| &mut r.body) {
Some(RelationBody::StateTransition { triggers, .. }) => Some(triggers),
_ => None,
},
C::TransitionEffects => match p.relations.get_mut(&id).map(|r| &mut r.body) {
Some(RelationBody::StateTransition { effects, .. }) => Some(effects),
_ => None,
},
C::Constrained => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
Some(SharedBody::Constraint { constrained, .. }) => Some(constrained),
_ => None,
},
}
.ok_or("Collection is inapplicable on target")?;
*slot = list;
Ok(())
}
pub(crate) fn chosen<T>(pair: &KeyPair<T>, layer: Layer) -> Option<&T> {
match layer {
Layer::Oa => pair.oa.as_ref(),
Layer::Sa => pair.sa.as_ref(),
}
}
pub(crate) fn value<T>(review: &Reviewed<T>, layer: Layer) -> Result<&T, String> {
match match layer {
Layer::Oa => &review.oa,
Layer::Sa => &review.sa,
} {
Observed::Value(v) => Ok(v),
Observed::NotApplicable => {
Err("Facet does not exist in the selected constructor branch".into())
}
}
}
pub(crate) fn directional_map(
map: &BTreeMap<Id, Id>,
direction: TransitionDirection,
) -> Result<BTreeMap<Id, Id>, String> {
if direction == TransitionDirection::OaToSa {
return Ok(map.clone());
}
let mut inverse = BTreeMap::new();
for (oa, sa) in map {
if inverse.insert(*sa, *oa).is_some_and(|old| old != *oa) {
return Err(format!(
"Reverse reference {sa} has multiple OA correspondences; supply an unambiguous mapping"
));
}
}
Ok(inverse)
}
pub(crate) fn remap_record(
record: &mut SemanticRecord,
map: &BTreeMap<Id, Id>,
) -> Result<(), String> {
let mut missing = BTreeSet::new();
let mut remap = |id: &mut Id| {
if let Some(new) = map.get(id) {
*id = *new;
} else {
missing.insert(*id);
}
};
match record {
SemanticRecord::Element(e) => crate::references::visit_element_refs(e, &mut remap),
SemanticRecord::Relation(r) => crate::references::visit_relation_refs(r, &mut remap),
SemanticRecord::Artifact(a) => remap(&mut a.owner),
}
if missing.is_empty() {
Ok(())
} else {
Err(format!(
"Missing explicit reference correspondence: {missing:?}"
))
}
}
pub(crate) fn remap_fact(
fact: &FacetReview,
map: &BTreeMap<Id, Id>,
source: Layer,
) -> Result<FacetReview, String> {
// Remap only the selected observation. Keys remain in canonical OA/SA space.
let mut json = serde_json::to_value(fact).map_err(|e| e.to_string())?;
let base = SemanticBaseline {
facets: vec![fact.clone()],
};
let side = match source {
Layer::Oa => "oa",
Layer::Sa => "sa",
};
let mut missing = BTreeSet::new();
for (path, kind) in historical_source_slots(&base) {
if kind != HistoricalSourceKind::Reference {
continue;
}
let rest = &path[2..];
if !rest.windows(2).any(|w| w[0] == side && w[1] == "Value") {
continue;
}
let mut leaf = &mut json;
for part in rest {
leaf = if leaf.is_array() {
leaf.get_mut(
part.parse::<usize>()
.map_err(|_| "Invalid historical array index")?,
)
} else {
leaf.get_mut(part)
}
.ok_or("Invalid historical source slot")?;
}
let id: Id = serde_json::from_value(leaf.clone()).map_err(|e| e.to_string())?;
if let Some(new) = map.get(&id) {
*leaf = serde_json::to_value(new).unwrap();
} else {
missing.insert(id);
}
}
if !missing.is_empty() {
return Err(format!(
"Missing explicit reference correspondence: {missing:?}"
));
}
serde_json::from_value(json).map_err(|e| e.to_string())
}
pub(crate) fn check_keys(selector: &FacetSelector, map: &BTreeMap<Id, Id>) -> Result<(), String> {
fn pair(keys: &KeyPair<Id>, map: &BTreeMap<Id, Id>) -> Result<(), String> {
if let (Some(oa), Some(sa)) = (keys.oa, keys.sa)
&& map.get(&oa) != Some(&sa)
{
return Err("Key pair disagrees with frozen reference correspondence".into());
}
if keys.oa.is_none() && keys.sa.is_none() {
return Err("Empty key pair".into());
}
Ok(())
}
match selector {
FacetSelector::Member { keys, .. }
| FacetSelector::Binding(keys)
| FacetSelector::Child(keys)
| FacetSelector::Incident { keys, .. }
| FacetSelector::Artifact { keys, .. } => pair(keys, map),
FacetSelector::Annotation(keys) => {
if let (Some(oa), Some(sa)) = (&keys.oa, &keys.sa) {
if oa.path.len() != sa.path.len() {
return Err("Annotation path pairing disagrees".into());
}
for (oa, sa) in oa
.path
.iter()
.chain([&oa.field])
.zip(sa.path.iter().chain([&sa.field]))
{
pair(
&KeyPair {
oa: Some(*oa),
sa: Some(*sa),
},
map,
)?;
}
}
if keys.oa.is_none() && keys.sa.is_none() {
return Err("Empty annotation pair".into());
}
Ok(())
}
FacetSelector::Literal(keys) => {
if keys
.oa
.as_ref()
.zip(keys.sa.as_ref())
.is_some_and(|(oa, sa)| oa != sa)
{
return Err(
"Enum literal identity is exact text; renaming is removal/addition".into(),
);
}
Ok(())
}
_ => Ok(()),
}
}
/// Apply just one selected fact to an in-memory expected project. The caller
/// subsequently prepares ordinary Commands and checks every induced difference.
pub(crate) fn write_fact(
p: &mut Project,
target: Id,
fact: &FacetReview,
from: Layer,
target_layer: Layer,
preserve_target_only: bool,
) -> Result<(), String> {
match fact {
FacetReview::Member {
collection: which,
keys,
review,
} => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let present = *value(review, from)?;
let key = chosen(keys, target_layer)
.copied()
.ok_or("Map the target collection member explicitly")?;
let mut list = collection(p, target, *which).ok_or("Inapplicable target collection")?;
if present {
if !list.contains(&key) {
list.push(key);
}
} else {
list.retain(|id| *id != key);
}
set_collection(p, target, *which, list)
}
FacetReview::Order {
collection: which,
review,
} => {
let supplied = value(review, from)?.clone();
let current = collection(p, target, order_collection(*which))
.ok_or("Inapplicable target order")?;
let supplied_set: BTreeSet<_> = supplied.iter().copied().collect();
let current_set: BTreeSet<_> = current.iter().copied().collect();
if supplied_set != current_set {
if preserve_target_only
&& supplied_set.is_subset(&current_set)
&& current
.iter()
.filter(|id| supplied_set.contains(id))
.copied()
.collect::<Vec<_>>()
== supplied
{
return Ok(());
}
return Err("Order must contain every surviving target member exactly once; target-only insertion requires an explicit final order".into());
}
set_collection(p, target, order_collection(*which), supplied)
}
FacetReview::Literal { keys, review } => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let present = *value(review, from)?;
let key = chosen(keys, target_layer).ok_or("Missing literal key on target")?;
let Some(SharedBody::DataType {
definition: DataTypeDefinition::Enumeration { literals },
..
}) = p.elements.get_mut(&target).and_then(Element::shared_mut)
else {
return Err("Target is not an enumeration".into());
};
if present {
if !literals.contains(key) {
literals.push(key.clone());
}
} else {
literals.retain(|v| v != key);
}
Ok(())
}
FacetReview::Annotation { keys, review } => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let key = chosen(keys, target_layer).ok_or("Missing target annotation key")?;
let row = value(review, from)?.clone();
if row.as_ref().is_some_and(|v| AnnotationKey::from(v) != *key) {
return Err(
"Annotation candidate changes its row key; review removal/addition".into(),
);
}
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = p.elements.get_mut(&target).and_then(Element::shared_mut)
else {
return Err("Target has no message annotations".into());
};
values.retain(|v| AnnotationKey::from(v) != *key);
if let Some(row) = row {
values.push(row);
}
Ok(())
}
FacetReview::Binding { keys, review } => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let key = *chosen(keys, target_layer).ok_or("Missing target binding key")?;
let row = value(review, from)?.clone();
if row.as_ref().is_some_and(|v| v.referenced != key) {
return Err(
"Binding candidate changes its row key; review removal/addition".into(),
);
}
let Some(SharedBody::Occurrence {
body: OccurrenceBody::ScenarioUse { bindings, .. },
..
}) = p.elements.get_mut(&target).and_then(Element::shared_mut)
else {
return Err("Target has no scenario bindings".into());
};
bindings.retain(|v| v.referenced != key);
if let Some(row) = row {
bindings.push(row);
}
Ok(())
}
FacetReview::Child { keys, review } => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let key = chosen(keys, target_layer).ok_or("Map target child explicitly")?;
let e = p
.elements
.get_mut(key)
.ok_or("Target child does not exist; create it through transition mapping")?;
if *value(review, from)? {
e.parent = Some(target);
} else if e.parent == Some(target) {
e.parent = None;
}
Ok(())
}
FacetReview::Incident { keys, end, review } => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let key = chosen(keys, target_layer).ok_or("Map incident relation explicitly")?;
if !*value(review, from)? {
return Err("Unlinking an incident relation requires an explicit final endpoint through the relation trace".into());
}
let relation = p
.relations
.get_mut(key)
.ok_or("Mapped incident relation is absent")?;
let (a, b) = relation.body.endpoints();
match end {
IncidentEnd::Source => relation.body.set_endpoints(target, b),
IncidentEnd::Target => relation.body.set_endpoints(a, target),
}
Ok(())
}
FacetReview::Artifact { keys, review } => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let key = chosen(keys, target_layer).ok_or("Map artifact explicitly")?;
if let ArtifactReview::Presence(review) = review {
if !*value(review, from)? {
p.artifact_links.remove(key);
return Ok(());
}
if p.artifact_links.get(key).is_some_and(|a| a.owner == target) {
return Ok(());
}
return Err("Artifact creation requires a complete typed candidate".into());
}
let a = p
.artifact_links
.get_mut(key)
.ok_or("Artifact candidate is absent")?;
if a.owner != target {
return Err(
"Artifact metadata can only be reviewed on its currently attached owner trace"
.into(),
);
}
match review {
ArtifactReview::Presence(_) => unreachable!(),
ArtifactReview::Name(r) => a.name = value(r, from)?.clone(),
ArtifactReview::Description(r) => a.description = value(r, from)?.clone(),
ArtifactReview::Owner(r) => a.owner = *value(r, from)?,
ArtifactReview::Relation(r) => a.relation = *value(r, from)?,
ArtifactReview::Uri(r) => a.uri = value(r, from)?.clone(),
ArtifactReview::Revision(r) => a.revision = value(r, from)?.clone(),
ArtifactReview::Fragment(r) => a.fragment = value(r, from)?.clone(),
}
Ok(())
}
_ => {
let (oa, sa) = sides(fact);
let side = match from {
Layer::Oa => oa,
Layer::Sa => sa,
};
let value = side
.get("Value")
.ok_or("Selected branch is not applicable")?
.clone();
write_scalar(p, target, &fact.selector(), value)
}
}
}
pub(crate) fn constructor_contains(group: &FacetSelector, facet: &FacetSelector) -> bool {
use FacetSelector as F;
match group {
F::Data(DataFacet::Constructor) => {
matches!(facet,F::Data(f) if *f != DataFacet::Unit) || matches!(facet, F::Literal(_))
}
F::Port(PortFacet::Constructor) => matches!(
facet,
F::Port(_)
| F::Member {
collection: ReferenceCollection::FunctionInputItems
| ReferenceCollection::FunctionOutputItems,
..
}
),
F::Occurrence(OccurrenceFacet::Constructor) => {
matches!(facet,F::Occurrence(f) if !matches!(f,OccurrenceFacet::Precondition | OccurrenceFacet::Postcondition))
|| matches!(
facet,
F::Annotation(_)
| F::Binding(_)
| F::Member {
collection: ReferenceCollection::Steps
| ReferenceCollection::FragmentBranches,
..
}
| F::Order(_)
)
}
F::Occurrence(OccurrenceFacet::MessageKind) => matches!(
facet,
F::Occurrence(OccurrenceFacet::MessageKind | OccurrenceFacet::TimerExpression)
),
F::Event(EventFacet::Constructor) => matches!(
facet,
F::Event(_)
| F::Member {
collection: ReferenceCollection::SignalItems,
..
}
),
F::State(StateFacet::Kind) => facet == group,
_ => false,
}
}
pub(crate) fn copy_constructor(
p: &mut Project,
target: Id,
source: &SemanticRecord,
group: &FacetSelector,
) -> Result<(), String> {
let SemanticRecord::Element(source) = source else {
return Err("Constructor source must be an element".into());
};
let target = p
.elements
.get_mut(&target)
.ok_or("Missing target element")?;
let a = source.shared().ok_or("Source has no shared constructor")?;
let b = target
.shared_mut()
.ok_or("Target has no shared constructor")?;
match (group, a, b) {
(
FacetSelector::Data(DataFacet::Constructor),
SharedBody::DataType {
definition,
default,
..
},
SharedBody::DataType {
definition: d,
default: v,
..
},
) => {
*d = definition.clone();
*v = default.clone();
}
(
FacetSelector::Port(PortFacet::Constructor),
SharedBody::Port { contract, legacy },
SharedBody::Port {
contract: c,
legacy: l,
},
) => {
*c = contract.clone();
*l = legacy.clone();
}
(
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
SharedBody::Occurrence { body, .. },
SharedBody::Occurrence { body: b, .. },
) => *b = body.clone(),
(
FacetSelector::Occurrence(OccurrenceFacet::MessageKind),
SharedBody::Occurrence {
body: OccurrenceBody::Exchange { message_kind, .. },
..
},
SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: k, ..
},
..
},
) => *k = message_kind.clone(),
(
FacetSelector::Event(EventFacet::Constructor),
SharedBody::Event { body },
SharedBody::Event { body: b },
) => *b = body.clone(),
(
FacetSelector::State(StateFacet::Kind),
SharedBody::State { kind, .. },
SharedBody::State { kind: k, .. },
) => *k = *kind,
_ => return Err("Constructor family mismatch".into()),
}
Ok(())
}
File diff suppressed because it is too large Load Diff
+255
View File
@@ -0,0 +1,255 @@
use crate::model::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Observed<T> {
NotApplicable,
Value(T),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Reviewed<T> {
pub oa: Observed<T>,
pub sa: Observed<T>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct KeyPair<K> {
pub oa: Option<K>,
pub sa: Option<K>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AnnotationKey {
pub path: Vec<Id>,
pub field: Id,
}
impl From<&MessageValue> for AnnotationKey {
fn from(value: &MessageValue) -> Self {
Self {
path: value.path.clone(),
field: value.field,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DataConstructor {
Boolean,
Integer,
Real,
String,
Enumeration,
Record,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PortConstructor {
FunctionInput,
FunctionOutput,
Component,
Legacy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum OccurrenceConstructor {
Exchange,
Action,
Control,
SequenceReference,
ScenarioUse,
State,
Fragment,
Branch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EventConstructor {
Signal,
Time,
Change,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MessageConstructor {
Asynchronous,
Call,
Reply,
Create,
Delete,
Timer,
}
// This finite catalog is also consumed by the typed historical leaf visitor.
// Each selector fixes its payload type; there is no arbitrary value/path schema.
macro_rules! scalar_families {
($m:ident) => { $m! {
Header, HeaderFacet, HeaderReview {
Name: String, Description: String, Parent: Option<Id>
}
ExchangeItem, ExchangeItemFacet, ExchangeItemReview {
Nature: ExchangeItemKind, Mechanism: ExchangeMechanism, Supertype: Option<Id>
}
Interface, InterfaceFacet, InterfaceReview { Supertype: Option<Id> }
Data, DataFacet, DataReview {
Constructor: DataConstructor,
IntegerMinimum: Option<i64>, IntegerMaximum: Option<i64>,
RealMinimum: Option<f64>, RealMaximum: Option<f64>,
StringMinimumLength: u32, StringMaximumLength: Option<u32>,
RecordSupertype: Option<Id>, Default: Option<DataValue>, Unit: String
}
Field, FieldFacet, FieldReview {
Type: TypeRef, Minimum: u32, Maximum: Option<u32>, Unit: String,
Direction: ParameterDirection, Storage: FieldStorage, Default: Option<Vec<DataValue>>
}
Port, PortFacet, PortReview {
Constructor: PortConstructor, ComponentKind: ComponentPortKind,
Orientation: ComponentPortOrientation, LegacyDirection: PortDirection,
LegacyInterface: Option<Id>
}
Scenario, ScenarioFacet, ScenarioReview {
Viewpoint: ScenarioViewpoint, Context: Option<Id>, Precondition: String, Postcondition: String
}
Lifeline, LifelineFacet, LifelineReview { Represented: Option<Id> }
Occurrence, OccurrenceFacet, OccurrenceReview {
Constructor: OccurrenceConstructor, Precondition: String, Postcondition: String,
Exchange: Option<Id>, Sender: Option<Id>, Receiver: Option<Id>,
MessageKind: MessageConstructor, TimerExpression: String, ReplyTo: Option<Id>,
Action: Option<Id>, ActionLifeline: Option<Id>, ControlKind: ControlKind,
Sequence: Option<Id>, Scenario: Option<Id>, State: Option<Id>,
StateLifeline: Option<Id>, FragmentOperator: FragmentOperator, BranchGuard: String
}
Event, EventFacet, EventReview {
Constructor: EventConstructor, TimeKind: TimeEventKind, TimeExpression: String, ChangeExpression: String
}
State, StateFacet, StateReview { Kind: StateKind, Entry: String, Exit: String, DoActivity: String }
Constraint, ConstraintFacet, ConstraintReview { Expression: String }
Relation, RelationFacet, RelationReview {
Source: Id, Target: Id, AllocationOrder: u32, Transmission: TransmissionProtocol,
Acquisition: AcquisitionProtocol, SourceRole: String, TargetRole: String,
SourceMinimum: u32, SourceMaximum: Option<u32>, TargetMinimum: u32,
TargetMaximum: Option<u32>, SourceNavigable: bool, TargetNavigable: bool,
SourcePort: Id, TargetPort: Id, Oriented: bool, ExtendCondition: String,
SequenceGuard: String, TransitionKind: TransitionKind, Trigger: String,
Guard: String, Effect: String, StateActionPhase: StateActionKind
}
}};
}
pub(super) use scalar_families;
macro_rules! define_families {
($( $family:ident, $selector:ident, $review:ident { $( $leaf:ident: $ty:ty ),* } )*) => {
$(
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum $selector { $( $leaf ),* }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum $review { $( $leaf(Reviewed<$ty>) ),* }
)*
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum FacetSelector {
$( $family($selector), )*
Member { collection: ReferenceCollection, keys: KeyPair<Id> },
Order(OrderedCollection),
Literal(KeyPair<String>),
Annotation(KeyPair<AnnotationKey>),
Binding(KeyPair<Id>),
Child(KeyPair<Id>),
Incident { keys: KeyPair<Id>, end: IncidentEnd },
Artifact { keys: KeyPair<Id>, facet: ArtifactFacet },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum FacetReview {
$( $family($review), )*
Member { collection: ReferenceCollection, keys: KeyPair<Id>, review: Reviewed<bool> },
Order { collection: OrderedCollection, review: Reviewed<Vec<Id>> },
Literal { keys: KeyPair<String>, review: Reviewed<bool> },
Annotation { keys: KeyPair<AnnotationKey>, review: Reviewed<Option<MessageValue>> },
Binding { keys: KeyPair<Id>, review: Reviewed<Option<LifelineBinding>> },
Child { keys: KeyPair<Id>, review: Reviewed<bool> },
Incident { keys: KeyPair<Id>, end: IncidentEnd, review: Reviewed<bool> },
Artifact { keys: KeyPair<Id>, review: ArtifactReview },
}
impl FacetReview {
pub fn selector(&self) -> FacetSelector {
match self {
$( Self::$family(value) => FacetSelector::$family(match value {
$( $review::$leaf(_) => $selector::$leaf, )*
}), )*
Self::Member { collection, keys, .. } => FacetSelector::Member { collection: *collection, keys: keys.clone() },
Self::Order { collection, .. } => FacetSelector::Order(*collection),
Self::Literal { keys, .. } => FacetSelector::Literal(keys.clone()),
Self::Annotation { keys, .. } => FacetSelector::Annotation(keys.clone()),
Self::Binding { keys, .. } => FacetSelector::Binding(keys.clone()),
Self::Child { keys, .. } => FacetSelector::Child(keys.clone()),
Self::Incident { keys, end, .. } => FacetSelector::Incident { keys: keys.clone(), end: *end },
Self::Artifact { keys, review } => FacetSelector::Artifact { keys: keys.clone(), facet: review.facet() },
}
}
}
};
}
scalar_families!(define_families);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum ReferenceCollection {
Steps,
FragmentBranches,
FunctionInputItems,
FunctionOutputItems,
SignalItems,
ExchangeItems,
TransitionTriggers,
TransitionEffects,
Constrained,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum OrderedCollection {
Steps,
FragmentBranches,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum IncidentEnd {
Source,
Target,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum ArtifactFacet {
Presence,
Name,
Description,
Owner,
Relation,
Uri,
Revision,
Fragment,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum ArtifactReview {
Presence(Reviewed<bool>),
Name(Reviewed<String>),
Description(Reviewed<String>),
Owner(Reviewed<Id>),
Relation(Reviewed<ArtifactRelation>),
Uri(Reviewed<String>),
Revision(Reviewed<Option<String>>),
Fragment(Reviewed<Option<String>>),
}
impl ArtifactReview {
pub fn facet(&self) -> ArtifactFacet {
match self {
Self::Presence(_) => ArtifactFacet::Presence,
Self::Name(_) => ArtifactFacet::Name,
Self::Description(_) => ArtifactFacet::Description,
Self::Owner(_) => ArtifactFacet::Owner,
Self::Relation(_) => ArtifactFacet::Relation,
Self::Uri(_) => ArtifactFacet::Uri,
Self::Revision(_) => ArtifactFacet::Revision,
Self::Fragment(_) => ArtifactFacet::Fragment,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SemanticBaseline {
pub facets: Vec<FacetReview>,
}
+1 -1
View File
@@ -999,7 +999,7 @@ mod transaction_tests {
let directory = tempfile::tempdir().unwrap();
fs::write(
directory.path().join("project.arcadia"),
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
)
.unwrap();
fs::write(directory.path().join("a.arcadia"), "oa_activity a \"A\" {}").unwrap();
+50 -6
View File
@@ -1,6 +1,6 @@
//! Pure, bounded source projects. All includes resolve against supplied UTF-8
//! documents; this module never reads a filesystem, URL, or process directory.
use crate::{Id, Kind, Project, language, persistence::DecodeResult, validation};
use crate::{Id, Kind, Project, SCHEMA_VERSION, language, persistence::DecodeResult, validation};
use language::{Diagnostic, ParseLimits, Span};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
@@ -280,7 +280,7 @@ pub fn lint_bundle(bundle: &SourceBundle, limits: ProjectLimits) -> Vec<SourceDi
}
}
/// Compile reachable source3/4 files, explicitly upgrade data contracts, and stabilize UUIDs.
/// Compile reachable source3/4/5 files, explicitly upgrade contracts, and stabilize UUIDs.
/// Missing/unreachable files and every admission or engineering error leave the
/// input untouched. V1/V2 single-file migration remains in `language::parse`.
pub fn compile_bundle(
@@ -720,7 +720,7 @@ fn apply_edits(bundle: &mut SourceBundle, edits: Edits) -> Result<(), Vec<Source
Ok(())
}
/// Canonical, readable source4 export. Like `language::format`, the caller is
/// Canonical, readable current-version export. Like `language::format`, the caller is
/// responsible for supplying a valid model; compilation checks the result.
/// Existing source layout is preserved by `update_bundle`, not this formatter.
pub fn format_bundle(project: &Project) -> SourceBundle {
@@ -736,7 +736,7 @@ pub fn format_bundle(project: &Project) -> SourceBundle {
.push_str(&unindent(&declaration));
}
let mut entry = format!(
"arcadia 4;\n{}{{\n description {};\n",
"arcadia {SCHEMA_VERSION};\n{}{{\n description {};\n",
project_header(project),
quote(&project.description)
);
@@ -926,6 +926,7 @@ pub fn update_bundle_with_identity_map(
old_aliases: &old_aliases,
new_aliases: &new_aliases,
legacy_value_paths: false,
identity_origins: Some(&origins),
};
let local = edits.entry(path.clone()).or_default();
update.header(&actual.header, &previous.header, &next.header, local);
@@ -955,6 +956,7 @@ pub fn update_bundle_with_identity_map(
old_aliases: &old_aliases,
new_aliases: &new_aliases,
legacy_value_paths: false,
identity_origins: Some(&origins),
};
let mut local = Vec::new();
update.header(
@@ -1126,6 +1128,7 @@ struct SyntaxUpdate<'a> {
// Source3 migration only adds path qualification; the existing field identity
// must keep its authored argument trivia during that one-time transformation.
legacy_value_paths: bool,
identity_origins: Option<&'a BTreeMap<Id, Id>>,
}
impl SyntaxUpdate<'_> {
fn header(
@@ -1221,6 +1224,18 @@ impl SyntaxUpdate<'_> {
alias.clone(),
));
}
(EditKind::HistoricalUuid(_), EditKind::HistoricalUuid(id)) => {
let token = significant(&self.source[actual.span.start..actual.span.end])
.pop()
.expect("historical UUID string");
edits.push((
Span {
start: actual.span.start + token.span.start,
end: actual.span.start + token.span.end,
},
quote(&id.to_string()),
));
}
(EditKind::Atom, EditKind::Atom) => edits.push((
actual.span,
self.after[after.span.start..after.span.end].into(),
@@ -1345,11 +1360,27 @@ impl SyntaxUpdate<'_> {
let previous = before.unwrap_or(&[]);
let old_keys: Option<Vec<_>> = previous
.iter()
.map(|item| item_key(item, self.before, self.old_aliases, self.legacy_value_paths))
.map(|item| {
item_key(
item,
self.before,
self.old_aliases,
self.legacy_value_paths,
None,
)
})
.collect();
let new_keys: Option<Vec<_>> = after
.iter()
.map(|item| item_key(item, self.after, self.new_aliases, self.legacy_value_paths))
.map(|item| {
item_key(
item,
self.after,
self.new_aliases,
self.legacy_value_paths,
self.identity_origins,
)
})
.collect();
let keyed = old_keys.zip(new_keys).filter(|(old, new)| {
old.iter().collect::<BTreeSet<_>>().len() == old.len()
@@ -1457,9 +1488,22 @@ fn item_key(
source: &str,
aliases: &BTreeMap<String, String>,
legacy_value_paths: bool,
origins: Option<&BTreeMap<Id, Id>>,
) -> Option<String> {
use language::EditKind;
if matches!(value.kind, EditKind::Record(_))
&& let Some(key) = language::semantic_row_key(
&source[value.span.start..value.span.end],
origins.unwrap_or(&BTreeMap::new()),
)
{
return Some(key);
}
match &value.kind {
EditKind::HistoricalUuid(id) => Some(format!(
"uuid:{}",
origins.and_then(|map| map.get(id)).unwrap_or(id)
)),
EditKind::Reference(alias) => aliases.get(alias).map(|id| format!("ref:{id}")),
EditKind::Atom => Some(format!(
"literal:{}",
@@ -1,4 +1,4 @@
//! Source3 data upgrade through token edits; authored modules, nesting and trivia survive.
//! Source3/4 upgrade through token edits; authored modules, nesting and trivia survive.
use super::*;
pub(super) fn upgrade(
@@ -15,6 +15,7 @@ pub(super) fn upgrade(
let changed: BTreeSet<_> = report
.semantic_defaults
.iter()
.filter(|default| default.field != "baseline.semantic")
.map(|default| default.object)
.collect();
let aliases: BTreeMap<_, _> = project
@@ -61,6 +62,7 @@ pub(super) fn upgrade(
old_aliases: &aliases,
new_aliases: &aliases,
legacy_value_paths: true,
identity_origins: None,
};
update.value(
&actual.fields,
@@ -78,7 +80,7 @@ pub(super) fn upgrade(
edits
.entry(current.bundle.entry.clone())
.or_default()
.push((version, "4".into()));
.push((version, crate::SCHEMA_VERSION.to_string()));
// New allocation relations are emitted into the allocating interface's
// existing module, outside its lexical element body. Existing source never moves.
let mut added: BTreeMap<String, String> = BTreeMap::new();
@@ -117,7 +119,7 @@ pub(super) fn upgrade(
&bundle.entry,
Span::default(),
"migration_source_mismatch",
"Targeted source3 upgrade did not reproduce the migrated project.",
"Targeted source upgrade did not reproduce the migrated project.",
)]);
}
result.decoded.migration = Some(report.clone());
+33 -2
View File
@@ -202,9 +202,15 @@ fn mapped_body(e: &Element, kind: Kind, layer: Layer) -> Option<ElementBody> {
return None;
}
if let Some(shared) = e.shared()
&& kind == e.kind()
&& (kind == e.kind() || (kind.is_state() && e.kind().is_state()))
{
let mut shared = shared.clone();
if let SharedBody::State {
kind: state_kind, ..
} = &mut shared
{
*state_kind = kind.state_kind()?;
}
if let SharedBody::Scenario { viewpoint, .. } = &mut shared {
*viewpoint = match (*viewpoint, layer) {
(ScenarioViewpoint::OperationalActivity, Layer::Sa) => {
@@ -389,11 +395,30 @@ pub fn plan_transition(
commands: vec![],
};
let mut selected: BTreeSet<_> = request.sources.iter().copied().collect();
let omitted = if request.direction == TransitionDirection::SaToOa {
let mut omitted = if request.direction == TransitionDirection::SaToOa {
sa_port_omissions(project)
} else {
BTreeSet::new()
};
// An explicit existing Legacy counterpart can be compared and reviewed
// without inventing an OA typed port. Only this port is retained: dependent
// SA implementation records and new counterpart requests stay omitted.
for mapping in &request.mappings {
if project
.elements
.get(&mapping.source)
.is_some_and(|e| e.layer() == Layer::Sa && crate::ports::port_contract(e).is_some())
&& mapping
.target
.and_then(|target| project.elements.get(&target))
.is_some_and(|e| {
e.layer() == Layer::Oa
&& crate::ports::port_contract(e) == Some(&PortContract::Legacy)
})
{
omitted.remove(&mapping.source);
}
}
let mut reported_omissions = BTreeSet::new();
if selected.len() != request.sources.len() {
conflict(
@@ -927,6 +952,7 @@ pub fn plan_transition(
.unwrap_or(TraceBaseline {
oa: current_oa.clone(),
sa: current_sa.clone(),
semantic: None,
});
for field in &reviewed_fields {
set(&mut baseline.oa, *field, value(&current_oa, *field).clone());
@@ -1081,6 +1107,11 @@ pub fn counterpart_kinds(source: Kind, direction: TransitionDirection) -> Vec<Ki
}
fn compatible_kinds(source: Kind, target: Kind, direction: TransitionDirection) -> bool {
// State kind is a reviewed structural facet of one state family. An existing
// counterpart remains selectable after either side changes its vertex kind.
if source.is_state() && target.is_state() {
return true;
}
if matches!(
(direction, source, target),
(
+13
View File
@@ -876,6 +876,19 @@ pub fn validate(p: &Project) -> Vec<Issue> {
text(&mut out, t.id, "baseline.name", &props.name);
text(&mut out, t.id, "baseline.description", &props.description);
}
if let Some(semantic) = &b.semantic
&& let Err(errors) = crate::semantic::validate_semantic_baseline(semantic)
{
for message in errors {
error(
&mut out,
t.id,
"semantic_baseline",
"baseline.semantic",
message,
);
}
}
}
match crate::transitions::trace_drift(p, t) {
crate::transitions::Drift::Unchanged => {}
+27
View File
@@ -0,0 +1,27 @@
// Frozen schema4 input: reviewed prose is deliberately different from live prose.
arcadia 4;
project reviewed_prose "Reviewed dimensions" id "40000000-0000-4000-8000-000000000001" {
description "Preserve the original review without inventing semantic history.";
oa_data_type oa_dimension "OA dimension now" id "40000000-0000-4000-8000-000000000002" {
description "Unreviewed OA description";
definition { Integer { minimum 0; maximum 100; }; };
default { Integer 20; };
unit "m";
}
sa_data_type sa_dimension "SA dimension now" id "40000000-0000-4000-8000-000000000003" {
description "Unreviewed SA description";
definition { Real { minimum 0.0; maximum 100.0; }; };
default { Real 20.0; };
unit "ft";
}
trace dimension_review "Dimension rationale" id "40000000-0000-4000-8000-000000000004" {
description "The target unit and representation are intentional.";
oa @oa_dimension;
sa @sa_dimension;
kind Item;
baseline {
oa { name "Approved OA name"; description "Approved OA\nprose"; };
sa { name "Approved SA name"; description "Approved SA prose"; };
};
}
}
+51
View File
@@ -0,0 +1,51 @@
{
"schema_version": 4,
"id": "40000000-0000-4000-8000-000000000001",
"alias": "reviewed_prose",
"name": "Reviewed dimensions",
"description": "Preserve the original review without inventing semantic history.",
"elements": {
"40000000-0000-4000-8000-000000000002": {
"id": "40000000-0000-4000-8000-000000000002",
"alias": "oa_dimension",
"name": "OA dimension now",
"description": "Unreviewed OA description",
"parent": null,
"body": {"Oa": {"Shared": {"DataType": {
"definition": {"Integer": {"minimum": 0, "maximum": 100}},
"default": {"Integer": 20},
"unit": "m"
}}}}
},
"40000000-0000-4000-8000-000000000003": {
"id": "40000000-0000-4000-8000-000000000003",
"alias": "sa_dimension",
"name": "SA dimension now",
"description": "Unreviewed SA description",
"parent": null,
"body": {"Sa": {"Shared": {"DataType": {
"definition": {"Real": {"minimum": 0.0, "maximum": 100.0}},
"default": {"Real": 20.0},
"unit": "ft"
}}}}
}
},
"relations": {},
"diagrams": {},
"artifact_links": {},
"traces": {
"40000000-0000-4000-8000-000000000004": {
"id": "40000000-0000-4000-8000-000000000004",
"alias": "dimension_review",
"name": "Dimension rationale",
"description": "The target unit and representation are intentional.",
"oa": "40000000-0000-4000-8000-000000000002",
"sa": "40000000-0000-4000-8000-000000000003",
"kind": "Item",
"baseline": {
"oa": {"name": "Approved OA name", "description": "Approved OA\nprose"},
"sa": {"name": "Approved SA name", "description": "Approved SA prose"}
}
}
}
}
+4 -3
View File
@@ -491,7 +491,7 @@ fn authoring_defaults_and_forward_references_remain_strict() {
#[test]
fn typed_unknown_fields_enums_duplicates_and_reference_spelling_are_rejected() {
for (source, expected) in [
("arcadia 5; project mission \"x\" {}", "Unsupported schema"),
("arcadia 6; project mission \"x\" {}", "Unsupported schema"),
(
"arcadia 2; project mission \"x\" { oa_activity a \"A\" { nonsense 1; } }",
"Unknown field",
@@ -795,7 +795,7 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
assert_eq!(from_source, from_json);
let original: serde_json::Value = serde_json::from_slice(old_json).unwrap();
let mut retained = serde_json::to_value(&from_json).unwrap();
assert_eq!(retained["schema_version"], 4);
assert_eq!(retained["schema_version"], 5);
retained["schema_version"] = 2.into();
let allocations: Vec<_> = from_json
.relations
@@ -991,7 +991,7 @@ fn v1_text_and_json_migrate_deterministically_without_losing_legacy_semantics()
assert_eq!(from_text, from_json);
assert_eq!(read(text), from_text);
assert_eq!(persistence::decode(json).unwrap(), from_json);
assert_eq!(from_text.schema_version, 4);
assert_eq!(from_text.schema_version, 5);
assert!(!from_text.elements.values().any(|e| e.layer() == Layer::Sa));
let raw: serde_json::Value = serde_json::from_slice(json).unwrap();
for (id, record) in raw["elements"].as_object().unwrap() {
@@ -1193,6 +1193,7 @@ fn complete_normative_examples_are_accepted_by_the_real_parser() {
if code.trim_start().starts_with("arcadia 2;")
|| code.trim_start().starts_with("arcadia 3;")
|| code.trim_start().starts_with("arcadia 4;")
|| code.trim_start().starts_with("arcadia 5;")
{
read(&code);
examples += 1;
+3 -3
View File
@@ -39,7 +39,7 @@ fn frozen_original_v2_reader_preserves_every_original_record() {
assert_eq!(decoded.project, from_source.project);
assert_eq!(decoded.migration, from_source.migration);
let report = decoded.migration.unwrap();
assert_eq!((report.from_schema, report.to_schema), (2, 4));
assert_eq!((report.from_schema, report.to_schema), (2, 5));
assert_eq!(report.preserved_ports.len(), 4);
assert!(report.generated_endpoints.is_empty());
assert_eq!(report.orientation_defaults.len(), 1);
@@ -144,7 +144,7 @@ fn missing_v2_endpoints_are_deterministic_reported_and_independently_typed() {
}
}
let canonical = language::format(&first.project);
assert!(canonical.starts_with("arcadia 4;"));
assert!(canonical.starts_with("arcadia 5;"));
let reopened = persistence::decode_with_report(canonical.as_bytes()).unwrap();
assert!(reopened.migration.is_none());
assert_eq!(reopened.project, first.project);
@@ -274,7 +274,7 @@ fn old_contract_is_validated_before_new_shapes_and_v1_flows_through_v2() {
assert!(error.contains("V2") && error.contains(&fe), "{error}");
let v1 = include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json");
let result = persistence::decode_with_report(v1).unwrap();
assert_eq!(result.project.schema_version, 4);
assert_eq!(result.project.schema_version, 5);
assert_eq!(result.migration.unwrap().from_schema, 1);
assert_eq!(persistence::decode(v1).unwrap(), result.project);
}
+2 -1
View File
@@ -29,7 +29,8 @@ project typed_ports "Typed ports" {
#[test]
fn source4_covers_typed_contracts_roles_allocations_and_nested_legacy_references() {
let decoded = language::parse_with_report(SOURCE).unwrap();
assert!(decoded.migration.is_none());
let migration = decoded.migration.as_ref().unwrap();
assert_eq!((migration.from_schema, migration.to_schema), (4, 5));
let canonical = language::format(&decoded.project);
for field in [
"incoming_items [@data]",
+680
View File
@@ -0,0 +1,680 @@
use oa_core::{transitions::*, *};
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug)]
enum Family {
Data,
Port,
Occurrence,
Message,
Event,
State,
}
const FAMILIES: [Family; 6] = [
Family::Data,
Family::Port,
Family::Occurrence,
Family::Message,
Family::Event,
Family::State,
];
fn add(p: &mut Project, kind: Kind, layer: Layer, parent: Option<Id>) -> Id {
p.add_element(kind, layer, "reviewed", parent).unwrap()
}
fn trace(p: &mut Project, oa: Id, sa: Id) -> Id {
let id = Id::new_v4();
p.traces.insert(
id,
Trace {
id,
alias: format!("review_{}", id.simple()),
name: "Review".into(),
description: String::new(),
oa,
sa,
kind: TraceKind::Manual,
baseline: Some(TraceBaseline {
oa: p.properties(oa).unwrap(),
sa: p.properties(sa).unwrap(),
semantic: None,
}),
},
);
id
}
fn set(p: &mut Project, id: Id, body: SharedBody) {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = body;
}
fn integer(p: &mut Project, id: Id) {
set(
p,
id,
SharedBody::DataType {
definition: DataTypeDefinition::Integer {
minimum: Some(0),
maximum: Some(10),
},
default: None,
unit: "m".into(),
},
);
}
fn fixture(family: Family) -> (Project, Id, Id, Id, FacetSelector, FacetSelector) {
let mut p = Project::empty();
let mut pair = vec![];
for layer in [Layer::Oa, Layer::Sa] {
let id = match family {
Family::Data => {
let id = add(&mut p, Kind::DataType, layer, None);
integer(&mut p, id);
id
}
Family::Port => {
let kind = if layer == Layer::Oa {
Kind::Actor
} else {
Kind::SystemActor
};
let owner = add(&mut p, kind, layer, None);
let id = Id::new_v4();
let mut port = make_port(&p, id, owner, "port", PortContract::Legacy).unwrap();
if let Some(SharedBody::Port { legacy, .. }) = port.shared_mut() {
*legacy = Some(LegacyPortContract {
direction: PortDirection::InOut,
interface: None,
});
}
p.elements.insert(id, port);
id
}
Family::Occurrence | Family::Message => {
let owner = add(&mut p, Kind::Scenario, layer, None);
let id = add(&mut p, Kind::Occurrence, layer, Some(owner));
p.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap()
.push(id);
set(
&mut p,
id,
SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: None,
sender: None,
receiver: None,
message_kind: MessageKind::Timer {
expression: "10s".into(),
},
reply_to: None,
values: vec![],
},
precondition: String::new(),
postcondition: String::new(),
},
);
id
}
Family::Event => {
let id = add(&mut p, Kind::Event, layer, None);
set(
&mut p,
id,
SharedBody::Event {
body: EventBody::Time {
kind: TimeEventKind::After,
expression: "10s".into(),
},
},
);
id
}
Family::State => {
let machine = add(&mut p, Kind::StateMachine, layer, None);
let region = add(&mut p, Kind::Region, layer, Some(machine));
add(&mut p, Kind::State, layer, Some(region))
}
};
pair.push(id);
}
let (oa, sa) = (pair[0], pair[1]);
let t = trace(&mut p, oa, sa);
let (constructor, descendant) = match family {
Family::Data => (
FacetSelector::Data(DataFacet::Constructor),
FacetSelector::Data(DataFacet::IntegerMaximum),
),
Family::Port => (
FacetSelector::Port(PortFacet::Constructor),
FacetSelector::Port(PortFacet::LegacyDirection),
),
Family::Occurrence => (
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
FacetSelector::Occurrence(OccurrenceFacet::TimerExpression),
),
Family::Message => (
FacetSelector::Occurrence(OccurrenceFacet::MessageKind),
FacetSelector::Occurrence(OccurrenceFacet::TimerExpression),
),
Family::Event => (
FacetSelector::Event(EventFacet::Constructor),
FacetSelector::Event(EventFacet::TimeExpression),
),
Family::State => (
FacetSelector::State(StateFacet::Kind),
FacetSelector::State(StateFacet::Entry),
),
};
(p, oa, sa, t, constructor, descendant)
}
fn change_descendant(p: &mut Project, id: Id) {
match p.elements.get_mut(&id).unwrap().shared_mut().unwrap() {
SharedBody::DataType {
definition: DataTypeDefinition::Integer { maximum, .. },
..
} => *maximum = Some(20),
SharedBody::Port {
legacy: Some(LegacyPortContract { direction, .. }),
..
} => *direction = PortDirection::Out,
SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: MessageKind::Timer { expression },
..
},
..
}
| SharedBody::Event {
body: EventBody::Time { expression, .. },
} => *expression = "20s".into(),
SharedBody::State { entry, .. } => *entry = "initialize".into(),
_ => panic!("Unexpected test family"),
}
}
fn request(
oa: Id,
sa: Id,
reverse: bool,
resolutions: Vec<(FacetSelector, SemanticDecision)>,
) -> SemanticTransitionRequest {
let (direction, source, target) = if reverse {
(TransitionDirection::SaToOa, sa, oa)
} else {
(TransitionDirection::OaToSa, oa, sa)
};
let mut transition = TransitionRequest::new(direction, vec![source]);
transition.mappings.push(TransitionMapping {
source,
target: Some(target),
target_kind: None,
});
let mut r = SemanticTransitionRequest::new(transition);
r.reviews.push(SemanticReviewRequest {
source,
resolutions: resolutions
.into_iter()
.map(|(facet, decision)| FacetResolution { facet, decision })
.collect(),
reset: vec![],
});
r
}
fn apply(p: &Project, r: SemanticTransitionRequest) -> (Project, SemanticTransitionPlan) {
let plan = plan_semantic_transition(p, r).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
(apply_semantic_transition(p, &plan).unwrap(), plan)
}
fn drift(p: &Project, t: Id, selector: &FacetSelector) -> Drift {
semantic_facets(p, &p.traces[&t], &BTreeMap::new())
.unwrap()
.into_iter()
.find(|f| f.selector == *selector)
.unwrap()
.drift
}
#[test]
fn first_equal_constructor_acknowledges_only_discriminator_in_every_family_and_direction() {
for family in FAMILIES {
for reverse in [false, true] {
for decision in [
SemanticDecision::KeepTarget,
SemanticDecision::TakeSource,
SemanticDecision::Candidate,
] {
let (mut p, oa, sa, t, constructor, descendant) = fixture(family);
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
change_descendant(&mut p, target);
let mut r = request(oa, sa, reverse, vec![(constructor.clone(), decision)]);
if decision == SemanticDecision::Candidate {
r.candidates
.push(SemanticRecord::Element(p.elements[&target].clone()));
}
let (q, plan) = apply(&p, r.clone());
assert_eq!(q.elements, p.elements, "{family:?} {decision:?}");
assert_eq!(plan.reviews[0].acknowledged, vec![constructor.clone()]);
assert!(plan.reviews[0].retired.is_empty());
let facts = semantic_facets(&q, &q.traces[&t], &BTreeMap::new()).unwrap();
assert!(facts.iter().all(|f| f.drift
== if f.selector == constructor {
Drift::Unchanged
} else {
Drift::Unreviewed
}));
let (mut q, _) = apply(
&q,
request(
oa,
sa,
reverse,
vec![(descendant.clone(), SemanticDecision::KeepTarget)],
),
);
change_descendant(&mut q, source);
assert_eq!(
drift(&q, t, &descendant),
if reverse {
Drift::SaChanged
} else {
Drift::OaChanged
}
);
let repeated = plan_semantic_transition(&q, r).unwrap();
assert!(
repeated
.conflicts
.iter()
.any(|c| c.contains("No constructor switch"))
);
assert!(apply_semantic_transition(&q, &repeated).is_err());
}
}
}
}
#[test]
fn independent_constructor_and_leaf_selections_complete_missing_or_empty_baselines() {
for family in FAMILIES {
for reverse in [false, true] {
for history in 0..3 {
let (mut p, oa, sa, t, _, _) = fixture(family);
if history == 0 {
p.traces.get_mut(&t).unwrap().baseline = None;
} else if history == 2 {
p.traces
.get_mut(&t)
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic = Some(SemanticBaseline::default());
}
let selectors: Vec<_> = semantic_facets(&p, &p.traces[&t], &BTreeMap::new())
.unwrap()
.into_iter()
.map(|f| f.selector)
.collect();
let (q, plan) = apply(
&p,
request(
oa,
sa,
reverse,
selectors
.iter()
.cloned()
.map(|s| (s, SemanticDecision::KeepTarget))
.collect(),
),
);
assert_eq!(q.elements, p.elements);
assert_eq!(plan.reviews[0].acknowledged.len(), selectors.len());
assert!(plan.reviews[0].retired.is_empty());
let status = trace_semantic_drift(&q, &q.traces[&t], &BTreeMap::new()).unwrap();
assert_eq!(status.own, Drift::Unchanged, "{family:?} {history}");
assert!(!status.own_unreviewed);
}
}
}
}
#[test]
fn migrated_equal_constructor_can_complete_semantic_review_without_model_changes() {
let json = include_str!("fixtures/v4-reviewed-prose.json")
.replace("\"Real\"", "\"Integer\"")
.replace("0.0", "0")
.replace("100.0", "100")
.replace("20.0", "20");
for reverse in [false, true] {
let decoded = persistence::decode_with_report(json.as_bytes()).unwrap();
let report = decoded.migration.unwrap();
assert_eq!((report.from_schema, report.to_schema), (4, 5));
let p = decoded.project;
let t = p.traces.values().next().unwrap();
assert!(t.baseline.as_ref().unwrap().semantic.is_none());
let original_prose = t.baseline.clone().unwrap();
let (oa, sa, id) = (t.oa, t.sa, t.id);
let selections = semantic_facets(&p, t, &BTreeMap::new())
.unwrap()
.into_iter()
.map(|f| (f.selector, SemanticDecision::KeepTarget))
.collect();
let (q, _) = apply(&p, request(oa, sa, reverse, selections));
assert_eq!(q.elements, p.elements);
let baseline = q.traces[&id].baseline.as_ref().unwrap();
assert_eq!(baseline.oa, original_prose.oa);
assert_eq!(baseline.sa, original_prose.sa);
let status = trace_semantic_drift(&q, &q.traces[&id], &BTreeMap::new()).unwrap();
assert_eq!(status.own, Drift::Unchanged);
assert!(!status.own_unreviewed);
}
}
#[test]
fn equal_constructor_candidate_requires_independent_selection_of_changed_descendants() {
for reverse in [false, true] {
let (p, oa, sa, t, constructor, descendant) = fixture(Family::Data);
let target = if reverse { oa } else { sa };
let mut changed = p.clone();
change_descendant(&mut changed, target);
let mut r = request(
oa,
sa,
reverse,
vec![(constructor.clone(), SemanticDecision::Candidate)],
);
r.candidates
.push(SemanticRecord::Element(changed.elements[&target].clone()));
let invalid = plan_semantic_transition(&p, r.clone()).unwrap();
assert!(
invalid
.conflicts
.iter()
.any(|c| c.contains("changes an unselected facet"))
);
assert!(apply_semantic_transition(&p, &invalid).is_err());
r.reviews[0].resolutions.push(FacetResolution {
facet: descendant.clone(),
decision: SemanticDecision::Candidate,
});
let (q, plan) = apply(&p, r);
assert_eq!(q.elements, changed.elements);
assert_eq!(plan.reviews[0].acknowledged.len(), 2);
assert_eq!(drift(&q, t, &constructor), Drift::Unchanged);
assert_eq!(drift(&q, t, &descendant), Drift::Unchanged);
assert_eq!(
drift(&q, t, &FacetSelector::Data(DataFacet::Default)),
Drift::Unreviewed
);
}
}
#[test]
fn reset_equal_constructor_does_not_refresh_previously_reviewed_descendants() {
for reverse in [false, true] {
let (p, oa, sa, t, constructor, descendant) = fixture(Family::Data);
let (mut p, _) = apply(
&p,
request(
oa,
sa,
reverse,
vec![
(constructor.clone(), SemanticDecision::KeepTarget),
(descendant.clone(), SemanticDecision::KeepTarget),
],
),
);
let original = p.traces[&t]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap()
.clone();
change_descendant(&mut p, if reverse { sa } else { oa });
let mut r = request(
oa,
sa,
reverse,
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
);
r.reviews[0].reset.push(constructor.clone());
let (q, plan) = apply(&p, r);
assert_eq!(q.elements, p.elements);
assert_eq!(plan.reviews[0].acknowledged, vec![constructor]);
assert!(plan.reviews[0].retired.is_empty());
assert_eq!(
q.traces[&t]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap(),
&original
);
assert_eq!(
drift(&q, t, &descendant),
if reverse {
Drift::SaChanged
} else {
Drift::OaChanged
}
);
}
}
#[test]
fn first_constructor_acknowledgement_agrees_only_on_its_canonical_discriminator_slot() {
for switch in [false, true] {
let (mut p, oa, sa, t, constructor, descendant) = fixture(Family::Data);
let second = add(&mut p, Kind::DataType, Layer::Oa, None);
integer(&mut p, second);
if switch {
set(
&mut p,
second,
SharedBody::DataType {
definition: DataTypeDefinition::Real {
minimum: Some(0.),
maximum: Some(30.),
},
default: None,
unit: "m".into(),
},
);
} else {
change_descendant(&mut p, second);
}
trace(&mut p, second, sa);
let mut r = request(
oa,
sa,
false,
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
);
r.transition.sources.push(second);
r.transition.mappings.push(TransitionMapping {
source: second,
target: Some(sa),
target_kind: None,
});
r.reviews.push(SemanticReviewRequest {
source: second,
reset: vec![],
resolutions: vec![FacetResolution {
facet: if switch {
constructor.clone()
} else {
descendant.clone()
},
decision: SemanticDecision::TakeSource,
}],
});
for reversed in [false, true] {
let mut r = r.clone();
if reversed {
r.reviews.reverse();
}
let plan = plan_semantic_transition(&p, r).unwrap();
if switch {
assert!(plan.conflicts.iter().any(|c| c.contains("Contradictory")));
assert!(apply_semantic_transition(&p, &plan).is_err());
} else {
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let q = apply_semantic_transition(&p, &plan).unwrap();
assert_eq!(drift(&q, t, &constructor), Drift::Unchanged);
assert_eq!(drift(&q, t, &descendant), Drift::Unreviewed);
assert!(matches!(
q.elements[&sa].shared(),
Some(SharedBody::DataType {
definition: DataTypeDefinition::Integer {
maximum: Some(20),
..
},
..
})
));
}
}
}
}
#[test]
fn one_sided_or_disappeared_message_constructor_is_not_an_equal_scalar_acknowledgement() {
for reverse in [false, true] {
let (mut p, oa, sa, t, constructor, descendant) = fixture(Family::Message);
let source = if reverse { sa } else { oa };
let target = if reverse { oa } else { sa };
let mut action = p.elements[&source].shared().unwrap().clone();
if let SharedBody::Occurrence { body, .. } = &mut action {
*body = OccurrenceBody::Action {
action: None,
lifeline: None,
};
}
set(&mut p, source, action.clone());
let (q, plan) = apply(
&p,
request(
oa,
sa,
reverse,
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
),
);
// One-sided applicability still has a constructor-group scope and records its timer leaf.
assert_eq!(
plan.reviews[0].acknowledged,
vec![constructor.clone(), descendant.clone()]
);
assert_eq!(q.elements, p.elements);
assert_eq!(drift(&q, t, &descendant), Drift::Unchanged);
let mut p = q;
set(&mut p, target, action);
let mut r = request(
oa,
sa,
reverse,
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
);
r.reviews[0].reset.push(constructor);
let plan = plan_semantic_transition(&p, r).unwrap();
assert!(!plan.conflicts.is_empty());
assert!(apply_semantic_transition(&p, &plan).is_err());
}
}
#[test]
fn historical_switches_retain_groups_and_completed_requests_still_reject_in_both_directions() {
for reverse in [false, true] {
let (p, oa, sa, t, constructor, _) = fixture(Family::Data);
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
let keep = request(
oa,
sa,
reverse,
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
);
let (mut p, _) = apply(&p, keep.clone());
set(
&mut p,
source,
SharedBody::DataType {
definition: DataTypeDefinition::Real {
minimum: Some(0.),
maximum: Some(20.),
},
default: None,
unit: "m".into(),
},
);
let (mut q, plan) = apply(&p, keep.clone());
let maximum = FacetSelector::Data(DataFacet::RealMaximum);
assert!(plan.reviews[0].acknowledged.contains(&maximum));
assert!(
plan.reviews[0]
.acknowledged
.contains(&FacetSelector::Data(DataFacet::Default))
);
assert!(
!plan.reviews[0]
.acknowledged
.contains(&FacetSelector::Data(DataFacet::Unit))
);
assert_eq!(q.elements, p.elements);
if let Some(SharedBody::DataType {
definition: DataTypeDefinition::Real { maximum, .. },
..
}) = q.elements.get_mut(&source).unwrap().shared_mut()
{
*maximum = Some(30.);
}
let repeated = plan_semantic_transition(&q, keep).unwrap();
assert!(
repeated
.conflicts
.iter()
.any(|c| c.contains("No constructor switch"))
);
assert!(apply_semantic_transition(&q, &repeated).is_err());
assert_eq!(
drift(&q, t, &maximum),
if reverse {
Drift::SaChanged
} else {
Drift::OaChanged
}
);
let take = request(
oa,
sa,
reverse,
vec![(constructor, SemanticDecision::TakeSource)],
);
let (p, _) = apply(&q, take.clone());
assert!(matches!(
p.elements[&target].shared(),
Some(SharedBody::DataType {
definition: DataTypeDefinition::Real {
maximum: Some(30.),
..
},
..
})
));
let completed = plan_semantic_transition(&p, take).unwrap();
assert!(
completed
.conflicts
.iter()
.any(|c| c.contains("No constructor switch"))
);
assert!(apply_semantic_transition(&p, &completed).is_err());
}
}
+169
View File
@@ -0,0 +1,169 @@
use oa_core::{language, persistence, source_project::*, *};
use serde_json::Value;
use std::collections::BTreeMap;
const JSON4: &[u8] = include_bytes!("fixtures/v4-reviewed-prose.json");
const SOURCE4: &str = include_str!("fixtures/v4-reviewed-prose.arcadia");
#[test]
fn frozen_v4_json_and_source_preserve_the_old_review_without_fabricating_semantics() {
let json = persistence::decode_with_report(JSON4).unwrap();
let source = language::parse_with_report(SOURCE4).unwrap();
assert_eq!(json.project, source.project);
for decoded in [json, source] {
assert_eq!(decoded.project.schema_version, 5);
let report = decoded.migration.unwrap();
assert_eq!((report.from_schema, report.to_schema), (4, 5));
let trace = decoded.project.traces.values().next().unwrap();
let baseline = trace.baseline.as_ref().unwrap();
assert!(baseline.semantic.is_none());
assert_eq!(baseline.oa.name, "Approved OA name");
assert_eq!(baseline.oa.description, "Approved OA\nprose");
assert_eq!(baseline.sa.name, "Approved SA name");
assert_ne!(baseline.oa.name, decoded.project.named(trace.oa));
let mut expected: Value = serde_json::from_slice(JSON4).unwrap();
expected["schema_version"] = 5.into();
assert_eq!(serde_json::to_value(&decoded.project).unwrap(), expected);
let reopened =
persistence::decode_with_report(&serde_json::to_vec(&decoded.project).unwrap())
.unwrap();
assert!(reopened.migration.is_none());
assert_eq!(reopened.project, decoded.project);
}
}
#[test]
fn older_headers_cannot_admit_new_semantic_baselines() {
let mut value: Value = serde_json::from_slice(JSON4).unwrap();
let trace = value["traces"]
.as_object_mut()
.unwrap()
.values_mut()
.next()
.unwrap();
trace["baseline"]["semantic"] = serde_json::json!({"facets": []});
for version in [3, 4] {
value["schema_version"] = version.into();
let error = persistence::decode(&serde_json::to_vec(&value).unwrap())
.unwrap_err()
.to_string();
assert!(error.contains("semantic"), "{error}");
}
let source = SOURCE4.replace("baseline {", "baseline { semantic { facets []; };");
assert!(
language::parse(&source)
.unwrap_err()
.iter()
.any(|d| d.message.contains("require schema/source5"))
);
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), source)]),
};
assert!(
compile_bundle(&bundle, ProjectLimits::default())
.unwrap_err()
.iter()
.any(|d| d.diagnostic.message.contains("require schema/source5"))
);
}
#[test]
fn frozen_v4_trace_envelope_keeps_duplicate_and_unknown_field_rejection() {
let json = std::str::from_utf8(JSON4).unwrap();
let duplicate = json.replace("\"baseline\": {", "\"baseline\": null, \"baseline\": {");
assert!(
persistence::decode(duplicate.as_bytes())
.unwrap_err()
.to_string()
.contains("duplicate field")
);
let unknown = json.replace("\"baseline\": {", "\"baseline\": { \"reviewed\": true,");
assert!(
persistence::decode(unknown.as_bytes())
.unwrap_err()
.to_string()
.contains("unknown field")
);
}
#[test]
fn authored_v4_modules_upgrade_only_the_version_and_preserve_nested_comments() {
let start = SOURCE4.find(" oa_data_type").unwrap();
let end = SOURCE4.rfind('}').unwrap();
let entry = format!(
"{} include \"contracts.arcadia\";\n}}\n",
&SOURCE4[..start]
);
let mut module =
SOURCE4[start..end].replace("baseline {", "baseline { // original review rationale");
module.push_str(
r#"
oa_data_type record "Independent record" id "40000000-0000-4000-8000-000000000011" {
definition { Record { supertype none; }; };
// This field remains inside its authored record.
oa_data_field flag "Flag" id "40000000-0000-4000-8000-000000000012" { type_ref Boolean; }
}
"#,
);
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), entry.clone()),
("contracts.arcadia".into(), module.clone()),
]),
};
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
assert_eq!(compiled.bundle.files["contracts.arcadia"], module);
assert_eq!(
compiled.bundle.files["project.arcadia"],
entry.replacen("arcadia 4;", "arcadia 5;", 1)
);
assert_eq!(
compiled.index.lexical_owners
[&Id::parse_str("40000000-0000-4000-8000-000000000012").unwrap()],
Id::parse_str("40000000-0000-4000-8000-000000000011").unwrap()
);
let reopened = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
assert!(reopened.decoded.migration.is_none());
assert_eq!(reopened.bundle, compiled.bundle);
assert_eq!(reopened.decoded.project, compiled.decoded.project);
assert_eq!(
bundle.files["project.arcadia"], entry,
"caller draft is unchanged"
);
}
#[test]
fn original_v1_v2_v3_inputs_complete_the_explicit_upgrade_to_five() {
for (version, bytes) in [
(
1,
include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json").as_slice(),
),
(
2,
include_bytes!("fixtures/v2-coastal-rescue.json").as_slice(),
),
(
3,
include_bytes!("fixtures/v3-data-contract.json").as_slice(),
),
] {
let decoded = persistence::decode_with_report(bytes).unwrap();
let report = decoded.migration.unwrap();
assert_eq!((report.from_schema, report.to_schema), (version, 5));
assert_eq!(decoded.project.schema_version, 5);
assert!(
decoded
.project
.traces
.values()
.all(|t| t.baseline.as_ref().is_none_or(|b| b.semantic.is_none()))
);
assert_eq!(
language::parse(&language::format(&decoded.project)).unwrap(),
decoded.project
);
}
}
File diff suppressed because it is too large Load Diff
+431
View File
@@ -0,0 +1,431 @@
use oa_core::{language, persistence, semantic::*, source_project::*, *};
use std::collections::BTreeMap;
fn id(n: u128) -> Id {
Id::from_u128(0x40000000_0000_4000_8000_000000000000 + n)
}
fn reviewed<T>(oa: T, sa: T) -> Reviewed<T> {
Reviewed {
oa: Observed::Value(oa),
sa: Observed::Value(sa),
}
}
fn project(facets: Vec<FacetReview>) -> Project {
let mut project =
persistence::decode(include_bytes!("fixtures/v4-reviewed-prose.json")).unwrap();
project
.traces
.get_mut(&id(4))
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic = Some(SemanticBaseline { facets });
project
}
fn facets(project: &mut Project) -> &mut Vec<FacetReview> {
&mut project
.traces
.get_mut(&id(4))
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic
.as_mut()
.unwrap()
.facets
}
fn compile(source: String) -> CompiledSourceProject {
compile_bundle(
&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), source)]),
},
ProjectLimits::default(),
)
.unwrap()
}
#[test]
fn typed_historical_facets_roundtrip_deleted_references_and_all_leaf_categories() {
let row = MessageValue {
path: vec![id(91), id(92)],
field: id(93),
expression: "x + 1".into(),
};
let model = project(vec![
FacetReview::Data(DataReview::Constructor(reviewed(
DataConstructor::Integer,
DataConstructor::Real,
))),
FacetReview::Data(DataReview::IntegerMinimum(Reviewed {
oa: Observed::Value(None),
sa: Observed::NotApplicable,
})),
FacetReview::Data(DataReview::RealMaximum(Reviewed {
oa: Observed::NotApplicable,
sa: Observed::Value(Some(1.5)),
})),
FacetReview::Field(FieldReview::Type(reviewed(
TypeRef::Named(id(90)),
TypeRef::Boolean,
))),
FacetReview::Field(FieldReview::Default(reviewed(
Some(vec![DataValue::String(id(90).to_string())]),
None,
))),
FacetReview::Member {
collection: ReferenceCollection::Steps,
keys: KeyPair {
oa: Some(id(91)),
sa: None,
},
review: reviewed(true, false),
},
FacetReview::Order {
collection: OrderedCollection::Steps,
review: reviewed(vec![id(91), id(92)], vec![]),
},
FacetReview::Annotation {
keys: KeyPair {
oa: Some(AnnotationKey::from(&row)),
sa: None,
},
review: reviewed(Some(row), None),
},
FacetReview::Binding {
keys: KeyPair {
oa: Some(id(94)),
sa: None,
},
review: reviewed(
Some(LifelineBinding {
referenced: id(94),
local: id(95),
}),
None,
),
},
FacetReview::Literal {
keys: KeyPair {
oa: Some(id(90).to_string()),
sa: None,
},
review: reviewed(true, false),
},
FacetReview::Artifact {
keys: KeyPair {
oa: Some(id(96)),
sa: None,
},
review: ArtifactReview::Relation(reviewed(
ArtifactRelation::References,
ArtifactRelation::References,
)),
},
]);
assert!(!model.contains(id(90)));
let source = language::format(&model);
assert!(source.starts_with("arcadia 5;"));
assert!(
source.contains(&format!("Named uuid \"{}\"", id(90))),
"{source}"
);
assert!(source.contains("sa NotApplicable;"));
assert!(source.contains("Value none;"));
assert!(source.contains(&format!("String \"{}\"", id(90))));
assert_eq!(language::parse(&source).unwrap(), model);
assert_eq!(compile(source).decoded.project, model);
assert_eq!(
persistence::decode(&serde_json::to_vec(&model).unwrap()).unwrap(),
model
);
}
#[test]
fn historical_uuid_and_enum_tokens_are_contextual_and_text_remains_quoted() {
let model = project(vec![
FacetReview::Field(FieldReview::Type(reviewed(
TypeRef::Named(id(2)),
TypeRef::Boolean,
))),
FacetReview::Data(DataReview::Unit(reviewed(id(2).to_string(), "m".into()))),
]);
let source = language::format(&model);
let reference = format!("Named uuid \"{}\"", id(2));
for replacement in [format!("Named \"{}\"", id(2)), "Named @oa_dimension".into()] {
let invalid = source.replace(&reference, &replacement);
assert!(language::parse(&invalid).is_err(), "{invalid}");
assert!(
compile_bundle(
&SourceBundle {
entry: "p.arcadia".into(),
files: BTreeMap::from([("p.arcadia".into(), invalid)])
},
ProjectLimits::default()
)
.is_err()
);
}
let invalid_text = source.replace("Value \"m\";", &format!("Value uuid \"{}\";", id(2)));
assert!(language::parse(&invalid_text).is_err());
let invalid_enum = source.replace("Value Boolean;", "Value \"Boolean\";");
assert!(language::parse(&invalid_enum).is_err());
let invalid_live = source.replace("oa @oa_dimension;", &format!("oa uuid \"{}\";", id(2)));
assert!(language::parse(&invalid_live).is_err());
let invalid_prose = source.replace(
"name \"Approved OA name\";",
&format!("name uuid \"{}\";", id(2)),
);
assert!(language::parse(&invalid_prose).is_err());
// `uuid` remains an ordinary legal declaration alias after @.
assert!(language::parse(&source.replace("oa_dimension", "uuid")).is_ok());
}
#[test]
fn semantic_values_remain_closed_typed_records_in_both_formats() {
let model = project(vec![FacetReview::Data(DataReview::IntegerMinimum(
reviewed(Some(0), None),
))]);
let source = language::format(&model);
for invalid in [
source.replace("IntegerMinimum", "UnknownFacet"),
source.replace("Value 0;", "Value \"0\";"),
source.replace("facets [", "unexpected true; facets ["),
] {
assert!(language::parse(&invalid).is_err(), "{invalid}");
}
let mut json = serde_json::to_value(&model).unwrap();
json["traces"][id(4).to_string()]["baseline"]["semantic"] = serde_json::json!({"facets": [{"Data": {"IntegerMinimum": {"oa": {"Value": "0"}, "sa": {"Value": null}}}}]});
assert!(persistence::decode(&serde_json::to_vec(&json).unwrap()).is_err());
}
#[test]
fn facet_reordering_and_reidentification_keep_comments_with_typed_row_identity() {
let row = MessageValue {
path: vec![id(2)],
field: id(90),
expression: id(2).to_string(),
};
let model = project(vec![
FacetReview::Annotation {
keys: KeyPair {
oa: Some(AnnotationKey::from(&row)),
sa: None,
},
review: reviewed(Some(row), None),
},
FacetReview::Literal {
keys: KeyPair {
oa: Some(id(2).to_string()),
sa: None,
},
review: reviewed(true, false),
},
FacetReview::Field(FieldReview::Type(reviewed(
TypeRef::Named(id(2)),
TypeRef::Boolean,
))),
]);
let source = language::format(&model)
.replace("Annotation {", "Annotation { // annotation rationale")
.replace("Literal {", "Literal { // literal rationale")
.replace("Named uuid ", "Named uuid // historical type rationale\n");
let current = compile(source);
let mut prepared = prepare_command(
&current.decoded.project,
&Command::Reidentify(id(2), id(20)),
)
.unwrap();
facets(&mut prepared.project).reverse();
let updated = update_bundle_with_identity_map(
&current,
&prepared.project,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
let text = &updated.bundle.files["project.arcadia"];
assert_eq!(updated.decoded.project, prepared.project);
assert!(
text.contains(&format!(
"Named uuid // historical type rationale\n\"{}\"",
id(20)
)),
"{text}"
);
let type_note = text.find("// historical type rationale").unwrap();
let literal_note = text.find("// literal rationale").unwrap();
let annotation_note = text.find("// annotation rationale").unwrap();
assert!(
type_note < literal_note && literal_note < annotation_note,
"{text}"
);
assert!(text.contains(&format!("expression \"{}\"", id(2))));
let FacetReview::Literal { keys, .. } = &facets(&mut prepared.project)[1] else {
panic!()
};
assert_eq!(
keys.oa,
Some(id(2).to_string()),
"UUID-shaped literal keys are text"
);
assert_eq!(
compile_bundle(&updated.bundle, ProjectLimits::default())
.unwrap()
.bundle,
updated.bundle
);
}
#[test]
fn historical_reference_deletion_does_not_remove_the_review_or_create_a_live_alias() {
let mut model = project(vec![FacetReview::Field(FieldReview::Type(reviewed(
TypeRef::Named(id(90)),
TypeRef::Boolean,
)))]);
let mut former_type = model.elements[&id(2)].clone();
former_type.id = id(90);
former_type.alias = "former_type".into();
model.elements.insert(id(90), former_type);
let current = compile(language::format(&model));
let prepared = prepare_command(&current.decoded.project, &Command::Delete(id(90))).unwrap();
let updated = update_bundle_with_identity_map(
&current,
&prepared.project,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
let source = &updated.bundle.files["project.arcadia"];
assert!(!updated.decoded.project.contains(id(90)));
assert!(!source.contains("@former_type"));
assert!(source.contains(&format!("Named uuid \"{}\"", id(90))));
assert_eq!(updated.decoded.project.traces, model.traces);
assert_eq!(language::parse(source).unwrap(), prepared.project);
}
#[test]
fn included_historical_review_edits_save_and_reopen_without_rewriting_other_modules() {
use oa_core::source_files::{load_project, save_project};
let model = project(vec![FacetReview::Field(FieldReview::Type(reviewed(
TypeRef::Named(id(90)),
TypeRef::Boolean,
)))]);
let source = language::format(&model);
let trace_at = source.find(" trace ").unwrap();
let end = source.rfind('}').unwrap();
let entry = format!(
"{} include \"reviews.arcadia\";\n}}\n",
&source[..trace_at]
);
let review =
source[trace_at..end].replace("Named uuid ", "Named uuid // deleted type retained\n");
let directory = tempfile::tempdir().unwrap();
let entry_path = directory.path().join("project.arcadia");
std::fs::write(&entry_path, &entry).unwrap();
std::fs::write(directory.path().join("reviews.arcadia"), &review).unwrap();
let snapshot = load_project(&entry_path, ProjectLimits::default()).unwrap();
let compiled = compile_bundle(&snapshot.reachable_bundle(), ProjectLimits::default()).unwrap();
assert_eq!(compiled.index.declarations[&id(4)].path, "reviews.arcadia");
let mut changed = compiled.decoded.project.clone();
let FacetReview::Field(FieldReview::Type(value)) = &mut facets(&mut changed)[0] else {
panic!()
};
value.sa = Observed::Value(TypeRef::String);
let updated = update_bundle(&compiled, &changed, ProjectLimits::default()).unwrap();
assert_eq!(updated.bundle.files["project.arcadia"], entry);
let saved = save_project(&snapshot, &updated, ProjectLimits::default()).unwrap();
let reopened = load_project(&entry_path, ProjectLimits::default()).unwrap();
assert_eq!(saved.bundle, reopened.bundle);
assert_eq!(
compile_bundle(&reopened.reachable_bundle(), ProjectLimits::default())
.unwrap()
.decoded
.project,
changed
);
assert_eq!(std::fs::read_to_string(&entry_path).unwrap(), entry);
assert!(reopened.bundle.files["reviews.arcadia"].contains("uuid // deleted type retained"));
assert!(!changed.contains(id(90)));
}
#[test]
fn prepared_semantic_review_writes_back_and_preserves_the_accepted_target_difference() {
use oa_core::transitions::{TransitionDirection, TransitionMapping, TransitionRequest};
let model = project(vec![]);
let current = compile(language::format(&model).replace(
"baseline {",
"baseline { // prior prose review is independent",
));
let mut transition = TransitionRequest::new(TransitionDirection::OaToSa, vec![id(2)]);
transition.mappings.push(TransitionMapping {
source: id(2),
target: Some(id(3)),
target_kind: None,
});
let mut request = SemanticTransitionRequest::new(transition);
request.reviews.push(SemanticReviewRequest {
source: id(2),
resolutions: vec![
FacetResolution {
facet: FacetSelector::Data(DataFacet::Unit),
decision: SemanticDecision::KeepTarget,
},
FacetResolution {
facet: FacetSelector::Header(HeaderFacet::Name),
decision: SemanticDecision::TakeSource,
},
],
reset: vec![],
});
let plan = plan_semantic_transition(&current.decoded.project, request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let prepared = prepare_semantic_transition(&current.decoded.project, &plan).unwrap();
let updated = update_bundle_with_identity_map(
&current,
&prepared.project,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
assert_eq!(
updated.decoded.project.elements[&id(3)].name,
"OA dimension now"
);
assert!(
matches!(updated.decoded.project.elements[&id(3)].shared(), Some(SharedBody::DataType { unit, .. }) if unit == "ft")
);
assert!(
updated.bundle.files["project.arcadia"].contains("// prior prose review is independent")
);
let baseline = updated.decoded.project.traces[&id(4)]
.baseline
.as_ref()
.unwrap();
assert_eq!(baseline.oa.name, "Approved OA name");
assert!(
baseline
.semantic
.as_ref()
.unwrap()
.facets
.contains(&FacetReview::Data(DataReview::Unit(reviewed(
"m".into(),
"ft".into()
))))
);
assert_eq!(
language::parse(&language::format(&updated.decoded.project)).unwrap(),
prepared.project
);
assert_eq!(
persistence::decode(&serde_json::to_vec(&updated.decoded.project).unwrap()).unwrap(),
prepared.project
);
}
+4 -4
View File
@@ -21,7 +21,7 @@ fn genuine_v3_preserves_typed_ports_and_allocates_deterministic_relationships()
assert_eq!(decoded.migration, source.migration);
let project = &decoded.project;
let report = decoded.migration.as_ref().unwrap();
assert_eq!((report.from_schema, report.to_schema), (3, 4));
assert_eq!((report.from_schema, report.to_schema), (3, 5));
assert_eq!(project.identities().len(), 19);
assert!(report.preserved_ports.is_empty());
assert!(report.generated_endpoints.is_empty());
@@ -71,7 +71,7 @@ fn genuine_v3_preserves_typed_ports_and_allocates_deterministic_relationships()
.any(|issue| issue.code == "message_value_ambiguous_path")
);
let canonical = language::format(project);
assert!(canonical.starts_with("arcadia 4;"));
assert!(canonical.starts_with("arcadia 5;"));
let reopened = language::parse_with_report(&canonical).unwrap();
assert_eq!(&reopened.project, project);
assert!(reopened.migration.is_none());
@@ -154,12 +154,12 @@ fn v1_and_frozen_v2_inputs_reach_four_through_the_shared_data_upgrade() {
language::parse_with_report(include_str!("fixtures/v2-coastal-rescue.arcadia")).unwrap();
assert_eq!(v2.project, text.project);
assert_eq!(v2.migration, text.migration);
assert_eq!(v2.migration.unwrap().to_schema, 4);
assert_eq!(v2.migration.unwrap().to_schema, 5);
let v1 = language::parse_with_report(
"arcadia 1; project \"Old\" id \"00000000-0000-0000-0000-000000000001\" {}",
)
.unwrap();
assert_eq!(v1.project.schema_version, 4);
assert_eq!(v1.project.schema_version, 5);
assert_eq!(v1.migration.unwrap().from_schema, 1);
}
+1 -1
View File
@@ -26,7 +26,7 @@ fn authored_v3_bundle_upgrade_preserves_includes_nested_ownership_comments_and_i
};
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
assert_eq!(compiled.bundle.files.len(), 2);
assert!(compiled.bundle.files["project.arcadia"].contains("arcadia 4;"));
assert!(compiled.bundle.files["project.arcadia"].contains("arcadia 5;"));
assert!(compiled.bundle.files["project.arcadia"].contains("include \"contracts.arcadia\";"));
for text in [
"// Contracts stay in their original module.",
+6 -6
View File
@@ -5,7 +5,7 @@ fn setup() -> tempfile::TempDir {
let directory = tempfile::tempdir().unwrap();
fs::write(
directory.path().join("project.arcadia"),
"arcadia 4; project p \"P\" { include \"parts/a.arcadia\"; }\n",
"arcadia 5; project p \"P\" { include \"parts/a.arcadia\"; }\n",
)
.unwrap();
fs::create_dir(directory.path().join("parts")).unwrap();
@@ -59,7 +59,7 @@ fn loading_keeps_original_no_uuid_bytes_and_saving_stabilizes_exactly_once() {
fn invalid_and_missing_includes_keep_editable_text_and_located_diagnostics() {
let directory = setup();
let entry = directory.path().join("project.arcadia");
fs::write(&entry, "arcadia 4; project p \"P\" {\n include \"missing.arcadia\";\n include \"parts/a.arcadia\";\n}\n").unwrap();
fs::write(&entry, "arcadia 5; project p \"P\" {\n include \"missing.arcadia\";\n include \"parts/a.arcadia\";\n}\n").unwrap();
fs::write(
directory.path().join("parts/a.arcadia"),
"oa_activity a \"A\" { invalid",
@@ -102,7 +102,7 @@ fn discovery_never_refreshes_drafts_and_preserves_detached_buffers() {
let overlays = BTreeMap::from([
(
"project.arcadia".into(),
"arcadia 4; project p \"P\" { include \"new.arcadia\"; }".into(),
"arcadia 5; project p \"P\" { include \"new.arcadia\"; }".into(),
),
(
"parts/a.arcadia".into(),
@@ -176,7 +176,7 @@ fn new_destinations_require_explicit_missing_expectations_and_reject_collisions(
files: BTreeMap::from([
(
"project.arcadia".into(),
"arcadia 4; project p \"P\" { include \"child.arcadia\"; }".into(),
"arcadia 5; project p \"P\" { include \"child.arcadia\"; }".into(),
),
("child.arcadia".into(), "oa_activity a \"A\" {}".into()),
]),
@@ -256,7 +256,7 @@ fn physical_hard_link_aliases_are_rejected() {
.unwrap();
fs::write(
directory.path().join("project.arcadia"),
"arcadia 4; project p \"P\" { include \"parts/a.arcadia\"; include \"alias.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"parts/a.arcadia\"; include \"alias.arcadia\"; }",
)
.unwrap();
assert!(matches!(
@@ -286,7 +286,7 @@ fn symlinks_to_outside_or_inside_project_are_rejected() {
std::os::unix::fs::symlink(&destination, &link).unwrap();
fs::write(
directory.path().join("project.arcadia"),
"arcadia 4; project p \"P\" { include \"alias.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"alias.arcadia\"; }",
)
.unwrap();
let loaded = load_project(
@@ -6,7 +6,7 @@ fn source() -> CompiledSourceProject {
&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), "arcadia 4; project p \"Identity review\" { include \"team/first.arcadia\"; include \"team/second.arcadia\"; include \"contracts.arcadia\"; }".into()),
("project.arcadia".into(), "arcadia 5; project p \"Identity review\" { include \"team/first.arcadia\"; include \"team/second.arcadia\"; include \"contracts.arcadia\"; }".into()),
("team/first.arcadia".into(), "// Rationale for first\nsa_exchange_item first \"First\" { description \"First rationale\"; }\n".into()),
("team/second.arcadia".into(), "// Rationale for second\nsa_exchange_item second \"Second\" {}\n".into()),
("contracts.arcadia".into(), "sa_event contract \"Contract\" { body { Signal { items [\n @first // first item rationale\n ,\n @second // second item rationale\n ]; }; }; }\n".into()),
@@ -5,7 +5,7 @@ fn compile(a: &str, b: &str) -> CompiledSourceProject {
compile_bundle(&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), "arcadia 4; project p \"Nested writeback\" { include \"a.arcadia\"; include \"b.arcadia\"; }\n".into()),
("project.arcadia".into(), "arcadia 5; project p \"Nested writeback\" { include \"a.arcadia\"; include \"b.arcadia\"; }\n".into()),
("a.arcadia".into(), a.into()), ("b.arcadia".into(), b.into()),
]),
}, ProjectLimits::default()).unwrap()
+25 -25
View File
@@ -30,7 +30,7 @@ fn id(project: &Project, alias: &str) -> Id {
#[test]
fn route_edits_keep_diagram_file_ownership_and_comments_with_edge_identity() {
let input = bundle(
"arcadia 4; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
"arcadia 5; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
&[
(
"work.arcadia",
@@ -95,7 +95,7 @@ diagram review "Review" {
#[test]
fn invalid_cross_file_route_references_and_geometry_locate_the_authored_view() {
let mut input = bundle(
"arcadia 4; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
"arcadia 5; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
&[
(
"work.arcadia",
@@ -127,7 +127,7 @@ fn invalid_cross_file_route_references_and_geometry_locate_the_authored_view() {
fn typed() -> SourceBundle {
bundle(
"arcadia 4; project test_model \"航海\" {\n include \"exchanges.arcadia\";\n include \"work/send.arcadia\";\n include \"work/receive.arcadia\";\n include \"types.arcadia\";\n}",
"arcadia 5; project test_model \"航海\" {\n include \"exchanges.arcadia\";\n include \"work/send.arcadia\";\n include \"work/receive.arcadia\";\n include \"types.arcadia\";\n}",
&[
(
"exchanges.arcadia",
@@ -194,7 +194,7 @@ fn cross_file_typed_ports_global_forward_references_and_stable_ids() {
#[test]
fn advisory_lint_locations_refer_to_raw_drafts_before_identity_stabilization() {
let input = bundle(
"arcadia 4; project p \"P\" { include \"work.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"work.arcadia\"; }",
&[(
"work.arcadia",
"sa_function first \"First\" {}\nsa_function second \"Second\" {}\n",
@@ -228,7 +228,7 @@ fn advisory_lint_locations_refer_to_raw_drafts_before_identity_stabilization() {
#[test]
fn source_errors_keep_exact_fragment_unicode_positions() {
let input = bundle(
"arcadia 4; project p \"P\" { include \"child.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"child.arcadia\"; }",
&[(
"child.arcadia",
"// Пример\nsa_function child \"航海\" { parent @missing; }",
@@ -274,7 +274,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
),
] {
let input = bundle(
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
&[("a.arcadia", left), ("b.arcadia", right)],
);
let error = compile_bundle(&input, ProjectLimits::default())
@@ -285,7 +285,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
assert_eq!(error.related[0].path, "a.arcadia");
}
let input = bundle(
"arcadia 4; project p \"P\" { include \"child.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"child.arcadia\"; }",
&[("child.arcadia", "oa_activity p \"Collision\" {}")],
);
let error = compile_bundle(&input, ProjectLimits::default())
@@ -297,7 +297,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
#[test]
fn includes_are_once_only_and_cycles_missing_and_unreachable_files_are_located() {
let mut input = bundle(
"arcadia 4; project p \"P\" { include \"sub/a.arcadia\"; include \"sub/b.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"sub/a.arcadia\"; include \"sub/b.arcadia\"; }",
&[
("sub/a.arcadia", "include \"../shared.arcadia\";"),
("sub/b.arcadia", "include \"../shared.arcadia\";"),
@@ -356,7 +356,7 @@ fn portable_paths_reject_escapes_devices_urls_and_case_collisions() {
);
assert!(resolve_include("../bad.arcadia", "a.arcadia").is_err());
let input = bundle(
"arcadia 4; project p \"P\" {}",
"arcadia 5; project p \"P\" {}",
&[("A.arcadia", ""), ("a.arcadia", "")],
);
assert_eq!(
@@ -370,7 +370,7 @@ fn portable_paths_reject_escapes_devices_urls_and_case_collisions() {
#[test]
fn project_admission_is_aggregate_and_precedes_global_resolution() {
let input = bundle(
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
&[
("a.arcadia", "oa_activity one \"One\" { parent @missing; }"),
("b.arcadia", "oa_activity two \"Two\" {}"),
@@ -591,7 +591,7 @@ fn failed_update_preserves_candidate_and_rejects_stale_index() {
#[test]
fn single_string_and_legacy_apis_remain_explicit_and_filesystem_free() {
let source = "arcadia 4; project p \"P\" { include \"missing.arcadia\"; }";
let source = "arcadia 5; project p \"P\" { include \"missing.arcadia\"; }";
assert!(
language::parse(source).unwrap_err()[0]
.message
@@ -681,7 +681,7 @@ fn json_bundle_rejects_duplicate_file_keys_and_unknown_fields() {
#[test]
fn generated_id_insertion_handles_adjacent_header_tokens_and_remains_exactly_indexed() {
let input = bundle(
"arcadia 4;project p\"P\"{include \"child.arcadia\";}",
"arcadia 5;project p\"P\"{include \"child.arcadia\";}",
&[(
"child.arcadia",
"oa_activity a\"A\"{}diagram d\"D\"{}node n{diagram @d;element @a;}",
@@ -703,7 +703,7 @@ fn generated_id_insertion_handles_adjacent_header_tokens_and_remains_exactly_ind
#[test]
fn duplicate_include_edges_count_toward_admission_and_ordered_references_are_aggregate() {
let input = bundle(
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"a.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"a.arcadia\"; }",
&[("a.arcadia", "")],
);
assert_eq!(
@@ -720,7 +720,7 @@ fn duplicate_include_edges_count_toward_admission_and_ordered_references_are_agg
"project_limit"
);
let input = bundle(
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
&[
(
"a.arcadia",
@@ -763,7 +763,7 @@ fn include_inspection_reuses_strict_entry_fragment_syntax_and_real_spans() {
&source[span.start..span.end],
"include \"../shared.arcadia\";"
);
let entry = "arcadia 4; project p \"P\" { include \"sub/work.arcadia\"; }";
let entry = "arcadia 5; project p \"P\" { include \"sub/work.arcadia\"; }";
assert_eq!(
inspect_includes("project.arcadia", entry, true, ProjectLimits::default()).unwrap()[0]
.target,
@@ -829,7 +829,7 @@ fn comments(source: &str) -> Vec<String> {
#[test]
fn scalar_header_guard_and_project_edits_preserve_exact_crlf_trivia_and_escape_spelling() {
let entry = "arcadia 4;\r\nproject p // project title\r\n \"Original project\" {\r\n description // project reason\r\n \"Before\";\r\n include \"behavior.arcadia\";\r\n}\r\n";
let entry = "arcadia 5;\r\nproject p // project title\r\n \"Original project\" {\r\n description // project reason\r\n \"Before\";\r\n include \"behavior.arcadia\";\r\n}\r\n";
let source = "oa_state_machine machine \"Machine\" {}\r\noa_region region_one \"Region\" { parent @machine; }\r\noa_state first \"First\" { parent @region_one; }\r\noa_state second \"Second\" { parent @region_one; }\r\nstate_transition route // route title\r\n \"Original transition\" {\r\n\tguard // feasibility\r\n\t \"old guard\"; // retain explanation\r\n\tdescription \"Keep\\u0020spelling\";\r\n\tsource @ // endpoint proof\r\n first; target @second;\r\n}\r\n";
let current = compile(&bundle(entry, &[("behavior.arcadia", source)]));
let mut project = current.decoded.project.clone();
@@ -864,7 +864,7 @@ fn scalar_header_guard_and_project_edits_preserve_exact_crlf_trivia_and_escape_s
#[test]
fn alias_updates_touch_only_header_and_reference_tokens_even_with_comments_after_at() {
let input = bundle(
"arcadia 4; project p \"P\" { include \"work.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"work.arcadia\"; }",
&[(
"work.arcadia",
"oa_activity root_work // root rationale\n \"Root\\u0020name\" { description \"root_work is prose\"; }\noa_activity child \"Child\" { parent @ // parent proof\n root_work; }\n",
@@ -887,7 +887,7 @@ fn alias_updates_touch_only_header_and_reference_tokens_even_with_comments_after
#[test]
fn omitted_defaults_stay_omitted_and_new_fields_do_not_reformat_surviving_fields() {
let input = bundle(
"arcadia 4; project p \"P\" { include \"work.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"work.arcadia\"; }",
&[(
"work.arcadia",
"oa_activity work \"Work\" {\r\n\t// owner rationale\r\n\tparent none ; // preserve spacing\r\n}\r\n",
@@ -913,7 +913,7 @@ fn omitted_defaults_stay_omitted_and_new_fields_do_not_reformat_surviving_fields
#[test]
fn kind_change_removes_field_syntax_but_keeps_its_comments_and_other_fields() {
let input = bundle(
"arcadia 4; project p \"P\" { include \"item.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"item.arcadia\"; }",
&[(
"item.arcadia",
"oa_exchange_item item \"Item\" {\n description \"Keep\\u0020this\" ; // description note\n kind // classification rationale\n Information ;\n parent none;\n}\n",
@@ -953,7 +953,7 @@ fn significant_words(source: &str) -> Vec<String> {
#[test]
fn constructor_tag_changes_retain_shared_nested_fields_and_removed_field_comments() {
let input = bundle(
"arcadia 4; project p \"P\" { include \"event.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"event.arcadia\"; }",
&[(
"event.arcadia",
"oa_event timer \"Timer\" { body { Time // time rationale\n { expression // expression rationale\n \"before\" ; kind After; // clock rationale\n }; }; }\n",
@@ -986,7 +986,7 @@ fn constructor_tag_changes_retain_shared_nested_fields_and_removed_field_comment
#[test]
fn scalar_record_constructor_changes_keep_comments_inside_removed_values() {
let input = bundle(
"arcadia 4; project p \"P\" { include \"ports.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"ports.arcadia\"; }",
&[(
"ports.arcadia",
"sa_function work \"Work\" {}\nsa_port input \"Input\" { parent @work; contract Legacy; // contract rationale\n legacy { direction In; // direction rationale\n interface none; // interface rationale\n }; }\n",
@@ -1017,7 +1017,7 @@ fn scalar_record_constructor_changes_keep_comments_inside_removed_values() {
#[test]
fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_identity() {
let input = bundle(
"arcadia 4; project p \"P\" { include \"types.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"types.arcadia\"; }",
&[(
"types.arcadia",
"sa_exchange_item a \"A\" {} sa_exchange_item b \"B\" {}\nsa_event service \"Service\" { body { Signal { items [\n // before A\n @ // A reference rationale\n a // after A\n ,\n // before B\n @b // after B\n ]; }; }; }\n",
@@ -1052,7 +1052,7 @@ fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_ident
#[test]
fn removed_list_items_and_declarations_keep_comments_as_enclosing_annotations() {
let input = bundle(
"arcadia 4; project p \"P\" { include \"types.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"types.arcadia\"; }",
&[(
"types.arcadia",
"sa_exchange_item a \"A\" { // declaration explanation\n } sa_exchange_item b \"B\" {}\nsa_event service \"Service\" { body { Signal { items [\n // explanation for removed A\n @a,\n // explanation for retained B\n @b ]; }; }; }\n",
@@ -1104,7 +1104,7 @@ oa_occurrence message "Message" { parent @conversation; body { Exchange {
}; }; }
"#;
let current = compile(&bundle(
"arcadia 4; project p \"P\" { include \"conversation.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"conversation.arcadia\"; }",
&[("conversation.arcadia", source)],
));
let mut project = current.decoded.project.clone();
@@ -1153,7 +1153,7 @@ oa_occurrence message "Message" { parent @conversation; body { Exchange {
#[test]
fn added_list_items_empty_lists_and_literal_changes_preserve_existing_annotations() {
let input = bundle(
"arcadia 4; project p \"P\" { include \"types.arcadia\"; }",
"arcadia 5; project p \"P\" { include \"types.arcadia\"; }",
&[(
"types.arcadia",
"oa_data_type values \"Values\" { definition { Enumeration { literals [ // enumeration rationale\n ]; }; }; }\n",
+1
View File
@@ -42,6 +42,7 @@ fn make_trace(p: &mut Project, oa: Id, sa: Id) -> Id {
baseline: Some(TraceBaseline {
oa: p.properties(oa).unwrap(),
sa: p.properties(sa).unwrap(),
semantic: None,
}),
};
p.traces.insert(id, t);
+9
View File
@@ -13,5 +13,14 @@ path = "src/main.rs"
oa-core = { path = "../oa-core" }
iced = { version = "0.14", default-features = false, features = ["wgpu", "canvas", "tokio", "x11", "wayland", "advanced"] }
rfd = "0.15"
serde.workspace = true
serde_json.workspace = true
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["rt", "time"] }
[dev-dependencies]
iced_runtime = "0.14"
oa-server = { path = "../oa-server" }
axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio"] }
tempfile = "3"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync"] }
+104 -6
View File
@@ -1,3 +1,4 @@
mod hosted;
use crate::forms::Draft;
use crate::{
document::Document,
@@ -224,6 +225,7 @@ mod direct_scenario_tests {
#[cfg(test)]
mod behavior_lifecycle_tests;
mod process_canvas;
mod state_diagram;
#[cfg(test)]
mod state_diagram_tests;
@@ -279,6 +281,7 @@ impl std::fmt::Display for Choice {
}
#[derive(Debug, Clone, Copy)]
pub enum Pending {
HostedOpen,
New,
Demo,
Open,
@@ -298,6 +301,7 @@ pub struct FilePrompt {
#[derive(Debug, Clone)]
pub enum Message {
Hosted(crate::hosted::Event),
PortAnchor(crate::port_edit::Event),
Connection(crate::connection_edit::Event),
Menu(crate::model_menu::Event),
@@ -316,6 +320,7 @@ pub enum Message {
SelectProject,
Search(String),
StartTransition,
StartSemanticReview(Id),
Workflow(crate::workflow::Event),
CollapseExplorer(Id),
ExplorerGroup(crate::explorer::Group),
@@ -359,6 +364,7 @@ pub enum Message {
BehaviorDiagram(Choice),
StateDiagram(crate::behavior::state_edit::Event),
ScenarioCanvas(crate::behavior::scenario::CanvasEvent),
TopologyCanvas(crate::behavior::topology::CanvasEvent),
PlaceSelected,
HideSelected,
ToggleCollapse,
@@ -407,6 +413,7 @@ pub enum Message {
}
pub struct App {
pub hosted: crate::hosted::State,
pub connection: Option<crate::connection_edit::Session>,
pub model_menu: Option<crate::model_menu::Opened>,
pub source_tree: crate::source_tree::State,
@@ -522,6 +529,7 @@ impl App {
Kind::Activity
},
status: "Coastal rescue example · explore both engineering perspectives".into(),
hosted: Default::default(),
busy: false,
pending: None,
delete_pending: false,
@@ -791,7 +799,11 @@ impl App {
}
fn execute(&mut self, command: Command) -> bool {
let command = crate::port_edit::repair_owner_change(self.project(), command);
let scenario_selection = self.behavior.scenario.created_selection(&command);
let scenario_selection = self
.behavior
.scenario
.created_selection(&command)
.or_else(|| self.behavior.topology.created_selection(&command));
match self.document.execute(command) {
Ok(selected) => {
if let Some(id) = scenario_selection.or(selected) {
@@ -837,7 +849,10 @@ impl App {
let source_version = self.source_version + 1;
let lint_running = self.lint_running;
let guide = std::mem::take(&mut self.guide);
let mut hosted = std::mem::take(&mut self.hosted);
hosted.leave_document();
*self = Self::with_project(project);
self.hosted = hosted;
self.window_size = window_size;
self.canvas_size = canvas_size;
self.source_version = source_version;
@@ -899,8 +914,12 @@ impl App {
}
}
pub fn view(&self) -> Element<'_, Message> {
crate::context_menu::Popup::new(crate::views::view(self), crate::model_menu::popup(self))
.into()
let base = crate::context_menu::Popup::new(
crate::views::view(self),
crate::model_menu::popup(self),
)
.into();
crate::hosted::view::overlay(self, base)
}
pub fn subscription(&self) -> Subscription<Message> {
Subscription::batch([
@@ -956,6 +975,10 @@ impl App {
if matches!(
&message,
Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Scrolled(..))
| Message::TopologyCanvas(
crate::behavior::topology::CanvasEvent::Scrolled(..)
| crate::behavior::topology::CanvasEvent::Viewport(..)
)
) {
return self.handle(message);
}
@@ -1008,6 +1031,7 @@ impl App {
&& !matches!(
message,
Message::Saved(_)
| Message::Hosted(crate::hosted::Event::Completed(..))
| Message::Loaded(_)
| Message::IncludesLoaded(_, _)
| Message::SavePath(_)
@@ -1027,6 +1051,10 @@ impl App {
message,
Message::Escape | Message::Request(Pending::Close(_))
) {
if self.hosted.visible {
self.hosted.visible = false;
return Task::none();
}
if self.workflow.take().is_some() {
return Task::none();
}
@@ -1051,6 +1079,7 @@ impl App {
| Message::Lint(_)
| Message::Linted(_, _)
| Message::Saved(_)
| Message::Hosted(crate::hosted::Event::Completed(..))
| Message::Loaded(_)
| Message::IncludesLoaded(_, _)
| Message::SavePath(_)
@@ -1058,7 +1087,8 @@ impl App {
| Message::Exported(_)
);
if !modal_event
&& ((self.workflow.is_some() && !matches!(message, Message::Workflow(_)))
&& ((self.hosted.visible && !matches!(message, Message::Hosted(_)))
|| (self.workflow.is_some() && !matches!(message, Message::Workflow(_)))
|| (self.file_prompt.is_some()
&& !matches!(
message,
@@ -1116,6 +1146,11 @@ impl App {
let entering_catalog =
self.page != Page::Catalog && matches!(message, Message::Page(Page::Catalog));
let mut task = self.handle(message);
if let Some(context) = self.topology_context() {
self.behavior
.topology
.enter(context.document, context.owner);
}
if self.connection.as_ref().is_some_and(|session| {
Some(session.context) != self.route_context()
|| self.page != Page::Diagram
@@ -1171,6 +1206,7 @@ impl App {
}
fn handle(&mut self, message: Message) -> Task<Message> {
match message {
Message::Hosted(event) => return self.hosted_event(event),
Message::ScenarioCanvas(event) => {
use crate::behavior::scenario::{self, CanvasEvent};
let context = event.context();
@@ -1267,6 +1303,7 @@ impl App {
}
}
}
Message::TopologyCanvas(event) => return self.edit_process_canvas(event),
Message::StateDiagram(event) => return self.edit_state_diagram(event),
Message::BehaviorOwner(choice) => {
if self.apply()
@@ -1656,12 +1693,57 @@ impl App {
}
}
}
Message::StartSemanticReview(selected) => {
if self.apply() {
match crate::semantic_review::State::new(self.project(), selected) {
Ok(state) => {
self.workflow =
Some(crate::workflow::Workflow::Semantic(Box::new(state)))
}
Err(error) => self.status = error,
}
}
}
Message::Workflow(event) => {
if matches!(event, crate::workflow::Event::Cancel) {
self.workflow = None;
return Task::none();
}
if let Some(mut workflow) = self.workflow.take() {
if let crate::workflow::Workflow::Semantic(state) = &mut workflow {
match event {
crate::workflow::Event::Semantic(event) => {
state.update(self.project(), event)
}
crate::workflow::Event::Apply => {
let focus = state.target(self.project());
match state.command(self.project()) {
Ok(command) => {
if self.execute(command) {
if let Some(target) = focus {
self.selected = target;
if let Some(layer) = self.project().layer_of(target)
{
self.layer = layer;
}
self.refresh_draft();
}
self.status = "Semantic review applied · model, source and baseline form one undoable change".into();
return Task::none();
}
state.error = Some(self.status.clone());
}
Err(error) => {
self.status = error.clone();
state.error = Some(error);
}
}
}
_ => {}
}
self.workflow = Some(workflow);
return Task::none();
}
let stay_in_catalog = self.page == Page::Catalog
&& matches!(&workflow, crate::workflow::Workflow::Ports(_));
let transition_focus = match &workflow {
@@ -1854,6 +1936,9 @@ impl App {
Ok(Some(command)) => {
if self.execute(command) {
self.behavior.acknowledge_commit();
if self.topology_context().is_some() {
return self.reveal_process_selection();
}
}
}
Ok(None) => {}
@@ -2425,6 +2510,9 @@ impl App {
return self.handle(Message::Save(false));
}
Message::Save(save_as) => {
if !save_as && self.hosted.session.is_some() {
return self.save_hosted();
}
if !self.apply_behavior() || !self.apply_source() || !self.apply() {
self.continue_after_save = false;
self.pending = None;
@@ -2476,6 +2564,9 @@ impl App {
}
self.disk_project = Some(disk);
self.path = Some(receipt.path);
self.hosted.session = None;
self.hosted.review = None;
self.hosted.uncertain = None;
self.document.mark_saved(receipt.snapshot);
self.prune_closed_files(!self.document.dirty());
self.status = "All source files saved".into();
@@ -2581,8 +2672,10 @@ impl App {
}
Message::WindowResized(size) => {
// The canvas can retain its capped size during a real window
// resize. Invalidate any scenario pointer/slot gesture anyway.
if self.window_size != size && self.scenario_context().is_some() {
// resize. Invalidate any sequence pointer/slot gesture anyway.
if self.window_size != size
&& (self.scenario_context().is_some() || self.topology_context().is_some())
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
self.window_size = size;
@@ -2812,6 +2905,7 @@ impl App {
fn proceed(&mut self, pending: Pending) -> Task<Message> {
self.pending = None;
match pending {
Pending::HostedOpen => self.open_hosted(),
Pending::New => {
self.reset(Project::empty(), None);
Task::none()
@@ -2970,6 +3064,10 @@ impl App {
}
}
Err(error) => {
// Installing a local repair workspace changes its destination,
// even while the last valid graphical model stays visible. A
// failed/cancelled Open that installs nothing retains its binding.
self.hosted.leave_document();
self.canvas_document = Id::new_v4();
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.path = Some(path);
@@ -322,14 +322,18 @@ fn passive_behavior_canvas_events_do_not_apply_property_drafts() {
Message::Field("/name".into(), "Unapplied state name".into()),
);
assert!(app.draft_dirty());
let context = behavior::topology::Context {
document: app.canvas_document,
owner: fixture.machine,
revision: app.document.revision(),
epoch: app.canvas_epoch,
};
for event in [
behavior::Event::Topology(behavior::topology::Event::Viewport(iced::Size::new(
900., 600.,
))),
behavior::Event::Topology(behavior::topology::Event::Zoom(0.7)),
behavior::Event::Topology(behavior::topology::Event::Fit),
behavior::topology::CanvasEvent::Viewport(context, iced::Size::new(900., 600.)),
behavior::topology::CanvasEvent::Zoom(context, Some(0.7)),
behavior::topology::CanvasEvent::Zoom(context, None),
] {
send(app, Message::Behavior(event));
send(app, Message::TopologyCanvas(event));
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert!(app.draft_dirty());
assert!(!app.behavior.has_pending());
+568
View File
@@ -0,0 +1,568 @@
//! Desktop destination lifecycle; HTTP never becomes a second editing history.
use super::*;
use crate::hosted::{
Event, Operation, Outcome, Request, Review, Session,
client::{Connection, Failure, Remote},
};
impl App {
pub fn hosted_label(&self) -> String {
self.hosted.session.as_ref().map_or_else(
|| {
if self.dirty() {
"Unsaved changes".into()
} else if self.path.is_some() {
"All changes saved".into()
} else {
"Local project".into()
}
},
|session| {
format!(
"Hosted · revision {} · {}",
session.revision,
if self.dirty() {
"local changes"
} else {
"saved"
}
)
},
)
}
pub fn save_label(&self) -> &'static str {
if self.hosted.session.is_some() {
"Save to server"
} else {
"Save all"
}
}
fn hosted_problem(&mut self, detail: impl Into<String>) {
let detail = detail.into();
self.status = detail.clone();
self.hosted.error = Some(Failure {
detail,
code: "desktop_hosted".into(),
status: None,
uncertain: false,
published: None,
});
}
fn hosted_ready(&mut self) -> bool {
if self.pending_recovery.is_some() {
self.continue_after_save = false;
self.pending = None;
self.hosted.visible = false;
self.page = Page::Text;
self.status =
"Recover the interrupted source save before changing storage destinations.".into();
return false;
}
if !self.apply_behavior() || !self.apply_source() || !self.apply() {
self.hosted.visible = false;
self.continue_after_save = false;
self.pending = None;
return false;
}
if self.source_workspace.detached_dirty() {
self.hosted.visible = false;
self.page = Page::Text;
self.status =
"Reattach or revert detached source drafts before saving to a server.".into();
self.continue_after_save = false;
self.pending = None;
return false;
}
true
}
fn hosted_start(
&mut self,
operation: Operation,
connection: Connection,
resource_id: Option<Id>,
) -> Task<Message> {
let request = Request {
id: Id::new_v4(),
document: self.canvas_document,
revision: self.document.revision(),
operation,
connection,
resource_id,
etag: self.hosted.session.as_ref().map(|s| s.etag.clone()),
snapshot: self.document.snapshot(),
};
self.hosted.request = Some(request.clone());
if operation != Operation::Check {
self.hosted.error = None;
}
self.busy = true;
self.status = match operation {
Operation::Catalog(_) => "Reading hosted models…",
Operation::Open(_) => "Opening hosted model…",
Operation::Publish => "Publishing source project…",
Operation::Save => "Saving to server…",
Operation::Check => "Checking current server revision…",
}
.into();
let query = self.hosted.query.clone();
let after = if operation == Operation::Catalog(true) {
self.hosted.next
} else {
None
};
Task::perform(
async move {
let result = match operation {
Operation::Catalog(_) => request
.connection
.catalog(&query, after)
.await
.map(Outcome::Catalog),
Operation::Open(id) => request.connection.open(id).await.map(Outcome::Remote),
Operation::Publish => request
.connection
.publish(request.snapshot.bundle.clone())
.await
.map(Outcome::Remote),
Operation::Save => request
.connection
.save(
request.resource_id.expect("save has a resource"),
request.etag.as_deref().expect("save has a precondition"),
request.snapshot.bundle.clone(),
)
.await
.map(Outcome::Remote),
Operation::Check => request
.connection
.open(request.resource_id.expect("check has a resource"))
.await
.map(Outcome::Remote),
};
Message::Hosted(Event::Completed(request.id, request.document, result))
},
|message| message,
)
}
pub(super) fn open_hosted(&mut self) -> Task<Message> {
if self.pending_recovery.is_some() {
self.hosted_problem(
"Recover the interrupted source save before opening another model.",
);
return Task::none();
}
if let Some((connection, id)) = self.hosted.open_target.take() {
return self.hosted_start(Operation::Open(id), connection, Some(id));
}
Task::none()
}
pub(super) fn save_hosted(&mut self) -> Task<Message> {
if !self.hosted_ready() {
return Task::none();
}
if self.hosted.review.is_some() {
self.hosted.visible = true;
self.hosted_problem("The server has a different version. Review it before saving; your local work is retained.");
self.continue_after_save = false;
self.pending = None;
return Task::none();
}
if let Some(session) = &self.hosted.session {
return self.hosted_start(
Operation::Save,
session.connection.clone(),
Some(session.resource_id),
);
}
Task::none()
}
fn check_hosted(&mut self) -> Task<Message> {
let destination = self
.hosted
.uncertain
.as_ref()
.and_then(|r| r.resource_id.map(|id| (r.connection.clone(), id)))
.or_else(|| {
self.hosted
.session
.as_ref()
.map(|s| (s.connection.clone(), s.resource_id))
});
if let Some((connection, id)) = destination {
return self.hosted_start(Operation::Check, connection, Some(id));
}
self.hosted_problem("The publication result is unknown. Refresh the catalog and inspect the hosted models before choosing another action.");
Task::none()
}
fn acknowledge_hosted(&mut self, request: &Request, remote: Remote) -> Task<Message> {
if remote.compiled.bundle != request.snapshot.bundle {
self.hosted.review = Some(Review {
connection: request.connection.clone(),
remote,
});
self.hosted.review_path = None;
self.hosted.visible = true;
self.hosted_problem("The server returned a different source version. Review it; the local history has been preserved.");
self.continue_after_save = false;
self.pending = None;
return Task::none();
}
self.hosted.session = Some(Session {
connection: request.connection.clone(),
resource_id: remote.resource_id,
revision: remote.revision,
etag: remote.etag,
});
self.path = None;
self.disk_project = None;
self.document.mark_saved(request.snapshot.clone());
self.hosted.uncertain = None;
self.hosted.review = None;
self.hosted.review_path = None;
self.hosted.error = None;
self.status = format!(
"Hosted revision {} saved. Undo remains local until your next Save.",
remote.revision
);
// A late receipt may save an older snapshot; it must not close newer work.
if self.continue_after_save {
self.continue_after_save = false;
if request.revision == self.document.revision()
&& !self.dirty()
&& let Some(pending) = self.pending.take()
{
return self.proceed(pending);
}
self.pending = None;
}
Task::none()
}
pub(super) fn hosted_event(&mut self, event: Event) -> Task<Message> {
match event {
Event::Show => {
self.hosted.visible = true;
self.model_menu = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
Event::Close => self.hosted.visible = false,
Event::Base(base) => {
self.hosted.base = base;
self.hosted.connection = None;
self.hosted.catalog.clear();
self.hosted.invalidate_catalog();
self.hosted.selected = None;
}
Event::Token(token) => {
self.hosted.token = token;
self.hosted.connection = None;
self.hosted.catalog.clear();
self.hosted.selected = None;
self.hosted.invalidate_catalog();
}
Event::Query(query) => {
if self.hosted.query != query {
self.hosted.query = query;
self.hosted.selected = None;
self.hosted.invalidate_catalog();
}
}
Event::Connect => match Connection::new(&self.hosted.base, self.hosted.token.clone()) {
Ok(connection) => {
self.hosted.invalidate_catalog();
return self.hosted_start(Operation::Catalog(false), connection, None);
}
Err(error) => {
self.status = error.detail.clone();
self.hosted.error = Some(error);
}
},
Event::More => {
if !self.hosted.catalog_stale
&& self.hosted.next.is_some()
&& let Some(connection) = &self.hosted.connection
{
return self.hosted_start(Operation::Catalog(true), connection.clone(), None);
}
}
Event::Select(id) => self.hosted.selected = Some(id),
Event::OpenSelected => {
if let (Some(connection), Some(id)) =
(&self.hosted.connection, self.hosted.selected)
{
self.hosted.open_target = Some((connection.clone(), id));
self.hosted.visible = false;
return self.handle(Message::Request(Pending::HostedOpen));
}
}
Event::Publish => {
if !self.hosted_ready() {
return Task::none();
}
let connection = match Connection::new(&self.hosted.base, self.hosted.token.clone())
{
Ok(connection) => connection,
Err(error) => {
self.status = error.detail.clone();
self.hosted.error = Some(error);
return Task::none();
}
};
return self.hosted_start(Operation::Publish, connection, None);
}
Event::Save => return self.save_hosted(),
Event::Check => return self.check_hosted(),
Event::ReviewFile(path) => {
if let Some(source) = self
.hosted
.review
.as_ref()
.and_then(|remote| remote.remote.compiled.bundle.files.get(&path))
{
self.hosted.review_editor = text_editor::Content::with_text(source);
self.hosted.review_path = Some(path);
}
}
Event::ReviewAction(action) => {
if !action.is_edit() {
self.hosted.review_editor.perform(action);
}
}
Event::ReviewBack => self.hosted.review_path = None,
Event::Reload => {
if !self.hosted_ready() {
return Task::none();
}
if let Some(review) = self.hosted.review.clone() {
let remote = review.remote;
let connection = review.connection;
{
match self.document.apply_compiled((*remote.compiled).clone()) {
Ok(_) => {
self.document.mark_saved(remote.compiled.clone());
self.hosted.session = Some(Session {
connection,
resource_id: remote.resource_id,
revision: remote.revision,
etag: remote.etag,
});
self.path = None;
self.disk_project = None;
self.hosted.review = None;
self.hosted.review_path = None;
self.hosted.uncertain = None;
self.hosted.error = None;
self.sync();
self.hosted.visible = false;
self.status = "Server version loaded. Undo restores the previous accepted local version.".into();
}
Err(error) => self.hosted_problem(error.to_string()),
}
}
}
}
Event::Local => {
self.hosted.visible = false;
return self.handle(Message::Save(true));
}
Event::Forget => {
if self.hosted.session.is_some() {
self.hosted_problem("Save as local before disconnecting this hosted model.");
} else {
self.hosted = Default::default();
self.hosted.visible = true;
}
}
Event::Completed(id, document, result) => {
let Some(request) = self.hosted.request.as_ref() else {
return Task::none();
};
if request.id != id
|| request.document != document
|| document != self.canvas_document
{
return Task::none();
}
let request = self.hosted.request.take().expect("matching request");
self.busy = false;
// A catalog is a read snapshot, never a live mirror. Known or
// uncertain writes and observed newer revisions invalidate only
// the service whose results are currently displayed.
let catalog_changed = match &result {
Ok(Outcome::Remote(remote)) => {
matches!(request.operation, Operation::Publish | Operation::Save)
// A changed bound head can enter/leave the active
// filter or page even when no row was displayed.
|| self.hosted.session.as_ref().is_some_and(|session| {
session.connection.base() == request.connection.base()
&& session.resource_id == remote.resource_id
&& session.etag != remote.etag
})
|| self.hosted.uncertain.as_ref().is_some_and(|failed| {
failed.operation == Operation::Save
&& failed.connection.base() == request.connection.base()
&& failed.resource_id == Some(remote.resource_id)
&& failed.etag.as_ref().is_some_and(|etag| etag != &remote.etag)
})
|| self.hosted.catalog.iter().any(|item| {
item.resource_id == remote.resource_id
&& item.revision != remote.revision
})
}
Err(error) => {
matches!(request.operation, Operation::Publish | Operation::Save)
&& (error.uncertain || error.status == Some(412))
}
Ok(Outcome::Catalog(_)) => false,
};
if catalog_changed
&& self
.hosted
.connection
.as_ref()
.is_some_and(|connection| connection.base() == request.connection.base())
{
self.hosted.invalidate_catalog();
}
match result {
Err(error) => {
let conflict =
error.status == Some(412) && request.operation == Operation::Save;
if error.uncertain || conflict {
let mut failed = request.clone();
if let Some(id) = error.published {
failed.resource_id = Some(id);
}
if matches!(request.operation, Operation::Save | Operation::Publish) {
self.hosted.uncertain = Some(failed);
}
}
self.status = error.detail.clone();
self.hosted.error = Some(error);
self.hosted.visible = true;
self.continue_after_save = false;
self.pending = None;
if conflict {
return self.check_hosted();
}
}
Ok(Outcome::Catalog(catalog)) => {
if let Some(session) = &mut self.hosted.session
&& session.connection.base() == request.connection.base()
{
session.connection = request.connection.clone();
}
if let Some(failed) = &mut self.hosted.uncertain
&& failed.connection.base() == request.connection.base()
{
failed.connection = request.connection.clone();
}
if let Some(review) = &mut self.hosted.review
&& review.connection.base() == request.connection.base()
{
review.connection = request.connection.clone();
}
self.hosted.connection = Some(request.connection);
if request.operation != Operation::Catalog(true) {
self.hosted.catalog.clear();
}
for item in catalog.items {
if !self
.hosted
.catalog
.iter()
.any(|old| old.resource_id == item.resource_id)
{
self.hosted.catalog.push(item);
}
}
self.hosted.next = catalog.next_after;
self.hosted.catalog_stale = false;
if !self
.hosted
.catalog
.iter()
.any(|item| Some(item.resource_id) == self.hosted.selected)
{
self.hosted.selected = None;
}
self.status = format!("{} hosted models shown", self.hosted.catalog.len());
}
Ok(Outcome::Remote(remote)) => match request.operation {
Operation::Open(_) => {
match Document::from_bundle(remote.compiled.bundle.clone(), LIMITS) {
Ok(document) => {
self.reset(document.project().clone(), None);
self.document = document;
self.source_workspace =
Workspace::new(self.document.bundle(), true);
self.sync();
self.hosted.session = Some(Session {
connection: request.connection,
resource_id: remote.resource_id,
revision: remote.revision,
etag: remote.etag,
});
self.status = "Hosted source project opened. Edits and Undo stay local until Save to server.".into();
}
Err(error) => self.hosted_problem(error.to_string()),
}
}
Operation::Publish | Operation::Save => {
return self.acknowledge_hosted(&request, remote);
}
Operation::Check => {
let acknowledged = self
.hosted
.uncertain
.as_ref()
.filter(|failed| {
failed.connection.base() == request.connection.base()
&& failed.resource_id == Some(remote.resource_id)
&& failed.snapshot.bundle == remote.compiled.bundle
})
.cloned();
if let Some(failed) = acknowledged {
return self.acknowledge_hosted(&failed, remote);
}
if self.hosted.session.as_ref().is_some_and(|session| {
session.connection.base() == request.connection.base()
&& session.resource_id == remote.resource_id
&& session.etag == remote.etag
}) {
self.hosted.review = None;
self.hosted.review_path = None;
self.status =
"The server revision is unchanged. Local edits are retained."
.into();
} else {
self.status = format!(
"Server revision {} is available. Review before replacing local work.",
remote.revision
);
self.hosted.review = Some(Review {
connection: request.connection.clone(),
remote,
});
self.hosted.review_path = None;
self.hosted.visible = true;
}
}
Operation::Catalog(_) => {
self.hosted_problem("Unexpected response to catalog request.")
}
},
}
}
}
Task::none()
}
}
#[cfg(test)]
mod tests;
+817
View File
@@ -0,0 +1,817 @@
use super::*;
use crate::hosted::client::Secret;
fn client() -> Connection {
Connection::new("http://127.0.0.1:8787", Secret::default()).unwrap()
}
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::Hosted(event));
}
fn rename(app: &mut App, name: &str) {
app.document
.execute(Command::Rename(app.project().id, name.into()))
.unwrap();
app.sync();
}
fn bind(app: &mut App) -> Id {
let resource = Id::new_v4();
app.hosted.session = Some(Session {
connection: client(),
resource_id: resource,
revision: 1,
etag: "\"opaque-base\"".into(),
});
resource
}
fn remote(snapshot: Arc<CompiledSourceProject>, resource_id: Id, revision: u64) -> Remote {
Remote {
resource_id,
revision,
etag: format!("\"opaque-{revision}\""),
compiled: snapshot,
}
}
fn complete(app: &mut App, result: Result<Outcome, Failure>) {
let r = app.hosted.request.as_ref().unwrap();
send(app, Event::Completed(r.id, r.document, result));
}
fn failure(status: Option<u16>, uncertain: bool) -> Failure {
Failure {
detail: "Test failure".into(),
code: "test".into(),
status,
uncertain,
published: None,
}
}
#[test]
fn hosted_publish_acknowledges_only_the_sent_snapshot_and_retains_undo() {
let mut app = App::with_project(Project::empty());
rename(&mut app, "Published revision");
app.path = Some(PathBuf::from("local.arcadia"));
send(&mut app, Event::Publish);
let captured = app.hosted.request.as_ref().unwrap().snapshot.clone();
assert!(app.busy);
rename(&mut app, "Later local edit");
let resource = Id::new_v4();
complete(&mut app, Ok(Outcome::Remote(remote(captured, resource, 1))));
assert!(!app.busy);
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, resource);
assert!(app.path.is_none());
assert_eq!(app.project().name, "Later local edit");
assert!(app.document.dirty());
let _ = app.update(Message::Undo);
assert_eq!(app.project().name, "Published revision");
assert!(!app.document.dirty());
}
#[test]
fn hosted_conflict_fetches_review_without_adoption_and_reload_is_undoable() {
let mut app = App::with_project(Project::empty());
let resource = bind(&mut app);
let mut other = Document::from_bundle(app.document.bundle().clone(), LIMITS).unwrap();
other
.execute(Command::Rename(other.project().id, "Other engineer".into()))
.unwrap();
rename(&mut app, "Local engineering work");
let local = app.document.snapshot();
send(&mut app, Event::Save);
complete(&mut app, Err(failure(Some(412), false)));
assert_eq!(
app.hosted.request.as_ref().unwrap().operation,
Operation::Check
);
assert!(Arc::ptr_eq(&local, &app.document.snapshot()));
complete(
&mut app,
Ok(Outcome::Remote(remote(other.snapshot(), resource, 2))),
);
assert!(Arc::ptr_eq(&local, &app.document.snapshot()));
assert_eq!(app.hosted.session.as_ref().unwrap().etag, "\"opaque-base\"");
send(&mut app, Event::Save);
assert!(!app.busy);
assert!(app.hosted.request.is_none());
send(&mut app, Event::Reload);
assert_eq!(app.project().name, "Other engineer");
assert!(!app.document.dirty());
let _ = app.update(Message::Undo);
assert_eq!(app.project().name, "Local engineering work");
assert!(app.document.dirty());
}
#[test]
fn hosted_unknown_save_is_acknowledged_by_exact_readback_without_retry() {
let mut app = App::with_project(Project::empty());
let resource = bind(&mut app);
rename(&mut app, "Saved despite lost response");
send(&mut app, Event::Save);
let captured = app.hosted.request.as_ref().unwrap().snapshot.clone();
complete(&mut app, Err(failure(None, true)));
assert!(app.document.dirty());
assert!(!app.busy);
assert!(app.hosted.request.is_none());
send(&mut app, Event::Check);
complete(&mut app, Ok(Outcome::Remote(remote(captured, resource, 2))));
assert!(!app.document.dirty());
assert!(app.hosted.uncertain.is_none());
assert_eq!(app.hosted.session.as_ref().unwrap().revision, 2);
}
#[test]
fn hosted_stale_completions_do_not_clear_current_busy_or_adopt_another_document() {
let mut app = App::with_project(Project::empty());
bind(&mut app);
send(&mut app, Event::Save);
let request = app.hosted.request.as_ref().unwrap().clone();
send(
&mut app,
Event::Completed(Id::new_v4(), request.document, Err(failure(None, true))),
);
assert!(app.busy);
assert_eq!(app.hosted.request.as_ref().unwrap().id, request.id);
app.reset(Project::empty(), None);
let current = app.document.snapshot();
send(
&mut app,
Event::Completed(
request.id,
request.document,
Ok(Outcome::Remote(remote(request.snapshot, Id::new_v4(), 3))),
),
);
assert!(Arc::ptr_eq(&current, &app.document.snapshot()));
assert!(app.hosted.session.is_none());
}
#[test]
fn hosted_invalid_and_detached_source_drafts_never_start_network_writes() {
let mut app = App::with_project(Project::empty());
bind(&mut app);
app.source_workspace
.edit("arcadia 5; ?".into(), app.source_editor.cursor());
let draft = app.source_workspace.bundle();
let accepted = app.document.snapshot();
send(&mut app, Event::Save);
assert!(app.hosted.request.is_none());
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.source_workspace.bundle(), draft);
assert!(!app.busy);
}
#[test]
fn hosted_open_waits_for_unsaved_guard_and_failed_open_preserves_current_document() {
let mut app = App::with_project(Project::empty());
rename(&mut app, "Keep this work");
app.hosted.connection = Some(client());
app.hosted.selected = Some(Id::new_v4());
send(&mut app, Event::OpenSelected);
assert!(matches!(app.pending, Some(Pending::HostedOpen)));
assert!(app.hosted.request.is_none());
let _ = app.update(Message::Discard);
let previous = app.document.snapshot();
complete(&mut app, Err(failure(Some(401), false)));
assert!(Arc::ptr_eq(&previous, &app.document.snapshot()));
assert_eq!(app.project().name, "Keep this work");
assert!(app.document.dirty());
}
#[test]
fn hosted_only_successful_source_save_as_changes_the_active_destination() {
let mut app = App::with_project(Project::empty());
let resource = bind(&mut app);
rename(&mut app, "Local fallback");
let failure = SaveFailure {
document: app.canvas_document,
message: "Disk failed".into(),
recovery: None,
};
let _ = app.update(Message::Saved(Err(failure)));
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, resource);
let directory = tempfile::tempdir().unwrap();
let target = directory.path().join(&app.document.bundle().entry);
let receipt = write_snapshot(
app.canvas_document,
app.document.revision(),
app.document.snapshot(),
target.clone(),
None,
)
.unwrap();
let _ = app.update(Message::Saved(Ok(Some(receipt))));
assert!(app.hosted.session.is_none());
assert_eq!(app.path, Some(target));
assert!(!app.document.dirty());
assert!(app.document.can_undo());
}
#[test]
fn hosted_modal_blocks_underlying_edits_and_credentials_are_redacted() {
let mut app = App::with_project(Project::empty());
send(&mut app, Event::Show);
let original = app.document.snapshot();
let _ = app.update(Message::Command(Command::Rename(
app.project().id,
"Underlying edit".into(),
)));
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
let token = "a-secret-never-in-a-diagnostic";
let event = Message::Hosted(Event::Token(Secret(token.into())));
assert!(!format!("{event:?}").contains(token));
let _ = app.update(event);
send(&mut app, Event::Forget);
assert!(app.hosted.token.0.is_empty());
}
#[test]
fn hosted_detached_drafts_and_recovery_remain_available_after_rejected_publish() {
let mut app = App::with_project(Project::empty());
let mut bundle = app.document.bundle().clone();
bundle
.files
.insert("detached.arcadia".into(), "// kept draft".into());
let reachable = app.document.bundle().files.keys().cloned().collect();
app.source_workspace.discovered(&bundle, reachable);
app.source_workspace
.switch("detached.arcadia", app.source_editor.cursor());
app.source_workspace.edit(
"// unsaved detached rationale".into(),
app.source_editor.cursor(),
);
send(&mut app, Event::Publish);
assert!(app.hosted.request.is_none());
assert!(app.source_workspace.detached_dirty());
assert_eq!(
app.source_workspace.files["detached.arcadia"].text,
"// unsaved detached rationale"
);
app.pending_recovery = Some(PathBuf::from("interrupted.arcadia"));
send(&mut app, Event::Publish);
assert!(app.hosted.request.is_none());
assert_eq!(
app.pending_recovery,
Some(PathBuf::from("interrupted.arcadia"))
);
}
#[test]
fn hosted_failed_cross_service_publication_checks_and_reloads_its_own_resource() {
let mut app = App::with_project(Project::empty());
let old_id = bind(&mut app);
app.hosted.base = "http://127.0.0.1:9876".into();
rename(&mut app, "New publication");
send(&mut app, Event::Publish);
let new_id = Id::new_v4();
let mut error = failure(None, true);
error.published = Some(new_id);
complete(&mut app, Err(error));
send(&mut app, Event::Check);
let request = app.hosted.request.as_ref().unwrap();
assert_eq!(request.resource_id, Some(new_id));
assert_eq!(request.connection.base(), "http://127.0.0.1:9876/");
let mut changed = Document::from_bundle(app.document.bundle().clone(), LIMITS).unwrap();
changed
.execute(Command::Rename(
changed.project().id,
"Changed after publication".into(),
))
.unwrap();
complete(
&mut app,
Ok(Outcome::Remote(remote(changed.snapshot(), new_id, 2))),
);
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, old_id);
send(&mut app, Event::Reload);
let session = app.hosted.session.as_ref().unwrap();
assert_eq!(session.resource_id, new_id);
assert_eq!(session.connection.base(), "http://127.0.0.1:9876/");
assert_eq!(app.project().name, "Changed after publication");
}
async fn dispatch(app: &mut App, message: Message) {
use iced::futures::StreamExt;
let mut tasks = vec![app.update(message)];
while let Some(task) = tasks.pop() {
if let Some(mut stream) = iced_runtime::task::into_stream(task) {
while let Some(action) = stream.next().await {
if let iced_runtime::Action::Output(message) = action {
tasks.push(app.update(message));
}
}
}
}
}
#[tokio::test]
async fn hosted_actual_app_tasks_publish_edit_conflict_correct_credentials_reload_and_open() {
let directory = tempfile::tempdir().unwrap();
let store = Arc::new(oa_server::store::Store::open(directory.path()).unwrap());
let token = "current-secret-for-hosted-regression-0123456789";
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let (stop, done) = tokio::sync::oneshot::channel::<()>();
let server = tokio::spawn(async move {
axum::serve(listener, oa_server::app(store, Some(token.into())))
.with_graceful_shutdown(async {
let _ = done.await;
})
.await
.unwrap();
});
let mut app = App::with_project(Project::empty());
let mut bundle = app.document.bundle().clone();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.insert_str(0, "// Hosted rationale\n");
app.document.apply_bundle(bundle).unwrap();
app.sync();
app.hosted.base = base.clone();
app.hosted.token = Secret(token.into());
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
assert!(!app.hosted.catalog_stale);
assert!(app.hosted.catalog.is_empty());
assert!(
app.hosted
.catalog_message()
.unwrap()
.starts_with("No models found")
);
dispatch(&mut app, Message::Hosted(Event::Publish)).await;
assert!(app.hosted.catalog_stale);
assert!(
!app.hosted
.catalog_message()
.unwrap()
.starts_with("No models found")
);
dispatch(&mut app, Message::Hosted(Event::Close)).await;
dispatch(&mut app, Message::Hosted(Event::Show)).await;
assert!(app.hosted.catalog_stale);
assert!(
app.hosted.request.is_none(),
"Showing the dialog does not poll"
);
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
assert!(!app.hosted.catalog_stale);
assert_eq!(app.hosted.catalog.len(), 1);
assert_eq!(app.hosted.catalog[0].revision, 1);
dispatch(&mut app, Message::Hosted(Event::Close)).await;
let session = app
.hosted
.session
.clone()
.expect("publish binds a resource");
assert!(!app.busy);
assert!(!app.document.dirty());
assert!(
app.document.bundle().files[&app.document.bundle().entry].contains("// Hosted rationale")
);
let project_id = app.project().id;
dispatch(
&mut app,
Message::Command(Command::Rename(project_id, "Graphical update".into())),
)
.await;
dispatch(&mut app, Message::Save(false)).await;
assert!(app.hosted.catalog_stale);
assert_eq!(
app.hosted.catalog[0].revision, 1,
"Old results stay identified as a snapshot"
);
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
assert!(!app.hosted.catalog_stale);
assert_eq!(app.hosted.catalog[0].revision, 2);
assert_eq!(app.hosted.catalog[0].name, "Graphical update");
let external = Connection::new(&base, Secret(token.into())).unwrap();
let remote = external.open(session.resource_id).await.unwrap();
assert_eq!(remote.compiled.decoded.project.name, "Graphical update");
let mut other = Document::from_bundle(remote.compiled.bundle.clone(), LIMITS).unwrap();
other
.execute(Command::Rename(project_id, "API engineering change".into()))
.unwrap();
external
.save(session.resource_id, &remote.etag, other.bundle().clone())
.await
.unwrap();
dispatch(
&mut app,
Message::Command(Command::Rename(project_id, "Local pending change".into())),
)
.await;
dispatch(&mut app, Message::Save(false)).await;
assert!(app.hosted.review.is_some());
assert_eq!(app.project().name, "Local pending change");
assert!(app.document.dirty());
// Simulate a retired credential in every preexisting reconciliation handle.
let old =
Connection::new(&base, Secret("retired-secret-012345678901234567890".into())).unwrap();
app.hosted.session.as_mut().unwrap().connection = old.clone();
app.hosted.uncertain.as_mut().unwrap().connection = old.clone();
app.hosted.review.as_mut().unwrap().connection = old;
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
let reconciled_write = app.hosted.uncertain.clone().unwrap();
// Reload the captured review directly. A fresh Check must not hide a stale
// credential in that review by replacing it with a newer response.
dispatch(&mut app, Message::Hosted(Event::Reload)).await;
assert_eq!(app.project().name, "API engineering change");
dispatch(&mut app, Message::Undo).await;
assert_eq!(app.project().name, "Local pending change");
dispatch(&mut app, Message::Save(false)).await;
assert!(!app.document.dirty());
// Independently exercise the corrected uncertain-write connection.
app.hosted.uncertain = Some(reconciled_write);
dispatch(&mut app, Message::Hosted(Event::Check)).await;
assert!(app.hosted.error.is_none());
assert!(app.hosted.uncertain.is_none());
let accepted = external.open(session.resource_id).await.unwrap();
assert_eq!(accepted.compiled.bundle, *app.document.bundle());
assert_eq!(
accepted.compiled.decoded.project.name,
"Local pending change"
);
let mut second = App::with_project(Project::empty());
second.hosted.base = base;
second.hosted.token = Secret(token.into());
dispatch(&mut second, Message::Hosted(Event::Connect)).await;
assert!(
second
.hosted
.catalog
.iter()
.any(|item| item.resource_id == session.resource_id)
);
send(&mut second, Event::Select(session.resource_id));
dispatch(&mut second, Message::Hosted(Event::OpenSelected)).await;
assert_eq!(second.document.bundle(), app.document.bundle());
assert_eq!(
second.hosted.session.as_ref().unwrap().resource_id,
session.resource_id
);
dispatch(
&mut second,
Message::Command(Command::Rename(project_id, "Saved before New".into())),
)
.await;
dispatch(&mut second, Message::Request(Pending::New)).await;
assert!(second.pending.is_some());
dispatch(&mut second, Message::SaveAndContinue).await;
assert!(second.hosted.session.is_none());
assert!(second.pending.is_none());
assert_ne!(second.project().id, project_id);
assert_eq!(
external
.open(session.resource_id)
.await
.unwrap()
.compiled
.decoded
.project
.name,
"Saved before New"
);
drop(external);
drop(app);
drop(second);
let _ = stop.send(());
tokio::time::timeout(std::time::Duration::from_secs(5), server)
.await
.unwrap()
.unwrap();
}
#[tokio::test]
async fn hosted_invalid_local_open_repair_saves_locally_and_never_replaces_old_resource() {
let directory = tempfile::tempdir().unwrap();
let store = Arc::new(oa_server::store::Store::open(directory.path()).unwrap());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let (stop, done) = tokio::sync::oneshot::channel::<()>();
let server = tokio::spawn(async move {
axum::serve(listener, oa_server::app(store, None))
.with_graceful_shutdown(async {
let _ = done.await;
})
.await
.unwrap();
});
let mut app = App::with_project(Project::empty());
app.hosted.base = base.clone();
dispatch(&mut app, Message::Hosted(Event::Publish)).await;
let session = app.hosted.session.clone().unwrap();
let accepted = app.document.snapshot();
let original_identity = app.canvas_document;
let external = Connection::new(&base, Secret::default()).unwrap();
let old = external.open(session.resource_id).await.unwrap();
let local = tempfile::tempdir().unwrap();
let path = local.path().join("repair.arcadia");
let invalid = "arcadia 5; ? // unfinished local work";
std::fs::write(&path, invalid).unwrap();
let path = path.canonicalize().unwrap();
dispatch(
&mut app,
Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))),
)
.await;
assert_ne!(app.canvas_document, original_identity);
assert_eq!(app.path.as_ref(), Some(&path));
assert!(!app.source_workspace.applied);
assert_eq!(app.source_editor.text(), invalid);
assert!(
Arc::ptr_eq(&accepted, &app.document.snapshot()),
"A repair workspace must preserve the last valid graphical model"
);
assert!(!app.source_diagnostics.is_empty());
assert!(
app.hosted.session.is_none(),
"The installed local repair workspace must own its save destination"
);
let mut repaired = Project::empty();
repaired.name = "Independent local repair".into();
let repaired_source = language::format(&repaired);
dispatch(
&mut app,
Message::SourceEdit(text_editor::Action::SelectAll),
)
.await;
dispatch(
&mut app,
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
Arc::new(repaired_source),
))),
)
.await;
dispatch(&mut app, Message::ApplySource).await;
assert_eq!(app.project(), &repaired);
assert!(!app.source_dirty());
dispatch(&mut app, Message::Save(false)).await;
let remote_after = external.open(session.resource_id).await.unwrap();
assert_eq!(
remote_after.etag, old.etag,
"Saving a repaired local workspace must never target the old hosted resource"
);
assert_eq!(remote_after.compiled.bundle, old.compiled.bundle);
assert!(app.hosted.session.is_none());
assert!(app.hosted.review.is_none());
assert!(app.hosted.uncertain.is_none());
assert!(app.hosted.request.is_none());
assert_eq!(app.path.as_ref(), Some(&path));
assert!(!app.dirty());
let saved = source_files::load_project(&path, LIMITS).unwrap();
assert_eq!(saved.reachable_bundle(), *app.document.bundle());
assert_eq!(
app.hosted.base, base,
"Connection settings remain available in memory"
);
drop(external);
drop(app);
let _ = stop.send(());
tokio::time::timeout(std::time::Duration::from_secs(5), server)
.await
.unwrap()
.unwrap();
}
#[test]
fn hosted_failed_or_cancelled_local_open_keeps_the_existing_document_and_destination() {
let mut app = App::with_project(Project::empty());
let resource = bind(&mut app);
let accepted = app.document.snapshot();
let identity = app.canvas_document;
let source = app.source_workspace.bundle();
let directory = tempfile::tempdir().unwrap();
let unreadable = directory.path().join("missing.json");
let error = read_snapshot(unreadable).unwrap_err();
for result in [Ok(None), Err(error)] {
let _ = app.update(Message::Loaded(result));
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.canvas_document, identity);
assert_eq!(app.source_workspace.bundle(), source);
assert!(app.path.is_none());
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, resource);
}
}
fn catalog_result(app: &App, resource: Id, revision: u64) -> Outcome {
Outcome::Catalog(crate::hosted::client::Catalog {
items: vec![crate::hosted::client::Summary {
resource_id: resource,
revision,
model_id: app.project().id,
name: format!("Catalog snapshot {revision}"),
elements: app.project().elements.len(),
relations: app.project().relations.len(),
}],
next_after: Some(resource),
})
}
#[test]
fn hosted_catalog_search_credentials_and_failed_refresh_never_mix_result_scopes() {
let mut app = App::with_project(Project::empty());
let first = Id::new_v4();
send(&mut app, Event::Connect);
let catalog = catalog_result(&app, first, 1);
complete(&mut app, Ok(catalog));
assert!(!app.hosted.catalog_stale);
send(&mut app, Event::More);
assert_eq!(
app.hosted.request.as_ref().unwrap().operation,
Operation::Catalog(true)
);
let next_page = catalog_result(&app, Id::new_v4(), 1);
complete(&mut app, Ok(next_page));
assert_eq!(app.hosted.catalog.len(), 2);
send(&mut app, Event::Select(first));
send(&mut app, Event::Query("different search".into()));
assert!(app.hosted.catalog_stale);
assert!(app.hosted.next.is_none());
assert!(app.hosted.selected.is_none());
assert_eq!(app.hosted.catalog.len(), 2);
assert!(
app.hosted
.catalog_message()
.unwrap()
.contains("Connect / refresh")
);
send(&mut app, Event::More);
assert!(app.hosted.request.is_none());
send(&mut app, Event::Connect);
complete(&mut app, Err(failure(None, false)));
assert!(app.hosted.catalog_stale);
assert_eq!(app.hosted.catalog[0].resource_id, first);
send(&mut app, Event::Connect);
let second = Id::new_v4();
let catalog = catalog_result(&app, second, 1);
complete(&mut app, Ok(catalog));
assert!(!app.hosted.catalog_stale);
assert_eq!(app.hosted.catalog.len(), 1);
assert_eq!(app.hosted.catalog[0].resource_id, second);
send(&mut app, Event::Query("different search".into()));
assert!(
!app.hosted.catalog_stale,
"Unchanged query preserves the snapshot"
);
send(
&mut app,
Event::Token(Secret("different-credential".into())),
);
assert!(app.hosted.catalog_stale);
assert!(app.hosted.catalog.is_empty());
assert!(app.hosted.connection.is_none());
assert!(app.hosted.next.is_none());
assert!(app.hosted.selected.is_none());
assert!(
!app.hosted
.catalog_message()
.unwrap()
.starts_with("No models found")
);
send(&mut app, Event::Connect);
let catalog = catalog_result(&app, second, 2);
complete(&mut app, Ok(catalog));
send(&mut app, Event::Base("http://127.0.0.1:9876".into()));
assert!(app.hosted.catalog.is_empty());
assert!(app.hosted.connection.is_none());
assert!(app.hosted.catalog_stale);
assert!(app.hosted.request.is_none());
}
#[test]
fn hosted_catalog_invalidation_follows_service_writes_and_observed_revision_changes() {
let mut app = App::with_project(Project::empty());
let resource = bind(&mut app);
let other = Id::new_v4();
send(&mut app, Event::Base("http://127.0.0.1:9876".into()));
send(&mut app, Event::Connect);
let catalog = catalog_result(&app, other, 7);
complete(&mut app, Ok(catalog));
rename(&mut app, "Saved to original service");
send(&mut app, Event::Save);
let saved = remote(app.document.snapshot(), resource, 2);
complete(&mut app, Ok(Outcome::Remote(saved)));
assert!(
!app.hosted.catalog_stale,
"A write to A cannot invalidate B's catalog"
);
assert_eq!(app.hosted.next, Some(other));
assert_eq!(app.hosted.catalog[0].resource_id, other);
send(&mut app, Event::Base("http://127.0.0.1:8787".into()));
send(&mut app, Event::Connect);
let catalog = catalog_result(&app, resource, 2);
complete(&mut app, Ok(catalog));
send(&mut app, Event::Check);
let changed = remote(app.document.snapshot(), resource, 3);
complete(&mut app, Ok(Outcome::Remote(changed)));
assert!(
app.hosted.catalog_stale,
"A known newer server revision invalidates old metadata"
);
assert!(app.hosted.next.is_none());
send(&mut app, Event::Connect);
let catalog = catalog_result(&app, resource, 3);
complete(&mut app, Ok(catalog));
send(&mut app, Event::Reload);
rename(&mut app, "Write with uncertain outcome");
send(&mut app, Event::Save);
complete(&mut app, Err(failure(None, true)));
assert!(app.hosted.catalog_stale);
assert!(app.hosted.next.is_none());
assert_eq!(app.hosted.catalog[0].revision, 3);
app.hosted.leave_document();
assert!(
app.hosted.catalog_stale,
"Leaving the hosted destination cannot make old results fresh"
);
}
#[test]
fn hosted_new_revision_invalidates_filtered_catalog_even_without_a_displayed_row() {
let mut app = App::with_project(Project::empty());
rename(&mut app, "Original");
let resource = bind(&mut app);
send(&mut app, Event::Query("Renamed".into()));
send(&mut app, Event::Connect);
complete(
&mut app,
Ok(Outcome::Catalog(crate::hosted::client::Catalog {
items: vec![],
next_after: None,
})),
);
assert!(!app.hosted.catalog_stale);
assert!(
app.hosted
.catalog_message()
.unwrap()
.starts_with("No models found")
);
let mut unchanged = remote(app.document.snapshot(), resource, 1);
unchanged.etag = app.hosted.session.as_ref().unwrap().etag.clone();
send(&mut app, Event::Check);
complete(&mut app, Ok(Outcome::Remote(unchanged)));
assert!(
!app.hosted.catalog_stale,
"An unchanged bound head keeps the filtered snapshot fresh"
);
// A different engineer changes the hosted model so it now matches the filter.
let mut changed = Document::from_bundle(app.document.bundle().clone(), LIMITS).unwrap();
changed
.execute(Command::Rename(changed.project().id, "Renamed".into()))
.unwrap();
send(&mut app, Event::Check);
complete(
&mut app,
Ok(Outcome::Remote(remote(changed.snapshot(), resource, 2))),
);
assert_eq!(app.hosted.review.as_ref().unwrap().remote.revision, 2);
assert!(
app.hosted.request.is_none(),
"No polling or automatic catalog request"
);
assert!(
app.hosted.catalog_stale,
"Known newer revision can change membership in an empty filtered catalog"
);
assert!(
!app.hosted
.catalog_message()
.unwrap()
.starts_with("No models found")
);
}
#[test]
fn hosted_changed_bound_head_does_not_invalidate_another_services_empty_filter() {
let mut app = App::with_project(Project::empty());
let resource = bind(&mut app);
send(&mut app, Event::Base("http://127.0.0.1:9876".into()));
send(&mut app, Event::Query("Renamed".into()));
send(&mut app, Event::Connect);
complete(
&mut app,
Ok(Outcome::Catalog(crate::hosted::client::Catalog {
items: vec![],
next_after: None,
})),
);
send(&mut app, Event::Check);
let changed = remote(app.document.snapshot(), resource, 2);
complete(&mut app, Ok(Outcome::Remote(changed)));
assert!(app.hosted.review.is_some());
assert!(!app.hosted.catalog_stale);
assert!(
app.hosted
.catalog_message()
.unwrap()
.starts_with("No models found")
);
assert!(app.hosted.request.is_none());
}
+591
View File
@@ -0,0 +1,591 @@
use super::*;
use crate::behavior::topology::{self, CanvasEvent};
impl App {
pub(crate) fn topology_context(&self) -> Option<topology::Context> {
let owner = crate::behavior::selected_owner(self)?;
(self.page == Page::Behavior
&& matches!(
self.project().elements.get(&owner)?.kind(),
Kind::Process | Kind::FunctionalChain
))
.then_some(topology::Context {
document: self.canvas_document,
owner,
revision: self.document.revision(),
epoch: self.canvas_epoch,
})
}
pub(super) fn reveal_process_selection(&self) -> Task<Message> {
let Some(context) = self.topology_context() else {
return Task::none();
};
let Some(offset) =
self.behavior
.topology
.selection_offset(self.project(), context.owner, self.selected)
else {
return Task::none();
};
iced::widget::operation::scroll_to("process-canvas", offset)
}
pub(super) fn edit_process_canvas(&mut self, event: CanvasEvent) -> Task<Message> {
let context = event.context();
if let CanvasEvent::Scrolled(_, area, offset) = event {
if self.topology_context().is_some_and(|c| {
c.document == context.document
&& c.owner == context.owner
&& c.revision == context.revision
}) && self.behavior.topology.scrolled(area, offset)
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
return Task::none();
}
if let CanvasEvent::Viewport(_, size) = event {
if self.topology_context().is_some_and(|c| {
c.document == context.document
&& c.owner == context.owner
&& c.revision == context.revision
}) && self.behavior.topology.viewport(size)
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
return Task::none();
}
if self.topology_context() != Some(context) {
return Task::none();
}
self.behavior
.topology
.enter(context.document, context.owner);
match event {
CanvasEvent::Select(_, id) => {
if !self.behavior.has_pending()
&& crate::behavior::selected_behavior(self.project(), id) == Some(context.owner)
{
self.select(id);
self.selected_node = None;
}
}
CanvasEvent::Choose(_, tool) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
self.behavior.topology.choose(tool);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Zoom(_, zoom) => {
self.behavior.topology.zoom(zoom);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Expand(_, id) => {
let mut state = std::mem::take(&mut self.behavior.topology);
let changed = state.expand(self.project(), context.owner, id);
self.behavior.topology = state;
if changed {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
CanvasEvent::Open(_, occurrence) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
if let Some(target) =
topology::referenced(self.project(), context.owner, occurrence)
{
if self
.project()
.elements
.get(&target)
.is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
{
self.behavior.topology.remember(context.owner, occurrence);
self.behavior.active_owner = Some(target);
}
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.select(target);
}
}
CanvasEvent::Back(_) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
let mut state = std::mem::take(&mut self.behavior.topology);
let back = state.back(self.project());
self.behavior.topology = state;
if let Some((owner, occurrence)) = back {
self.behavior.active_owner = Some(owner);
self.select(occurrence);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
mutation => {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
self.status =
"Apply or cancel pending edits before changing the process.".into();
return Task::none();
}
match mutation {
CanvasEvent::Begin {
tool,
control,
insertion,
..
} => {
let mut state = std::mem::take(&mut self.behavior.topology);
let result = state.begin(self.project(), context, tool, control, insertion);
self.behavior.topology = state;
match result {
Ok(()) => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status="Choose the contextual contribution and apply at the captured position.".into();
}
Err(e) => self.status = e,
}
}
CanvasEvent::AppendExchange(_) => {
let mut state = std::mem::take(&mut self.behavior.topology);
let result = state.append_exchange(self.project(), context.owner);
self.behavior.topology = state;
match result {
Ok(()) => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = "Choose the next exchange; Create appends it with an explicit sequence link.".into();
}
Err(e) => self.status = e,
}
}
CanvasEvent::Connect { source, target, .. } => {
let mut state = std::mem::take(&mut self.behavior.topology);
let result = state.connect(self.project(), context.owner, source, target);
self.behavior.topology = state;
match result {
Ok(()) => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = "Describe the path condition, then Create.".into();
}
Err(e) => self.status = e,
}
}
CanvasEvent::Reconnect {
link, end, target, ..
} => {
match topology::reconnect(self.project(), context.owner, link, end, target)
{
Ok(command) => {
if self.execute(command) {
self.selected = link;
self.refresh_draft();
return self.reveal_process_selection();
}
}
Err(e) => self.status = e,
}
}
CanvasEvent::Reorder {
occurrence, before, ..
} => {
match topology::reorder(self.project(), context.owner, occurrence, before) {
Ok(command) => {
if self.execute(command) {
self.selected = occurrence;
self.refresh_draft();
return self.reveal_process_selection();
}
}
Err(e) => self.status = e,
}
}
_ => unreachable!(),
}
}
}
Task::none()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::behavior::{
self,
topology::{
CanvasTool, Event, Insertion, Tool,
direct_tests::{self, Fixture},
},
};
fn setup() -> (App, Fixture) {
let f = direct_tests::fixture(Layer::Oa);
let mut app = App::with_project(f.p.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(Page::Behavior));
assert_eq!(app.topology_context().unwrap().owner, f.owner);
(app, f)
}
fn form(app: &mut App, event: Event) {
let _ = app.update(Message::Behavior(behavior::Event::Topology(event)));
}
fn begin(app: &App) -> CanvasEvent {
CanvasEvent::Begin {
context: app.topology_context().unwrap(),
tool: Tool::Work,
control: ControlKind::Choice,
insertion: Insertion::default(),
}
}
#[test]
fn direct_process_append_empty_first_second_exchange_has_exact_source_history() {
for layer in [Layer::Oa, Layer::Sa] {
let (f, next) = direct_tests::empty_exchange_fixture(layer);
let mut app = App::with_project(f.p.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(Page::Behavior));
let empty = app.document.snapshot();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin {
context,
tool: Tool::Exchange,
control: ControlKind::Choice,
insertion: Insertion::default(),
}));
assert!(app.behavior.has_pending());
form(
&mut app,
Event::Target(Choice {
id: f.exchange,
label: "Assessment".into(),
}),
);
form(&mut app, Event::Name("First contribution".into()));
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending(), "{}", app.status);
let first_id = app.selected;
let first = app.document.snapshot();
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&[first_id]
);
assert_eq!(app.project().relations, empty.decoded.project.relations);
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::AppendExchange(
context,
)));
assert!(
app.behavior.has_pending(),
"reachable single-exchange continuation: {}",
app.status
);
assert!(Arc::ptr_eq(&first, &app.document.snapshot()));
form(
&mut app,
Event::Target(Choice {
id: next,
label: "Response".into(),
}),
);
form(&mut app, Event::Name("Second contribution".into()));
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending(), "{}", app.status);
let second_id = app.selected;
let second = app.document.snapshot();
assert_ne!(first_id, second_id);
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&[first_id, second_id]
);
let links = app
.project()
.relations
.values()
.filter(|r| r.kind() == RelationKind::SequenceLink)
.collect::<Vec<_>>();
assert_eq!(links.len(), 1);
assert_eq!(
(links[0].source(), links[0].target()),
(first_id, second_id)
);
assert_eq!(
app.project().elements.len(),
empty.decoded.project.elements.len() + 2
);
for (id, relation) in &empty.decoded.project.relations {
assert_eq!(app.project().relations[id], *relation);
}
for work in f.work {
assert_eq!(
app.project().elements[&work],
empty.decoded.project.elements[&work]
);
}
let reopened = Document::from_bundle(
second.bundle.clone(),
oa_core::source_project::ProjectLimits::default(),
)
.unwrap();
assert_eq!(reopened.project(), app.project());
assert_eq!(reopened.bundle(), &second.bundle);
for expected in [&first, &empty] {
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &expected.bundle);
assert_eq!(app.project(), &expected.decoded.project);
}
assert!(
!app.document.can_undo(),
"each Create must add exactly one history entry"
);
for expected in [&first, &second] {
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &expected.bundle);
assert_eq!(app.project(), &expected.decoded.project);
}
assert!(!app.document.can_redo());
}
}
#[test]
fn direct_process_app_stale_context_dirty_source_and_properties_never_mutate() {
let (mut app, _) = setup();
let snapshot = app.document.snapshot();
for field in 0..4 {
let mut event = begin(&app);
let CanvasEvent::Begin { context, .. } = &mut event else {
unreachable!()
};
match field {
0 => context.document = Id::new_v4(),
1 => context.owner = Id::new_v4(),
2 => context.revision += 1,
_ => context.epoch += 1,
};
let _ = app.update(Message::TopologyCanvas(event));
assert!(!app.behavior.has_pending());
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
}
let _ = app.update(Message::Field("/name".into(), "Pending owner name".into()));
let event = begin(&app);
let _ = app.update(Message::TopologyCanvas(event));
assert!(!app.behavior.has_pending());
assert!(app.draft_dirty());
let context = app.topology_context().unwrap();
for event in [
CanvasEvent::Zoom(context, Some(0.7)),
CanvasEvent::Viewport(context, iced::Size::new(510., 360.)),
] {
let _ = app.update(Message::TopologyCanvas(event));
assert!(app.draft_dirty());
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
}
}
#[test]
fn direct_process_app_source_failure_retains_form_then_commits_once_and_selects_occurrence() {
let (mut app, f) = setup();
let bundle = app.document.bundle().clone();
let mut limits = oa_core::source_project::ProjectLimits::default();
limits.model.max_source_bytes =
bundle.files.values().map(String::len).sum::<usize>() + 3000;
app.document = Document::from_bundle(bundle.clone(), limits).unwrap();
app.sync();
let event = begin(&app);
let _ = app.update(Message::TopologyCanvas(event));
form(
&mut app,
Event::Target(Choice {
id: f.work[0],
label: "Assess".into(),
}),
);
form(&mut app, Event::Name("Retain this long input".repeat(1000)));
let _ = app.update(Message::Page(Page::Catalog));
assert_eq!(app.page, Page::Behavior);
form(&mut app, Event::Submit);
assert!(app.behavior.has_pending());
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo());
form(&mut app, Event::Name("Recovered contribution".into()));
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending());
assert_ne!(app.selected, f.owner);
assert_eq!(app.project().named(app.selected), "Recovered contribution");
let created = app.selected;
let after = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &bundle);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &after);
assert!(app.project().contains(created));
}
#[test]
fn direct_process_app_same_owner_selection_preserves_tool_but_owner_change_resets_it() {
let (mut app, f) = setup();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Choose(
context,
CanvasTool::Connect,
)));
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Select(
context, f.nodes[0],
)));
assert_eq!(app.topology_context(), Some(context));
let other = app
.project()
.make_element(Kind::Process, Layer::Oa, "Other", None)
.unwrap();
let other_id = other.id;
assert!(app.execute(Command::CreateElement(other)));
let _ = app.update(Message::BehaviorOwner(Choice {
id: other_id,
label: "Other".into(),
}));
assert_eq!(app.topology_context().unwrap().owner, other_id);
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Connect {
context,
source: f.nodes[0],
target: f.nodes[2],
}));
assert!(!app.behavior.has_pending());
}
#[test]
fn direct_process_app_save_scroll_resize_and_passive_geometry_keep_drafts_and_current_origin() {
let (mut app, f) = setup();
let context = app.topology_context().unwrap();
let original = app.document.snapshot();
app.busy = true;
for (area, offset) in [
(
behavior::scenario::ScrollArea::Canvas,
iced::Vector::new(25., 100.),
),
(
behavior::scenario::ScrollArea::Page,
iced::Vector::new(0., 40.),
),
] {
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
context, area, offset,
)));
}
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Viewport(
context,
iced::Size::new(500., 300.),
)));
assert_eq!(
app.behavior.topology.scroll_offset(),
iced::Vector::new(25., 140.)
);
assert_ne!(app.topology_context(), Some(context));
assert!(app.busy);
let mut stale = context;
stale.document = Id::new_v4();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
stale,
behavior::scenario::ScrollArea::Canvas,
iced::Vector::new(0., 900.),
)));
assert_eq!(
app.behavior.topology.scroll_offset(),
iced::Vector::new(25., 140.)
);
let root = PathBuf::from("process-scroll-save-fixture");
let receipt = FileSnapshot {
document: context.document,
revision: context.revision,
snapshot: original.clone(),
path: root.join(&original.bundle.entry),
disk: Some(source_files::DiskProject {
root,
bundle: original.bundle.clone(),
expectations: Default::default(),
reachable: original.bundle.files.keys().cloned().collect(),
diagnostics: vec![],
}),
};
let _ = app.update(Message::Saved(Ok(Some(receipt))));
assert!(!app.busy);
assert!(!app.document.can_undo());
assert_eq!(app.project(), &f.p);
let context = app.topology_context().unwrap();
let size = app.window_size;
let _ = app.update(Message::WindowResized(size));
assert_eq!(app.topology_context(), Some(context));
let _ = app.update(Message::WindowResized(iced::Size::new(
size.width + 20.,
size.height,
)));
assert_ne!(app.topology_context(), Some(context));
let event = begin(&app);
let _ = app.update(Message::TopologyCanvas(event));
form(&mut app, Event::Name("Keep this form".into()));
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
context,
behavior::scenario::ScrollArea::Page,
iced::Vector::new(0., 80.),
)));
assert!(app.behavior.has_pending());
assert_eq!(app.document.bundle(), &original.bundle);
}
#[test]
fn direct_process_app_open_source_and_back_preserve_local_reference_identity() {
let (mut app, f) = setup();
let source = app
.project()
.make_element(Kind::Process, Layer::Oa, "Reusable source", None)
.unwrap();
let source_id = source.id;
assert!(app.execute(Command::CreateElement(source)));
let mut occurrence = app
.project()
.make_element(Kind::Occurrence, Layer::Oa, "Local use", Some(f.owner))
.unwrap();
let occurrence_id = occurrence.id;
if let Some(SharedBody::Occurrence { body, .. }) = occurrence.shared_mut() {
*body = OccurrenceBody::SequenceReference {
sequence: Some(source_id),
};
}
assert!(app.execute(Command::CreateElement(occurrence)));
let snapshot = app.document.snapshot();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Open(
context,
occurrence_id,
)));
assert_eq!(app.selected, source_id);
assert_eq!(app.topology_context().unwrap().owner, source_id);
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Back(context)));
assert_eq!(app.selected, occurrence_id);
assert_eq!(app.topology_context().unwrap().owner, f.owner);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
}
}
#[cfg(test)]
#[test]
fn direct_process_dirty_source_blocks_canvas_creation_without_losing_text() {
let f = topology::direct_tests::fixture(Layer::Oa);
let mut app = App::with_project(f.p.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(Page::Behavior));
let original = app.document.snapshot();
let edited = format!(
"{}\n// Retained source draft\n",
app.source_workspace.text()
);
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
text_editor::Edit::Paste(edited.clone().into()),
)));
assert!(app.source_dirty());
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin {
context,
tool: topology::Tool::Work,
control: ControlKind::Choice,
insertion: topology::Insertion::default(),
}));
assert!(!app.behavior.has_pending());
assert!(app.source_dirty());
assert_eq!(app.source_workspace.text(), edited);
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
}
+245 -16
View File
@@ -38,14 +38,37 @@ mod scenario_scroll {
content: content.into(),
context,
area,
process: false,
})
}
pub(super) fn observe_topology<'a>(
context: topology::Context,
area: scenario::ScrollArea,
content: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
Element::new(Observer {
content: content.into(),
context: scenario::Context {
document: context.document,
scenario: context.owner,
revision: context.revision,
epoch: context.epoch,
},
area,
process: true,
})
}
struct Observer<'a, R = Renderer> {
content: Element<'a, Message, Theme, R>,
context: scenario::Context,
area: scenario::ScrollArea,
process: bool,
}
type Report = Option<(bool, Id, Id, Vector)>;
struct ObservedScroll {
identity: (Id, Id),
report: Report,
}
type Report = Option<(Id, Id, Vector)>;
#[derive(Default)]
struct Translation(Option<Vector>);
impl widget::Operation for Translation {
@@ -77,31 +100,61 @@ mod scenario_scroll {
&mut offset,
);
if let Some(offset) = offset.0 {
let next = Some((self.context.document, self.context.scenario, offset));
let previous = tree.state.downcast_mut::<Report>();
let next = Some((
self.process,
self.context.document,
self.context.scenario,
offset,
));
let previous = &mut tree.state.downcast_mut::<ObservedScroll>().report;
if *previous != next {
*previous = next;
shell.publish(Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled(
self.context,
self.area,
offset,
)));
shell.publish(if self.process {
Message::TopologyCanvas(topology::CanvasEvent::Scrolled(
topology::Context {
document: self.context.document,
owner: self.context.scenario,
revision: self.context.revision,
epoch: self.context.epoch,
},
self.area,
offset,
))
} else {
Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled(
self.context,
self.area,
offset,
))
});
}
}
}
}
impl<R: renderer::Renderer> Widget<Message, Theme, R> for Observer<'_, R> {
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<Report>()
widget::tree::Tag::of::<ObservedScroll>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(Report::None)
widget::tree::State::new(ObservedScroll {
identity: (self.context.document, self.context.scenario),
report: None,
})
}
fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut widget::Tree) {
tree.diff_children(std::slice::from_ref(&self.content));
if tree.state.downcast_ref::<ObservedScroll>().identity
!= (self.context.document, self.context.scenario)
{
// A new owner/document needs fresh real scroll state, not merely
// cleared mirrored offsets. Edits and tool/form changes retain it.
tree.state = self.state();
tree.children = self.children();
} else {
tree.diff_children(std::slice::from_ref(&self.content));
}
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
@@ -220,7 +273,9 @@ mod scenario_scroll {
Observer {
context,
area: scenario::ScrollArea::Canvas,
process: false,
content: scrollable(iced::widget::Space::new().width(200.).height(height))
.id("observer-test")
.width(200.)
.height(200.)
.into(),
@@ -265,6 +320,160 @@ mod scenario_scroll {
})
.collect()
}
#[test]
fn independent_process_owner_change_resets_actual_scroll_offsets() {
let context = scenario::direct_tests::context(Id::new_v4());
let redraw = || {
iced::Event::Window(iced::window::Event::RedrawRequested(
std::time::Instant::now(),
))
};
let process_offsets = |messages: Vec<Message>| {
messages
.into_iter()
.filter_map(|message| {
if let Message::TopologyCanvas(topology::CanvasEvent::Scrolled(
_,
_,
offset,
)) = message
{
Some(offset)
} else {
None
}
})
.collect::<Vec<_>>()
};
let mut view = observer(600., context);
view.process = true;
let mut tree = widget::Tree::new(&view as &dyn Widget<Message, Theme, ()>);
assert_eq!(
process_offsets(update(&mut view, &mut tree, redraw()).0),
vec![Vector::ZERO]
);
let (messages, captured) = update(
&mut view,
&mut tree,
iced::Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Pixels { x: 0., y: -180. },
}),
);
assert!(captured);
assert_eq!(process_offsets(messages), vec![Vector::new(0., 180.)]);
let mut other = context;
other.scenario = Id::new_v4();
let mut state = topology::State::default();
state.enter(context.document, context.scenario);
state.scrolled(scenario::ScrollArea::Canvas, Vector::new(0., 180.));
state.enter(other.document, other.scenario);
assert_eq!(state.scroll_offset(), Vector::ZERO);
view = observer(600., other);
view.process = true;
view.diff(&mut tree);
let reported = process_offsets(update(&mut view, &mut tree, redraw()).0);
assert_eq!(
reported,
vec![Vector::ZERO],
"The new process immediately reacquires its predecessor's scroll offset"
);
}
#[test]
fn direct_scroll_mounted_identity_resets_navigation_but_preserves_edits_before_first_report()
{
use widget::operation::scrollable::{AbsoluteOffset, scroll_to};
for process in [false, true] {
for area in [scenario::ScrollArea::Canvas, scenario::ScrollArea::Page] {
for change in 0..4 {
let context = scenario::direct_tests::context(Id::new_v4());
let mut view = observer(600., context);
view.process = process;
view.area = area;
let mut tree = widget::Tree::new(&view as &dyn Widget<Message, Theme, ()>);
let node = view.layout(
&mut tree,
&(),
&layout::Limits::new(Size::ZERO, Size::new(800., 700.)),
);
// Model a programmatic reveal before the observer has reported.
// Mounted identity must not depend on its last scroll notification.
let mut operation = scroll_to::<()>(
widget::Id::new("observer-test"),
AbsoluteOffset {
x: None,
y: Some(180.),
},
);
view.operate(&mut tree, Layout::new(&node), &(), &mut operation);
let mut next = context;
match change {
0 => next.scenario = Id::new_v4(),
1 => next.document = Id::new_v4(),
2 => {
next.revision += 1;
next.epoch += 5;
}
_ => {}
}
view = observer(if change == 3 { 800. } else { 600. }, next);
view.process = process;
view.area = area;
view.diff(&mut tree);
let redraw = || {
iced::Event::Window(iced::window::Event::RedrawRequested(
std::time::Instant::now(),
))
};
let (messages, _) = update(&mut view, &mut tree, redraw());
let actual = messages
.into_iter()
.filter_map(|message| match message {
Message::TopologyCanvas(topology::CanvasEvent::Scrolled(
c,
reported_area,
offset,
)) => {
assert_eq!(
(c.document, c.owner, c.revision, c.epoch),
(next.document, next.scenario, next.revision, next.epoch)
);
assert_eq!(
std::mem::discriminant(&reported_area),
std::mem::discriminant(&area)
);
Some(offset)
}
Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled(
c,
reported_area,
offset,
)) => {
assert_eq!(c, next);
assert_eq!(
std::mem::discriminant(&reported_area),
std::mem::discriminant(&area)
);
Some(offset)
}
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
actual,
vec![if change < 2 {
Vector::ZERO
} else {
Vector::new(0., 180.)
}],
"process={process} area={area:?} change={change}"
);
assert!(update(&mut view, &mut tree, redraw()).0.is_empty());
}
}
}
}
#[test]
fn direct_scroll_observer_reports_actual_initial_clamped_and_remounted_offsets_once() {
let context = scenario::direct_tests::context(Id::new_v4());
@@ -342,7 +551,7 @@ impl Event {
pub struct State {
pub active_owner: Option<Id>,
pub diagram: state_view::State,
topology: topology::State,
pub(crate) topology: topology::State,
pub(crate) scenario: scenario::State,
states: states::State,
}
@@ -878,11 +1087,14 @@ impl Scene {
MessageKind::Timer { .. } => "timer · ",
}
};
let message_label = if let MessageKind::Timer { expression } = message_kind
{
expression.as_str()
let message_label = if !scenario {
r.map(|r| r.name.clone()).unwrap_or_else(|| e.name.clone())
} else if let MessageKind::Timer { expression } = message_kind {
format!("{} · {expression}", e.name)
} else if let Some(exchange) = r.filter(|r| r.name != e.name) {
format!("{} · {}", e.name, exchange.name)
} else {
r.map(|r| r.name.as_str()).unwrap_or(&e.name)
e.name.clone()
};
self.lines.push(Line {
id: *id,
@@ -1603,6 +1815,23 @@ mod direct_canvas_tests {
})
}
#[test]
fn reused_exchange_messages_display_their_distinct_occurrence_names() {
let mut f = scenario::direct_tests::fixture();
f.project.elements.get_mut(&f.call).unwrap().name = "Route request".into();
f.project.elements.get_mut(&f.reply).unwrap().name = "Route response".into();
let scene = Scene::sequence(&f.project, f.owner, f.call);
let call = scene.lines.iter().find(|line| line.id == f.call).unwrap();
let reply = scene.lines.iter().find(|line| line.id == f.reply).unwrap();
assert!(call.label.starts_with("1. call · Route request"));
assert!(reply.label.starts_with("2. reply · Route response"));
assert!(call.label.ends_with(&f.project.relations[&f.exchange].name));
assert!(
reply
.label
.ends_with(&f.project.relations[&f.exchange].name)
);
}
#[test]
fn direct_drag_and_click_click_use_ordered_sources_and_publish_once() {
for drag in [false, true] {
let (f, scene) = scene();
+46 -26
View File
@@ -1221,8 +1221,11 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen
}
}));
let pending = app.behavior.has_pending();
// Keep the canvas at a stable widget-tree position when a form replaces
// the palette, so Iced retains the user's current sequence viewport.
let mut controls = column![].spacing(12);
if !pending {
out = out
controls = controls
.push(
row![
action("+ Lifeline", event(Event::Start(owner, Tool::Lifeline))),
@@ -1252,12 +1255,18 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen
MessageStyle::CallReply
};
if !pending {
out = out.push(
controls = controls.push(
row![
button("Select / reorder").on_press(Message::ScenarioCanvas(CanvasEvent::Choose(
context,
CanvasTool::Select
))),
button("Select / reorder")
.on_press(Message::ScenarioCanvas(CanvasEvent::Choose(
context,
CanvasTool::Select
)))
.style(if tool == CanvasTool::Select {
style::selected
} else {
style::nav
}),
pick_list(
vec![
MessageStyle::Asynchronous,
@@ -1277,21 +1286,28 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen
.text_size(12)
.padding(8)
.width(Fill),
button("Draw message").on_press(Message::ScenarioCanvas(CanvasEvent::Choose(
context,
CanvasTool::Message(style)
))),
button("Draw message")
.on_press(Message::ScenarioCanvas(CanvasEvent::Choose(
context,
CanvasTool::Message(style)
)))
.style(if matches!(tool, CanvasTool::Message(_)) {
style::selected
} else {
style::nav
}),
]
.spacing(6),
);
out = out.push(label(if matches!(tool, CanvasTool::Message(_)) {
controls = controls.push(label(if matches!(tool, CanvasTool::Message(_)) {
"Draw message · choose the highlighted sequence position on a lifeline, then click or drag to another lifeline · Escape cancels"
} else { "Select a lifeline or occurrence · drag the selected occurrence’s ↕ handle to reorder within its sequence" }));
} else {
out = out.push(label(
controls = controls.push(label(
"Pending form · the canvas remains visible · apply or cancel below",
));
}
out = out.push(controls);
let mut scene = Scene::sequence(p, owner, app.selected);
scene.interaction = Some((
context,
@@ -1314,7 +1330,7 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen
horizontal: scrollable::Scrollbar::default(),
})
.height(if pending {
(app.window_size.height - 500.).clamp(160., 260.)
(app.window_size.height - 660.).clamp(110., 190.)
} else {
(app.window_size.height - 410.).clamp(180., 340.)
}),
@@ -1524,24 +1540,28 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen
if let Err(error) = &readiness {
form = form.push(label(error.clone()));
}
form = form.push(
row![
button("Create")
.padding(9)
.on_press_maybe(readiness.is_ok().then_some(event(Event::Submit))),
action("Cancel", event(Event::Cancel))
]
.spacing(7),
);
let footer = row![
button("Create")
.padding(9)
.on_press_maybe(readiness.is_ok().then_some(event(Event::Submit))),
action("Cancel", event(Event::Cancel))
]
.spacing(7);
out = out.push(
container(scrollable(form).height((app.window_size.height - 470.).clamp(190., 280.)))
.padding(14)
.style(style::card),
container(
column![
scrollable(form).height((app.window_size.height - 650.).clamp(110., 240.)),
footer
]
.spacing(9),
)
.padding(14)
.style(style::card),
);
}
if lines(p, owner).is_empty() && p.elements[&owner].steps().is_none_or(Vec::is_empty) {
out = out.push(container(label("Add lifelines for the participating work or actors, then create messages between them.")).padding(16).width(Fill).style(style::card));
} else {
} else if !pending {
out = sequence_rows(p, owner, out);
}
super::scenario_scroll::observe(context, ScrollArea::Page, scrollable(out.padding(24)))
+197 -413
View File
@@ -1,8 +1,16 @@
//! Process/chain authoring and projection use contextual occurrences and explicit
//! SequenceLinks. Presentation order never invents control-flow relationships.
use super::*;
use iced::widget::{Column, column, text_input};
use iced::widget::{Column, text_input};
use std::collections::BTreeSet;
mod direct;
mod view;
use direct::Input;
pub(crate) use direct::{
CanvasEvent, CanvasTool, Context, Insertion, reconnect, referenced, reorder,
};
#[cfg(test)]
pub(crate) mod direct_tests;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tool {
@@ -40,9 +48,8 @@ pub enum Event {
Guard(String),
AlternateGuard(String),
Control(ControlChoice),
Viewport(Size),
Zoom(f32),
Fit,
Search(usize, String),
Page(usize, usize),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ControlChoice(pub ControlKind);
@@ -59,11 +66,25 @@ impl std::fmt::Display for ControlChoice {
}
#[derive(Default)]
pub struct State {
identity: Option<(Id, Id)>,
tool: CanvasTool,
expanded: BTreeSet<Id>,
scroll_offsets: [iced::Vector; 2],
return_to: Vec<(Id, Id)>,
draft: Option<Draft>,
viewport: Option<Size>,
zoom: Option<f32>,
fit: bool,
}
#[derive(Default)]
struct Browser {
search: String,
page: usize,
}
struct Draft {
insertion: Insertion,
fixed: bool,
browsers: [Browser; 4],
owner: Id,
tool: Tool,
name: String,
@@ -99,9 +120,6 @@ impl State {
return Err(PENDING_DRAFT.into());
}
match event {
Event::Viewport(size) => self.viewport = Some(size),
Event::Zoom(zoom) => self.zoom = Some(zoom.clamp(0.1, 2.)),
Event::Fit => self.zoom = None,
Event::Start(owner, tool, selected) => {
let e = p
.elements
@@ -127,6 +145,9 @@ impl State {
}
});
self.draft = Some(Draft {
insertion: Insertion::default(),
fixed: false,
browsers: Default::default(),
owner,
tool,
name: String::new(),
@@ -154,6 +175,11 @@ impl State {
Event::Guard(value) => d.guard = value,
Event::AlternateGuard(value) => d.alternate_guard = value,
Event::Control(value) => d.control = value.0,
Event::Search(index, value) if index < 4 => {
d.browsers[index].search = value;
d.browsers[index].page = 0;
}
Event::Page(index, value) if index < 4 => d.browsers[index].page = value,
_ => {}
}
}
@@ -163,12 +189,13 @@ impl State {
}
fn scale(&self, graph: Size, fallback_width: f32) -> f32 {
self.zoom.unwrap_or_else(|| {
let width = self.viewport.map_or(fallback_width, |s| s.width);
((width - 24.).max(1.) / graph.width.max(1.))
.min(450. / graph.height.max(1.))
.clamp(0.05, 1.)
})
if !self.fit {
return self.zoom.unwrap_or(1.);
}
let width = self.viewport.map_or(fallback_width, |s| s.width);
((width - 24.).max(1.) / graph.width.max(1.))
.min(450. / graph.height.max(1.))
.clamp(0.05, 1.)
}
}
fn is_contribution(p: &Project, owner: Id, id: Id) -> bool {
@@ -247,13 +274,24 @@ fn targets(p: &Project, owner: Id, tool: Tool) -> Vec<Choice> {
})
.map(|e| Choice {
id: e.id,
label: format!("{} · {}", e.name, e.alias),
label: format!("{} · {}", e.name, semantic_path(p, e.id)),
})
.collect()
};
choices.sort_by(|a, b| a.label.cmp(&b.label));
choices
}
fn semantic_path(p: &Project, id: Id) -> String {
let mut path = Vec::new();
let mut current = Some(id);
while let Some(id) = current {
let Some(e) = p.elements.get(&id) else { break };
path.push(e.alias.as_str());
current = e.parent;
}
path.reverse();
path.join(" / ")
}
fn contributions(p: &Project, owner: Id) -> Vec<Choice> {
p.elements
.get(&owner)
@@ -394,6 +432,40 @@ impl Draft {
if self.before.is_some() && self.before == self.after {
return Err("Choose different preceding and following contributions.".into());
}
let captured = self
.insertion
.link
.map(|id| {
p.relations
.get(&id)
.filter(|r| {
r.kind() == RelationKind::SequenceLink
&& is_contribution(p, self.owner, r.source())
&& is_contribution(p, self.owner, r.target())
})
.ok_or("The captured path no longer exists in this process.")
})
.transpose()?;
if captured
.is_some_and(|r| Some(r.source()) != self.before || Some(r.target()) != self.after)
{
return Err("The captured path changed. Choose its position again.".into());
}
let matches: Vec<_> = sequence_links(p, self.owner)
.into_iter()
.filter(|r| Some(r.source()) == self.before && Some(r.target()) == self.after)
.collect();
let split_link = if self.tool == Tool::Connect {
None
} else if captured.is_some() {
captured
} else if !self.fixed && matches.len() == 1 {
Some(matches[0])
} else if !self.fixed && matches.len() > 1 {
return Err("Select the specific sequence link to insert on.".into());
} else {
None
};
let mut commands = preserved_order(
p,
self.owner,
@@ -418,7 +490,6 @@ impl Draft {
{
return Err("This path already follows the stored order. Convert the ordered path to edit its sequence link.".into());
}
require_guard(p, source, &self.guard)?;
commands.push(link(p, source, target, &self.guard));
return Ok(unique_batch(p, commands));
}
@@ -459,12 +530,6 @@ impl Draft {
return Err("Choose same-layer work for the second path.".into());
}
let parallel = self.control == ControlKind::Fork;
if !parallel && (self.guard.trim().is_empty() || self.alternate_guard.trim().is_empty())
{
return Err(
"Describe both alternative guards; use 'else' for the fallback path.".into(),
);
}
let split = occurrence(
p,
self.owner,
@@ -552,30 +617,47 @@ impl Draft {
commands.push(Command::CreateElement(e));
(id, id)
};
if let Some(before) = self.before {
// Branch guards belong to the split's two outgoing paths. A branch
// inserted after another choice must first use an explicit link.
let guard = if self.tool == Tool::Branch {
""
} else {
&self.guard
};
require_guard(p, before, guard)?;
commands.push(link(p, before, first, guard));
if let Some(existing) = split_link {
let mut updated = existing.clone();
if let RelationBody::SequenceLink { target, .. } = &mut updated.body {
*target = first;
}
commands.push(Command::EditRelation(existing.id, updated));
} else if let Some(before) = self.before {
commands.push(link(
p,
before,
first,
if self.tool == Tool::Branch {
""
} else {
&self.guard
},
));
}
if let Some(after) = self.after {
commands.push(link(p, last, after, ""));
}
// Inserting between two selected contributions replaces their direct
// precedence while preserving other links and their semantic identities.
if let (Some(before), Some(after)) = (self.before, self.after) {
commands.extend(
sequence_links(p, self.owner)
.into_iter()
.filter(|r| r.source() == before && r.target() == after)
.map(|r| Command::Delete(r.id)),
);
}
let new_ids: Vec<_> = commands
.iter()
.filter_map(|c| match c {
Command::CreateElement(e) if e.parent == Some(self.owner) => Some(e.id),
_ => None,
})
.collect();
let mut order = owner.steps().expect("process steps").clone();
let slot = self.insertion.before.or(self.after);
let index = slot
.map(|id| {
order
.iter()
.position(|v| *v == id)
.ok_or("The presentation slot changed.")
})
.transpose()?
.unwrap_or(order.len());
order.splice(index..index, new_ids);
commands.push(Command::SetSteps(self.owner, order));
Ok(unique_batch(p, commands))
}
}
@@ -598,23 +680,6 @@ pub(super) fn unique_batch(p: &Project, mut commands: Vec<Command>) -> Command {
}
Command::Batch(commands)
}
fn require_guard(p: &Project, source: Id, guard: &str) -> Result<(), String> {
if guard.trim().is_empty()
&& matches!(
p.elements.get(&source).and_then(|e| e.shared()),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Control {
kind: ControlKind::Choice | ControlKind::Loop
},
..
})
)
{
Err("Describe this choice / repeat condition, or enter 'else'.".into())
} else {
Ok(())
}
}
fn event(e: Event) -> Message {
Message::Behavior(if matches!(e, Event::Submit) {
super::Event::Submit
@@ -622,307 +687,9 @@ fn event(e: Event) -> Message {
super::Event::Topology(e)
})
}
fn picker<'a>(
choices: Vec<Choice>,
value: Option<Id>,
placeholder: &'a str,
change: fn(Choice) -> Event,
) -> Element<'a, Message> {
let selected = value
.and_then(|id| choices.iter().find(|c| c.id == id))
.cloned();
pick_list(choices, selected, move |v| event(change(v)))
.placeholder(placeholder)
.padding(9)
.text_size(12)
.width(Fill)
.into()
pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a, Message> {
view::view(app, owner, out)
}
pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Element<'a, Message> {
let p = app.project();
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
if !p.elements[&owner].description.is_empty() {
out = out.push(
text(&p.elements[&owner].description)
.size(12)
.color(style::MUTED),
);
}
out = out
.push(
row![
action("+ Work", event(Event::Start(owner, Tool::Work, selected))),
action(
"+ Exchange",
event(Event::Start(owner, Tool::Exchange, selected))
),
action("Reuse…", event(Event::Start(owner, Tool::Reuse, selected))),
]
.spacing(7),
)
.push(
row![
action(
"+ Control",
event(Event::Start(owner, Tool::Control, selected))
),
action(
"Branch / parallel…",
event(Event::Start(owner, Tool::Branch, selected))
),
action(
"Connect…",
event(Event::Start(owner, Tool::Connect, selected))
),
action("Properties", Message::EditProperties(owner)),
]
.spacing(7),
);
if let Some(d) = app
.behavior
.topology
.draft
.as_ref()
.filter(|d| d.owner == owner)
{
let mut form = column![text(d.tool.title()).size(16)].spacing(9);
let legacy = legacy_order(p, owner);
if legacy.len() > 1 {
form = form.push(label(format!("This edit will preserve the {} ordered contributions as explicit sequence links in the same undoable operation. Inserting between two contributions replaces their direct link.", legacy.len())));
}
if matches!(
d.tool,
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
) {
let choices: Vec<_> = targets(p, owner, d.tool)
.into_iter()
.filter(|choice| compatible_source(p, d, choice.id))
.collect();
if choices.is_empty() {
form = form.push(label("No compatible sources for these connections. Change the insertion points, or create the matching work, exchange or reusable process first."));
}
form = form.push(picker(
choices,
d.target,
"Choose an existing source…",
Event::Target,
));
}
if d.tool != Tool::Connect {
form = form.push(
text_input("Contribution name (defaults to source name)", &d.name)
.on_input(|s| event(Event::Name(s)))
.padding(9)
.size(12),
);
}
if matches!(d.tool, Tool::Control | Tool::Branch) {
let kinds = if d.tool == Tool::Branch {
vec![ControlKind::Choice, ControlKind::Fork]
} else {
vec![
ControlKind::Choice,
ControlKind::Merge,
ControlKind::Fork,
ControlKind::Join,
ControlKind::Loop,
]
};
form = form.push(
pick_list(
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
Some(ControlChoice(d.control)),
|v| event(Event::Control(v)),
)
.padding(9)
.text_size(12)
.width(Fill),
);
}
if d.tool == Tool::Branch {
form = form.push(picker(
targets(p, owner, Tool::Work),
d.alternate,
"Work on the second path…",
Event::Alternate,
));
if d.control != ControlKind::Fork {
form = form.push(
row![
text_input("First path condition", &d.guard)
.on_input(|s| event(Event::Guard(s)))
.padding(9)
.size(12),
text_input("Second path condition", &d.alternate_guard)
.on_input(|s| event(Event::AlternateGuard(s)))
.padding(9)
.size(12),
]
.spacing(7),
);
}
form = form.push(label("Creates split, two work contributions, merge and their links in one undoable operation."));
}
form = form
.push(
row![
picker(
contributions(p, owner),
d.before,
if d.tool == Tool::Connect {
"From contribution…"
} else {
"After contribution (optional)…"
},
Event::Before
),
action("Clear", event(Event::ClearBefore)),
]
.spacing(7),
)
.push(
row![
picker(
contributions(p, owner),
d.after,
if d.tool == Tool::Connect {
"To contribution…"
} else {
"Before contribution (optional)…"
},
Event::After
),
action("Clear", event(Event::ClearAfter)),
]
.spacing(7),
);
if d.tool != Tool::Branch {
form = form.push(
text_input("Sequence guard (for a choice or loop)", &d.guard)
.on_input(|s| event(Event::Guard(s)))
.padding(9)
.size(12),
);
}
if let Err(problem) = d.command(p) {
form = form.push(label(problem));
}
form = form.push(
row![
button("Create")
.padding(9)
.on_press_maybe(d.command(p).is_ok().then_some(event(Event::Submit))),
action("Cancel", event(Event::Cancel))
]
.spacing(7),
);
out = out.push(container(form).padding(14).style(style::card));
}
let (canvas, size): (Element<'a, Message>, Size) = if uses_explicit_topology(p, owner)
|| p.elements[&owner].steps().is_none_or(Vec::is_empty)
{
let mut graph = Graph::new(p, owner, app.selected);
if graph.nodes.is_empty() {
out = out.push(container(column![
text("Start this process").size(18),
label("Use + Work to involve an existing activity, or + Exchange for an interaction. Then connect contributions or insert alternative and parallel paths."),
].spacing(12)).padding(24).width(Fill).style(style::card));
return scrollable(out.padding(24)).into();
} else if graph.edges.is_empty() {
out = out.push(label(
"No explicit sequence links. Connect contributions to describe topology.",
));
}
graph.scale = app
.behavior
.topology
.scale(graph.size, (app.window_size.width - 600.).max(200.));
let scale = graph.scale;
let size = Size::new(graph.size.width * scale, graph.size.height * scale);
out = out.push(
row![
action("Fit", event(Event::Fit)),
action("−", event(Event::Zoom(scale / 1.25))),
label(format!("{:.0}%", scale * 100.)),
action("+", event(Event::Zoom(scale * 1.25))),
action("100%", event(Event::Zoom(1.))),
]
.spacing(8)
.align_y(iced::Center),
);
(
canvas::Canvas::new(graph)
.width(size.width)
.height(size.height)
.into(),
size,
)
} else {
let scene = Scene::sequence(p, owner, app.selected);
let size = scene.size;
let conversion = preserved_order(p, owner, None);
out = out.push(label(
"Ordered exchange path · the stored contribution order defines this sequence.",
));
if !conversion.is_empty() {
out = out.push(action(
"Convert ordered path to explicit links",
Message::Command(unique_batch(p, conversion)),
));
}
(
canvas::Canvas::new(scene)
.width(size.width)
.height(size.height)
.into(),
size,
)
};
out = out.push(
iced::widget::sensor(
container(
scrollable(canvas)
.direction(scrollable::Direction::Both {
vertical: scrollable::Scrollbar::default(),
horizontal: scrollable::Scrollbar::default(),
})
.height(size.height.clamp(200., 470.)),
)
.padding(8)
.width(Fill)
.style(style::card),
)
.on_show(|size| event(Event::Viewport(size)))
.on_resize(|size| event(Event::Viewport(size))),
);
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference {
sequence: Some(target),
},
..
}) = p.elements.get(&app.selected).and_then(|e| e.shared())
{
out = out.push(action(
&format!("Open source: {} →", p.named(*target)),
Message::Select(*target),
));
}
for r in sequence_links(p, owner) {
let guard = match &r.body {
RelationBody::SequenceLink { guard, .. } if !guard.is_empty() => format!(" [{guard}]"),
_ => String::new(),
};
out = out.push(
action(
&format!("{} → {}{}", p.named(r.source()), p.named(r.target()), guard),
Message::Select(r.id),
)
.width(Fill),
);
}
scrollable(out.padding(24)).into()
}
fn sequence_links(p: &Project, owner: Id) -> Vec<&Relation> {
p.relations
.values()
@@ -942,6 +709,7 @@ struct GraphNode {
source: Option<Id>,
preview: Vec<(Id, String)>,
preview_links: Vec<(Id, Id)>,
preview_total: usize,
}
struct GraphEdge {
id: Id,
@@ -951,15 +719,21 @@ struct GraphEdge {
points: Vec<Point>,
label: Rectangle,
}
struct Graph {
struct Graph<'a> {
project: &'a Project,
interaction: Option<(Context, CanvasTool, bool)>,
scroll_offset: iced::Vector,
nodes: Vec<GraphNode>,
edges: Vec<GraphEdge>,
size: Size,
selected: Id,
scale: f32,
}
impl Graph {
fn new(p: &Project, owner: Id, selected: Id) -> Self {
impl<'a> Graph<'a> {
fn new(p: &'a Project, owner: Id, selected: Id) -> Self {
Self::expanded(p, owner, selected, &BTreeSet::new())
}
fn expanded(p: &'a Project, owner: Id, selected: Id, expanded: &BTreeSet<Id>) -> Self {
let choices = contributions(p, owner);
let links = sequence_links(p, owner);
let ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect();
@@ -1000,17 +774,17 @@ impl Graph {
fallback += 1;
}
}
let mut rows: BTreeMap<usize, usize> = BTreeMap::new();
let mut rows: BTreeMap<usize, f32> = BTreeMap::new();
let mut nodes = vec![];
let mut size: Size = Size::new(640., 220.);
for c in choices {
let e = &p.elements[&c.id];
let rank = ranks[&c.id];
let slot = rows.entry(rank).or_default();
let slot = rows.entry(rank).or_insert(24.);
let mut node = GraphNode {
id: e.id,
rect: Rectangle::new(
Point::new(24. + rank as f32 * 420., 24. + *slot as f32 * 255.),
Point::new(24. + rank as f32 * 420., *slot),
Size::new(270., 104.),
),
title: e.name.clone(),
@@ -1019,8 +793,8 @@ impl Graph {
source: None,
preview: vec![],
preview_links: vec![],
preview_total: 0,
};
*slot += 1;
if let Some(SharedBody::Occurrence { body, .. }) = e.shared() {
match body {
OccurrenceBody::Action { action, .. } => {
@@ -1051,11 +825,19 @@ impl Graph {
.map(|id| format!("ref · {}", p.named(id)))
.unwrap_or("Choose source in Properties".into());
if let Some(source) = sequence {
node.preview = contributions(p, *source)
let contributions = contributions(p, *source);
node.preview_total = contributions.len();
node.preview = contributions
.into_iter()
.take(4)
.take(if expanded.contains(&e.id) {
usize::MAX
} else {
4
})
.map(|c| (c.id, p.named(c.id).to_owned()))
.collect();
node.rect.height =
(132. + node.preview.len().div_ceil(2) as f32 * 44.).max(218.);
let preview_ids: BTreeSet<_> =
node.preview.iter().map(|(id, _)| *id).collect();
node.preview_links = sequence_links(p, *source)
@@ -1071,11 +853,13 @@ impl Graph {
_ => {}
}
}
*slot += node.rect.height + 36.;
size.width = size.width.max(node.rect.x + node.rect.width + 28.);
size.height = size.height.max(node.rect.y + node.rect.height + 42.);
nodes.push(node);
}
let mut edges = vec![];
let mut routed_pairs = BTreeSet::new();
for (i, r) in links.into_iter().enumerate() {
let a = nodes
.iter()
@@ -1089,11 +873,14 @@ impl Graph {
.rect;
let start = Point::new(a.x + a.width, a.center_y());
let end = Point::new(b.x, b.center_y());
let points = if end.x > start.x {
// Parallel records keep distinct visible paths and guard hit targets.
// Their semantic endpoints and IDs remain unchanged.
let forward = end.x > start.x && routed_pairs.insert((r.source(), r.target()));
let points = if forward {
let mid = (start.x + end.x) / 2.;
vec![start, Point::new(mid, start.y), Point::new(mid, end.y), end]
} else {
let below = size.height + 24. + i as f32 * 30.;
let below = size.height + 24. + i as f32 * 42.;
vec![
start,
Point::new(start.x + 18., start.y),
@@ -1107,7 +894,7 @@ impl Graph {
RelationBody::SequenceLink { guard, .. } => guard.clone(),
_ => String::new(),
};
let label_at = if end.x > start.x {
let label_at = if forward {
Point::new(end.x - 138., end.y - 43.)
} else {
Point::new(((start.x + end.x) / 2. - 66.).max(4.), points[2].y - 38.)
@@ -1128,6 +915,9 @@ impl Graph {
}
}
Self {
project: p,
interaction: None,
scroll_offset: iced::Vector::ZERO,
nodes,
edges,
size,
@@ -1204,47 +994,24 @@ fn draw_arrow(f: &mut Frame, points: &[Point], color: Color, width: f32) {
Stroke::default().with_color(color).with_width(width),
);
}
impl canvas::Program<Message> for Graph {
type State = ();
impl canvas::Program<Message> for Graph<'_> {
type State = Input;
fn update(
&self,
_: &mut (),
state: &mut Input,
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
if matches!(
event,
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
) && let Some(point) = cursor.position_in(bounds)
{
let point = Point::new(point.x / self.scale, point.y / self.scale);
if let Some(n) = self.nodes.iter().rev().find(|n| n.rect.contains(point)) {
let id = if point.y > n.rect.y + 75. {
n.source.unwrap_or(n.id)
} else {
n.id
};
return Some(Action::publish(Message::Select(id)).and_capture());
}
if let Some(e) = self.edges.iter().find(|e| {
(!e.guard.is_empty() && e.label.contains(point))
|| e.points
.windows(2)
.any(|v| crate::diagram::distance_to_segment(point, v[0], v[1]) < 8.)
}) {
return Some(Action::publish(Message::Select(e.id)).and_capture());
}
}
None
self.input(state, event, bounds, cursor)
}
fn draw(
&self,
_: &(),
state: &Input,
renderer: &Renderer,
_: &Theme,
bounds: Rectangle,
_: mouse::Cursor,
cursor: mouse::Cursor,
) -> Vec<canvas::Geometry> {
let mut f = Frame::new(renderer, bounds.size());
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
@@ -1327,7 +1094,7 @@ impl canvas::Program<Message> for Graph {
if n.source.is_some() {
draw_text(
&mut f,
"Open source →".into(),
format!("Source preview · {} / {}", n.preview.len(), n.preview_total),
Point::new(n.rect.x + 12., n.rect.y + 94.),
11.,
style::TEAL,
@@ -1400,11 +1167,12 @@ impl canvas::Program<Message> for Graph {
);
}
}
self.overlay(state, &mut f, cursor, bounds);
vec![f.into_geometry()]
}
fn mouse_interaction(
&self,
_: &(),
_: &Input,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> mouse::Interaction {
@@ -1478,6 +1246,9 @@ mod tests {
}
fn branch(owner: Id, a: Id, b: Id, before: Id, after: Id) -> Draft {
Draft {
insertion: Insertion::default(),
fixed: false,
browsers: Default::default(),
owner,
tool: Tool::Branch,
name: String::new(),
@@ -1666,7 +1437,7 @@ mod tests {
let command = branch(owner, a, b, before, after).command(&p).unwrap();
session.execute(command).unwrap();
let result = session.project();
assert!(!result.relations.contains_key(&old_link));
assert!(result.relations.contains_key(&old_link));
assert_eq!(result.elements[&owner].steps().unwrap().len(), 6);
assert_eq!(sequence_links(result, owner).len(), 6);
assert_eq!(
@@ -1777,10 +1548,26 @@ mod tests {
draft: Some(branch(owner, a, b, before, after)),
..State::default()
};
state.update(&p, Event::Guard(String::new())).unwrap();
state
.update(
&p,
Event::Target(Choice {
id: Id::nil(),
label: "Missing".into(),
}),
)
.unwrap();
assert!(state.update(&p, Event::Submit).is_err());
assert!(state.draft.is_some());
state.update(&p, Event::Guard("ready".into())).unwrap();
state
.update(
&p,
Event::Target(Choice {
id: a,
label: "Work".into(),
}),
)
.unwrap();
let command = state.update(&p, Event::Submit).unwrap().unwrap();
assert!(state.draft.is_some());
assert!(matches!(command, Command::Batch(_)));
@@ -1836,9 +1623,8 @@ mod tests {
.unwrap();
let mut graph = Graph::new(&p, owner, owner);
let mut state = State::default();
state
.update(&p, Event::Viewport(Size::new(486., 460.)))
.unwrap();
state.viewport(Size::new(486., 460.));
state.zoom(None);
graph.scale = state.scale(graph.size, 500.);
assert!(graph.size.width * graph.scale <= 462.1);
assert!(graph.size.height * graph.scale <= 450.1);
@@ -1849,7 +1635,7 @@ mod tests {
);
let result = canvas::Program::update(
&graph,
&mut (),
&mut Input::default(),
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Rectangle::new(Point::ORIGIN, Size::new(486., 460.)),
mouse::Cursor::Available(click),
@@ -1858,12 +1644,10 @@ mod tests {
.into_inner()
.0;
assert!(matches!(result, Some(Message::Select(id)) if id == node.id));
state.update(&p, Event::Zoom(1.)).unwrap();
state
.update(&p, Event::Viewport(Size::new(800., 460.)))
.unwrap();
state.zoom(Some(1.));
state.viewport(Size::new(800., 460.));
assert_eq!(state.scale(graph.size, 500.), 1.);
state.update(&p, Event::Fit).unwrap();
state.zoom(None);
assert!(state.scale(graph.size, 500.) < 1.);
}
}
@@ -0,0 +1,744 @@
//! Local pointer state and ordinary command proposals for the derived process graph.
use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Context {
pub document: Id,
pub owner: Id,
pub revision: u64,
pub epoch: u64,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Insertion {
pub link: Option<Id>,
pub before: Option<Id>,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum CanvasTool {
#[default]
Select,
Add(Tool, ControlKind),
Connect,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum End {
Source,
Target,
}
#[derive(Debug, Clone)]
pub enum CanvasEvent {
Scrolled(Context, scenario::ScrollArea, iced::Vector),
Choose(Context, CanvasTool),
Select(Context, Id),
AppendExchange(Context),
Begin {
context: Context,
tool: Tool,
control: ControlKind,
insertion: Insertion,
},
Connect {
context: Context,
source: Id,
target: Id,
},
Reconnect {
context: Context,
link: Id,
end: End,
target: Id,
},
Reorder {
context: Context,
occurrence: Id,
before: Id,
},
Expand(Context, Id),
Open(Context, Id),
Back(Context),
Zoom(Context, Option<f32>),
Viewport(Context, Size),
}
impl CanvasEvent {
pub fn context(&self) -> Context {
match self {
Self::Scrolled(c, ..)
| Self::Choose(c, ..)
| Self::Select(c, ..)
| Self::Expand(c, ..)
| Self::Open(c, ..)
| Self::Back(c)
| Self::AppendExchange(c)
| Self::Zoom(c, ..)
| Self::Viewport(c, ..) => *c,
Self::Begin { context, .. }
| Self::Connect { context, .. }
| Self::Reconnect { context, .. }
| Self::Reorder { context, .. } => *context,
}
}
}
impl State {
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
if self.identity != Some((document, owner)) {
self.identity = Some((document, owner));
self.tool = CanvasTool::Select;
self.zoom = Some(1.);
self.fit = false;
self.viewport = None;
self.expanded.clear();
self.scroll_offsets = [iced::Vector::ZERO; 2];
}
}
pub(crate) fn scroll_offset(&self) -> iced::Vector {
self.scroll_offsets[0] + self.scroll_offsets[1]
}
pub(crate) fn scrolled(&mut self, area: scenario::ScrollArea, offset: iced::Vector) -> bool {
let index = match area {
scenario::ScrollArea::Canvas => 0,
scenario::ScrollArea::Page => 1,
};
let changed = self.scroll_offsets[index] != offset;
self.scroll_offsets[index] = offset;
changed
}
pub(crate) fn choose(&mut self, tool: CanvasTool) {
// Choosing how to edit must retain the user's current view.
self.tool = tool;
}
pub(crate) fn zoom(&mut self, value: Option<f32>) {
self.fit = value.is_none();
self.zoom = value.map(|v| v.clamp(0.5, 2.));
}
pub(crate) fn viewport(&mut self, size: Size) -> bool {
let changed = self.viewport != Some(size);
self.viewport = Some(size);
changed
}
pub(crate) fn expand(&mut self, p: &Project, owner: Id, id: Id) -> bool {
if !is_contribution(p, owner, id)
|| !matches!(
p.elements[&id].shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { .. },
..
})
)
{
return false;
}
if !self.expanded.remove(&id) {
self.expanded.insert(id);
}
true
}
pub(crate) fn remember(&mut self, owner: Id, occurrence: Id) {
self.return_to.push((owner, occurrence));
}
pub(crate) fn back(&mut self, p: &Project) -> Option<(Id, Id)> {
while let Some((owner, occurrence)) = self.return_to.pop() {
if is_contribution(p, owner, occurrence) {
return Some((owner, occurrence));
}
}
None
}
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
let owner = self.pending_owner()?;
let Command::Batch(commands) = command else {
return None;
};
commands.iter().find_map(|c| match c {
Command::CreateElement(e) if e.parent == Some(owner) => Some(e.id),
_ => None,
})
}
pub(crate) fn begin(
&mut self,
p: &Project,
context: Context,
tool: Tool,
control: ControlKind,
insertion: Insertion,
) -> Result<(), String> {
if self.draft.is_some() {
return Err(PENDING_DRAFT.into());
}
if !matches!(
tool,
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Control
) {
return Err("Choose a contribution tool.".into());
}
if !legacy_order(p, context.owner).is_empty() {
return Err("Convert the ordered path to explicit links before drawing.".into());
}
let link = insertion
.link
.map(|id| {
p.relations
.get(&id)
.filter(|r| {
r.kind() == RelationKind::SequenceLink
&& is_contribution(p, context.owner, r.source())
&& is_contribution(p, context.owner, r.target())
})
.ok_or("Choose a sequence link in this process.")
})
.transpose()?;
if insertion
.before
.is_some_and(|id| !is_contribution(p, context.owner, id))
{
return Err("Choose a presentation slot in this process.".into());
}
self.update(p, Event::Start(context.owner, tool, None))?;
let d = self.draft.as_mut().expect("started form");
d.fixed = true;
d.insertion = insertion;
d.control = control;
if let Some(r) = link {
d.before = Some(r.source());
d.after = Some(r.target());
}
Ok(())
}
pub(crate) fn append_exchange(&mut self, p: &Project, owner: Id) -> Result<(), String> {
let last = legacy_order(p, owner)
.last()
.copied()
.ok_or("Choose an ordered exchange path to append to.")?;
self.update(p, Event::Start(owner, Tool::Exchange, Some(last)))?;
self.draft.as_mut().expect("started form").fixed = true;
Ok(())
}
pub(crate) fn connect(
&mut self,
p: &Project,
owner: Id,
source: Id,
target: Id,
) -> Result<(), String> {
if self.draft.is_some() {
return Err(PENDING_DRAFT.into());
}
if !is_contribution(p, owner, source)
|| !is_contribution(p, owner, target)
|| !validation::can_connect(p, RelationKind::SequenceLink, source, target)
{
return Err("Choose compatible contributions directly owned by this process.".into());
}
self.update(p, Event::Start(owner, Tool::Connect, None))?;
let d = self.draft.as_mut().expect("started form");
d.fixed = true;
d.before = Some(source);
d.after = Some(target);
Ok(())
}
}
pub(crate) fn referenced(p: &Project, owner: Id, occurrence: Id) -> Option<Id> {
if !is_contribution(p, owner, occurrence) {
return None;
}
match p.elements[&occurrence].shared()? {
SharedBody::Occurrence {
body: OccurrenceBody::Action { action, .. },
..
} => *action,
SharedBody::Occurrence {
body: OccurrenceBody::Exchange { exchange, .. },
..
} => *exchange,
SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { sequence },
..
} => *sequence,
_ => None,
}
}
pub(crate) fn reconnect(
p: &Project,
owner: Id,
id: Id,
end: End,
endpoint: Id,
) -> Result<Command, String> {
let mut r = p
.relations
.get(&id)
.filter(|r| {
r.kind() == RelationKind::SequenceLink
&& is_contribution(p, owner, r.source())
&& is_contribution(p, owner, r.target())
})
.ok_or("Choose a sequence link in this process.")?
.clone();
if !is_contribution(p, owner, endpoint) {
return Err("Choose a local contribution endpoint.".into());
}
if let RelationBody::SequenceLink { source, target, .. } = &mut r.body {
match end {
End::Source => *source = endpoint,
End::Target => *target = endpoint,
}
}
let command = Command::EditRelation(id, r);
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
Ok(command)
}
pub(crate) fn reorder(p: &Project, owner: Id, id: Id, before: Id) -> Result<Command, String> {
if !is_contribution(p, owner, id) || !is_contribution(p, owner, before) {
return Err("Reorder contributions in this process only.".into());
}
let graph = Graph::new(p, owner, id);
let position = |id| graph.nodes.iter().find(|n| n.id == id).map(|n| n.rect.x);
if position(id) != position(before) {
return Err("Reorder within this column; links determine the path columns.".into());
}
let mut steps = p.elements[&owner].steps().unwrap().clone();
if id != before {
steps.retain(|v| *v != id);
let index = steps
.iter()
.position(|v| *v == before)
.ok_or("This slot no longer exists.")?;
steps.insert(index, id);
}
let command = Command::SetSteps(owner, steps);
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
Ok(command)
}
#[derive(Default)]
pub(super) struct Input {
context: Option<Context>,
tool: CanvasTool,
pointer: Option<Point>,
bounds: Option<Rectangle>,
source: Option<Id>,
press: Option<Press>,
}
#[derive(Clone, Copy)]
enum Press {
Connect {
source: Id,
first: bool,
start: Point,
moved: bool,
},
Reconnect {
link: Id,
end: End,
start: Point,
moved: bool,
},
Reorder {
occurrence: Id,
start: Point,
moved: bool,
},
Add {
insertion: Insertion,
},
}
impl Input {
fn cancel(&mut self) {
self.source = None;
self.press = None;
}
}
impl Graph<'_> {
fn node_at(&self, point: Point) -> Option<Id> {
self.nodes
.iter()
.rev()
.find(|n| n.rect.contains(point))
.map(|n| n.id)
}
fn edge_at(&self, point: Point) -> Option<Id> {
self.edges
.iter()
.find(|e| {
(!e.guard.is_empty() && e.label.contains(point))
|| e.points.windows(2).any(|v| {
crate::diagram::distance_to_segment(point, v[0], v[1]) < 8. / self.scale
})
})
.map(|e| e.id)
}
fn handle(&self, id: Id) -> Option<Rectangle> {
let n = self.nodes.iter().find(|n| n.id == id)?;
Some(Rectangle::new(
Point::new(n.rect.x + n.rect.width - 30., n.rect.y + 7.),
Size::new(23., 24.),
))
}
fn endpoint_at(&self, point: Point) -> Option<(Id, End)> {
let e = self.edges.iter().find(|e| e.id == self.selected)?;
[(e.points[0], End::Source), (*e.points.last()?, End::Target)]
.into_iter()
.find(|(p, _)| p.distance(point) < 10. / self.scale)
.map(|(_, end)| (e.id, end))
}
fn connectable(&self, source: Id, target: Id) -> bool {
validation::can_connect(self.project, RelationKind::SequenceLink, source, target)
}
pub(super) fn input(
&self,
state: &mut Input,
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
let (context, tool, editable) = self
.interaction
.map_or((None, CanvasTool::Select, false), |(c, t, e)| {
(Some(c), t, e)
});
if state.context != context
|| state.tool != tool
|| state.bounds.is_some_and(|b| b != bounds)
{
state.cancel();
state.pointer = None;
state.context = context;
state.tool = tool;
}
state.bounds = Some(bounds);
if !editable {
state.cancel();
}
let visible = cursor.position().is_some();
if !visible
&& matches!(
event,
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
)
{
state.cancel();
return Some(Action::request_redraw());
}
let cursor = match event {
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
state.pointer = Some(*position);
if let Some(
Press::Connect { start, moved, .. }
| Press::Reconnect { start, moved, .. }
| Press::Reorder { start, moved, .. },
) = &mut state.press
{
*moved |= position.distance(*start) >= 3.;
}
mouse::Cursor::Available(*position + self.scroll_offset)
}
canvas::Event::Mouse(mouse::Event::WheelScrolled { .. } | mouse::Event::CursorLeft)
| canvas::Event::Window(
iced::window::Event::Resized(_) | iced::window::Event::Unfocused,
) => {
state.cancel();
state.pointer = None;
return Some(Action::request_redraw());
}
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
}) => {
state.cancel();
return Some(Action::request_redraw().and_capture());
}
canvas::Event::Mouse(
mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_),
) => state
.pointer
.map_or(cursor, |p| mouse::Cursor::Available(p + self.scroll_offset)),
canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => {
state.pointer = cursor.position().map(|p| p - self.scroll_offset);
cursor
}
_ => cursor,
};
let world = cursor
.position_in(bounds)
.map(|p| Point::new(p.x / self.scale, p.y / self.scale));
let publish = |event| Some(Action::publish(Message::TopologyCanvas(event)).and_capture());
match event {
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if visible => {
let point = world?;
let Some(context) = context else {
return self
.node_at(point)
.or_else(|| self.edge_at(point))
.map(|id| Action::publish(Message::Select(id)).and_capture());
};
if !editable {
return None;
}
let start = cursor.position()? - self.scroll_offset;
match tool {
CanvasTool::Add(..) => {
state.press = Some(Press::Add {
insertion: Insertion {
link: self
.edge_at(point)
.filter(|_| self.node_at(point).is_none()),
before: self.node_at(point),
},
});
}
CanvasTool::Connect => {
let first = state.source.is_none();
let source = state.source.or_else(|| self.node_at(point))?;
state.source = Some(source);
state.press = Some(Press::Connect {
source,
first,
start,
moved: false,
});
}
CanvasTool::Select => {
if let Some((link, end)) = self.endpoint_at(point) {
state.press = Some(Press::Reconnect {
link,
end,
start,
moved: false,
});
} else if self
.handle(self.selected)
.is_some_and(|r| r.contains(point))
{
state.press = Some(Press::Reorder {
occurrence: self.selected,
start,
moved: false,
});
} else {
return self
.node_at(point)
.or_else(|| self.edge_at(point))
.and_then(|id| publish(CanvasEvent::Select(context, id)));
}
}
}
Some(Action::request_redraw().and_capture())
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
let press = state.press.take()?;
let context = context?;
match press {
Press::Add { insertion } if world.is_some() => {
if let CanvasTool::Add(tool, control) = tool {
state.cancel();
return publish(CanvasEvent::Begin {
context,
tool,
control,
insertion,
});
}
}
Press::Connect {
source,
first,
moved,
..
} if moved || !first => {
if let Some(target) = world
.and_then(|p| self.node_at(p))
.filter(|target| self.connectable(source, *target))
{
state.cancel();
return publish(CanvasEvent::Connect {
context,
source,
target,
});
}
}
Press::Reconnect {
link,
end,
moved: true,
..
} => {
if let Some(target) = world.and_then(|p| self.node_at(p)) {
state.cancel();
return publish(CanvasEvent::Reconnect {
context,
link,
end,
target,
});
}
}
Press::Reorder {
occurrence,
moved: true,
..
} => {
if let Some(before) = world.and_then(|p| self.node_at(p)) {
state.cancel();
return publish(CanvasEvent::Reorder {
context,
occurrence,
before,
});
}
}
_ => {}
}
Some(Action::request_redraw().and_capture())
}
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => {
Some(Action::request_redraw())
}
_ => None,
}
}
pub(super) fn overlay(
&self,
state: &Input,
f: &mut Frame,
cursor: mouse::Cursor,
bounds: Rectangle,
) {
let Some((context, tool, editable)) = self.interaction else {
return;
};
if !editable {
return;
}
let point = state
.pointer
.map(|p| p + self.scroll_offset)
.or_else(|| cursor.position())
.filter(|p| bounds.contains(*p))
.map(|p| Point::new((p.x - bounds.x) / self.scale, (p.y - bounds.y) / self.scale));
let live =
state.context == Some(context) && state.tool == tool && state.bounds == Some(bounds);
if live && let Some(source) = state.source {
for n in &self.nodes {
if n.id == source || self.connectable(source, n.id) {
f.stroke(
&Path::rounded_rectangle(n.rect.position(), n.rect.size(), 10.into()),
Stroke::default().with_color(style::TEAL).with_width(2.5),
);
}
}
if let (Some(n), Some(to)) = (self.nodes.iter().find(|n| n.id == source), point) {
draw_arrow(
f,
&[Point::new(n.rect.x + n.rect.width, n.rect.center_y()), to],
style::TEAL,
2.,
);
}
}
if live
&& let Some(Press::Reconnect { link, end, .. }) = state.press
&& let Some(edge) = self.edges.iter().find(|e| e.id == link)
{
for node in &self.nodes {
let (source, target) = match end {
End::Source => (node.id, edge.target),
End::Target => (edge.source, node.id),
};
if self.connectable(source, target) {
f.stroke(
&Path::rounded_rectangle(node.rect.position(), node.rect.size(), 10.into()),
Stroke::default().with_color(style::TEAL).with_width(2.5),
);
}
}
if let Some(point) = point {
let (from, to) = match end {
End::Source => (point, *edge.points.last().unwrap()),
End::Target => (edge.points[0], point),
};
draw_arrow(f, &[from, to], style::TEAL, 2.5);
}
}
if live
&& let Some(Press::Reorder { occurrence, .. }) = state.press
&& let Some(point) = point
&& let Some(target) = self.node_at(point)
{
let source = self.nodes.iter().find(|n| n.id == occurrence).unwrap();
let target = self.nodes.iter().find(|n| n.id == target).unwrap();
if source.rect.x == target.rect.x {
f.stroke(
&Path::line(
Point::new(target.rect.x, target.rect.y - 7.),
Point::new(target.rect.x + target.rect.width, target.rect.y - 7.),
),
Stroke::default().with_color(style::TEAL).with_width(3.),
);
draw_text(
f,
"Order before · paths unchanged".into(),
Point::new(target.rect.x, target.rect.y - 24.),
12.,
style::TEAL,
);
}
}
if tool == CanvasTool::Select {
if let Some(r) = self.handle(self.selected) {
f.fill_rectangle(r.position(), r.size(), Color::WHITE);
draw_text(f, "↕".into(), r.position(), 17., style::TEAL);
}
if let Some(e) = self.edges.iter().find(|e| e.id == self.selected) {
for p in [e.points[0], *e.points.last().unwrap()] {
f.fill(&Path::circle(p, 6. / self.scale), style::TEAL);
}
}
}
if let CanvasTool::Add(..) = tool
&& let Some(point) = point
{
let at = self
.edge_at(point)
.filter(|_| self.node_at(point).is_none());
let caption = if let Some(id) = at {
if let Some(e) = self.edges.iter().find(|e| e.id == id) {
draw_arrow(f, &e.points, style::TEAL, 3.);
}
"Insert on this path · preserve its guard"
} else if self.node_at(point).is_some() {
"Add before this contribution · unconnected"
} else {
"Append contribution · unconnected"
};
draw_text(
f,
caption.into(),
Point::new(point.x + 12., point.y + 16.),
12. / self.scale,
style::TEAL,
);
}
}
}
impl State {
pub(crate) fn selection_offset(
&self,
p: &Project,
owner: Id,
id: Id,
) -> Option<scrollable::AbsoluteOffset> {
let graph = Graph::expanded(p, owner, id, &self.expanded);
let scale = self.scale(graph.size, 500.);
let rect = graph
.nodes
.iter()
.find(|n| n.id == id)
.map(|n| n.rect)
.or_else(|| graph.edges.iter().find(|e| e.id == id).map(|e| e.label))?;
Some(scrollable::AbsoluteOffset {
x: (rect.x * scale - 12.).max(0.),
y: (rect.y * scale - 12.).max(0.),
})
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,493 @@
use super::*;
use iced::widget::column;
pub(super) fn picker<'a>(
mut choices: Vec<Choice>,
d: &'a Draft,
index: usize,
value: Option<Id>,
placeholder: &'a str,
change: fn(Choice) -> Event,
) -> Element<'a, Message> {
let selected = value
.and_then(|id| choices.iter().find(|c| c.id == id))
.cloned();
let browser = &d.browsers[index];
let query = browser.search.trim().to_lowercase();
choices.retain(|c| c.label.to_lowercase().contains(&query));
let range = choice_page(choices.len(), browser.page);
let page = range.start / 24;
let mut out = column![
text_input("Search compatible choices…", &browser.search)
.on_input(move |v| event(Event::Search(index, v)))
.padding(7)
.size(12)
]
.spacing(5);
if let Some(selected) = selected
.as_ref()
.filter(|c| !choices[range.clone()].iter().any(|v| v.id == c.id))
{
out = out.push(label(format!("Selected: {}", selected.label)));
}
out = out.push(
row![
button("Previous")
.on_press_maybe((page > 0).then(|| event(Event::Page(index, page - 1)))),
text(format!(
"{} choices · {}/{}",
choices.len(),
page + 1,
choices.len().div_ceil(24).max(1)
))
.size(11),
button("Next").on_press_maybe(
(range.end < choices.len()).then(|| event(Event::Page(index, page + 1)))
)
]
.spacing(6),
);
out.push(
pick_list(choices[range].to_vec(), selected, move |v| event(change(v)))
.placeholder(placeholder)
.padding(8)
.text_size(12)
.width(Fill),
)
.into()
}
pub(super) fn choice_page(len: usize, requested: usize) -> std::ops::Range<usize> {
let start = requested.min(len.saturating_sub(1) / 24) * 24;
start..(start + 24).min(len)
}
pub(super) fn view<'a>(
app: &'a App,
owner: Id,
mut out: Column<'a, Message>,
) -> Element<'a, Message> {
let p = app.project();
let context = app.topology_context().expect("visible process context");
let state = &app.behavior.topology;
let pending = state.draft.is_some();
let explicit =
uses_explicit_topology(p, owner) || p.elements[&owner].steps().is_none_or(Vec::is_empty);
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
let choose = |tool| Message::TopologyCanvas(CanvasEvent::Choose(context, tool));
// Keep the canvas at the same sibling position when its palette becomes a
// pending-form notice, so Iced retains the scrollable's local offsets.
let mut controls = Column::new().spacing(14);
if !pending && explicit {
controls = controls
.push(
row![
button("Select / reorder")
.style(if state.tool == CanvasTool::Select {
button::primary
} else {
style::nav
})
.on_press(choose(CanvasTool::Select)),
button("Work")
.style(
if state.tool == CanvasTool::Add(Tool::Work, ControlKind::Choice) {
button::primary
} else {
style::nav
}
)
.on_press(choose(CanvasTool::Add(Tool::Work, ControlKind::Choice))),
button("Exchange")
.style(
if state.tool == CanvasTool::Add(Tool::Exchange, ControlKind::Choice) {
button::primary
} else {
style::nav
}
)
.on_press(choose(CanvasTool::Add(Tool::Exchange, ControlKind::Choice))),
button("Reuse")
.style(
if state.tool == CanvasTool::Add(Tool::Reuse, ControlKind::Choice) {
button::primary
} else {
style::nav
}
)
.on_press(choose(CanvasTool::Add(Tool::Reuse, ControlKind::Choice)))
]
.spacing(6),
)
.push(
row![
pick_list(
vec![
ControlKind::Choice,
ControlKind::Merge,
ControlKind::Fork,
ControlKind::Join,
ControlKind::Loop
]
.into_iter()
.map(ControlChoice)
.collect::<Vec<_>>(),
if let CanvasTool::Add(Tool::Control, kind) = state.tool {
Some(ControlChoice(kind))
} else {
None
},
move |c| Message::TopologyCanvas(CanvasEvent::Choose(
context,
CanvasTool::Add(Tool::Control, c.0)
))
)
.placeholder("Draw control…")
.text_size(12)
.padding(8),
button("Sequence link")
.style(if state.tool == CanvasTool::Connect {
button::primary
} else {
style::nav
})
.on_press(choose(CanvasTool::Connect)),
button("Two paths…").on_press(event(Event::Start(
owner,
Tool::Branch,
selected
)))
]
.spacing(6),
);
}
controls=controls.push(label(if pending{"Pending contribution · apply or cancel below"}else if !explicit{"Ordered contributions · Append exchange continues the path"}else{match state.tool{
CanvasTool::Select=>"Select local contributions and paths · drag path endpoints to reconnect · ↕ reorders within a column",
CanvasTool::Connect=>"Sequence link · click or drag from preceding to following contribution · Escape cancels",
CanvasTool::Add(..)=>"Choose a highlighted path or contribution position · existing work stays shared · Escape cancels"}}));
out = out.push(controls);
if explicit {
let mut graph = Graph::expanded(p, owner, app.selected, &state.expanded);
graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.));
graph.interaction = Some((
context,
state.tool,
!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(),
));
graph.scroll_offset = state.scroll_offset();
let scale = graph.scale;
out = out.push(
row![
button("Fit overview")
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(context, None))),
button("−").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
context,
Some(scale / 1.25)
))),
label(format!("{:.0}%", scale * 100.)),
button("+").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
context,
Some(scale * 1.25)
))),
button("100%").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
context,
Some(1.)
)))
]
.spacing(6),
);
if graph.nodes.is_empty() {
out = out.push(label(
"Choose Work, Exchange, Reuse or a control, then click the canvas to start.",
));
}
let size = Size::new(graph.size.width * scale, graph.size.height * scale);
let canvas = scrollable(
canvas::Canvas::new(graph)
.width(size.width)
.height(size.height),
)
.id("process-canvas")
.direction(scrollable::Direction::Both {
vertical: scrollable::Scrollbar::default(),
horizontal: scrollable::Scrollbar::default(),
})
.height(if pending {
(app.window_size.height - 510.).clamp(160., 240.)
} else {
(app.window_size.height - 420.).clamp(190., 420.)
});
out = out.push(
iced::widget::sensor(
container(super::super::scenario_scroll::observe_topology(
context,
scenario::ScrollArea::Canvas,
canvas,
))
.padding(8)
.width(Fill)
.style(style::card),
)
.on_show(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size)))
.on_resize(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))),
);
} else {
out = out.push(label(
"Ordered exchange path · append the next exchange or convert to explicit links.",
));
if !pending {
out = out.push(
button("Append exchange…")
.on_press(Message::TopologyCanvas(CanvasEvent::AppendExchange(context))),
).push(label("Append creates the next exchange contribution and an explicit sequence link from the final contribution in one undo step."));
}
let commands = preserved_order(p, owner, None);
if !commands.is_empty() {
out = out.push(
button("Convert ordered path to explicit links")
.on_press(Message::Command(unique_batch(p, commands))),
);
} else {
out=out.push(label("A single exchange has no path link to convert. Append the next exchange, or involve work, a control or a reusable process."))
.push(row![button("Add work…").on_press(event(Event::Start(owner,Tool::Work,selected))),
button("Add control…").on_press(event(Event::Start(owner,Tool::Control,selected))),
button("Reuse…").on_press(event(Event::Start(owner,Tool::Reuse,selected)))].spacing(6));
}
let scene = Scene::sequence(p, owner, app.selected);
let size = scene.size;
out = out.push(
scrollable(
canvas::Canvas::new(scene)
.width(size.width)
.height(size.height),
)
.direction(scrollable::Direction::Both {
vertical: scrollable::Scrollbar::default(),
horizontal: scrollable::Scrollbar::default(),
})
.height(240.),
);
}
if let Some(id) = selected {
let mut actions =
row![button("Contribution properties").on_press(Message::EditProperties(id))]
.spacing(6);
if let Some(target) = direct::referenced(p, owner, id) {
actions = actions.push(
button(text(format!("Open source: {}", short(p.named(target), 28))).size(12))
.on_press(Message::TopologyCanvas(CanvasEvent::Open(context, id))),
);
if matches!(
p.elements[&id].shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { .. },
..
})
) {
actions = actions.push(
button(if state.expanded.contains(&id) {
"Collapse"
} else {
"Expand"
})
.on_press(Message::TopologyCanvas(CanvasEvent::Expand(context, id))),
);
}
}
out = out.push(actions);
} else if sequence_links(p, owner)
.iter()
.any(|r| r.id == app.selected)
{
out = out.push(
button("Path properties / guard").on_press(Message::EditProperties(app.selected)),
);
}
if !state.return_to.is_empty() {
out = out.push(
button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))),
);
}
if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) {
let mut form = column![text(d.tool.title()).size(16)].spacing(8);
if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) {
let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body {
guard.as_str()
} else {
""
};
form = form.push(label(format!(
"Insert on {} → {} · preserve path identity and condition [{}]",
p.named(link.source()),
p.named(link.target()),
guard
)));
} else if d.fixed
&& d.tool == Tool::Exchange
&& let Some(before) = d.before
{
form = form.push(label(format!(
"Append after {} → new exchange · Create adds an explicit sequence link",
p.named(before)
)));
} else if d.fixed && d.tool != Tool::Connect {
form = form.push(label(
d.insertion
.before
.map_or("Append an unconnected contribution".into(), |id| {
format!("Unconnected contribution before {}", p.named(id))
}),
));
}
if !legacy_order(p, owner).is_empty() {
form = form.push(label(
"This edit preserves the ordered path as explicit links in the same undo step.",
));
}
if matches!(
d.tool,
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
) {
let choices = targets(p, owner, d.tool)
.into_iter()
.filter(|c| compatible_source(p, d, c.id))
.collect();
if d.tool == Tool::Branch {
form = form.push(label("First path work"));
}
form = form.push(picker(
choices,
d,
0,
d.target,
if d.tool == Tool::Branch {
"Choose first path work…"
} else {
"Choose existing source…"
},
Event::Target,
));
}
if d.tool != Tool::Connect {
form = form.push(
text_input("Contribution name (defaults to source)", &d.name)
.on_input(|v| event(Event::Name(v)))
.padding(8)
.size(12),
);
}
if matches!(d.tool, Tool::Control | Tool::Branch) {
let kinds = if d.tool == Tool::Branch {
vec![ControlKind::Choice, ControlKind::Fork]
} else {
vec![
ControlKind::Choice,
ControlKind::Merge,
ControlKind::Fork,
ControlKind::Join,
ControlKind::Loop,
]
};
form = form.push(
pick_list(
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
Some(ControlChoice(d.control)),
|v| event(Event::Control(v)),
)
.padding(8)
.text_size(12),
);
}
if d.tool == Tool::Branch {
form = form.push(label("Second path work")).push(picker(
targets(p, owner, Tool::Work),
d,
1,
d.alternate,
"Second path work…",
Event::Alternate,
));
if d.control != ControlKind::Fork {
form = form
.push(label("First path guard"))
.push(
text_input("First path guard", &d.guard)
.on_input(|v| event(Event::Guard(v)))
.padding(8),
)
.push(label("Second path guard"))
.push(
text_input("Second path guard (e.g. else)", &d.alternate_guard)
.on_input(|v| event(Event::AlternateGuard(v)))
.padding(8),
);
}
}
if d.fixed {
if let (Some(a), Some(b)) = (d.before, d.after) {
form = form.push(label(format!("{} → {}", p.named(a), p.named(b))));
}
} else {
form = form
.push(label("Preceding contribution"))
.push(picker(
contributions(p, owner),
d,
2,
d.before,
"After contribution…",
Event::Before,
))
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
.push(label("Following contribution"))
.push(picker(
contributions(p, owner),
d,
3,
d.after,
"Before contribution…",
Event::After,
))
.push(button("Clear following").on_press(event(Event::ClearAfter)));
}
if d.tool != Tool::Branch
&& d.insertion.link.is_none()
&& (d.before.is_some() || d.tool == Tool::Connect)
{
form = form.push(
text_input(
"Path condition (empty remains a Choice / Loop warning)",
&d.guard,
)
.on_input(|v| event(Event::Guard(v)))
.padding(8)
.size(12),
);
}
let valid = match d.command(p) {
Ok(_) => true,
Err(problem) => {
form = form.push(label(problem));
false
}
};
let actions = row![
button("Create").on_press_maybe(valid.then(|| event(Event::Submit))),
button("Cancel").on_press(event(Event::Cancel))
]
.spacing(7);
out = out.push(
container(
column![
actions,
scrollable(form).height((app.window_size.height - 520.).clamp(150., 260.))
]
.spacing(8),
)
.padding(12)
.style(style::card),
);
}
super::super::scenario_scroll::observe_topology(
context,
scenario::ScrollArea::Page,
scrollable(out.padding(18)),
)
}
+180 -31
View File
@@ -45,6 +45,7 @@ pub struct Draft {
baseline: Value,
pub inputs: BTreeMap<String, Input>,
pub exact_strings: bool,
context: Option<Value>,
port_contract_drafts: BTreeMap<String, Value>,
pub value_browsers: BTreeMap<String, ValueBrowser>,
}
@@ -83,6 +84,7 @@ impl Draft {
value,
inputs: BTreeMap::new(),
exact_strings: false,
context: None,
port_contract_drafts: BTreeMap::new(),
value_browsers: BTreeMap::new(),
};
@@ -140,8 +142,32 @@ impl Draft {
}
}
pub fn materialize(&self) -> Result<Value, String> {
self.materialize_inputs(|_| true)
}
/// Read-only context for dependent controls. Workflow proposals never replace
/// the authored value or its input buffers and are not used by command().
pub fn context_value(&self) -> Value {
self.context
.clone()
.unwrap_or_else(|| self.materialize().unwrap_or_else(|_| self.value.clone()))
}
pub fn set_context(&mut self, context: Option<Value>) {
self.context = context;
}
/// Materialize selected scalar subtrees without parsing retained, unselected drafts.
pub fn materialize_scopes(&self, scopes: &[String]) -> Result<Value, String> {
self.materialize_inputs(|path| {
scopes
.iter()
.any(|root| path == root || path.starts_with(&format!("{root}/")))
})
}
fn materialize_inputs(&self, include: impl Fn(&str) -> bool) -> Result<Value, String> {
let mut value = self.value.clone();
for (path, input) in &self.inputs {
if !include(path) {
continue;
}
let Some(target) = value.pointer_mut(path) else {
continue;
};
@@ -212,6 +238,97 @@ impl Draft {
self.rebuild();
Ok(())
}
/// Replace a workflow-authorized subtree without parsing unrelated inputs,
/// including an invalid value which this replacement deliberately removes.
pub fn shape_scoped(&mut self, path: &str, value: Value) -> Result<(), String> {
*self
.value
.pointer_mut(path)
.ok_or("Property no longer exists.")? = value;
self.rebuild_retaining(|old| (!within(old, path)).then(|| old.to_owned()));
Ok(())
}
fn rebuild_retaining(&mut self, new_path: impl Fn(&str) -> Option<String>) {
let previous = std::mem::take(&mut self.inputs);
self.rebuild();
for (path, input) in previous {
if let Some(path) = new_path(&path)
&& self.inputs.contains_key(&path)
{
self.inputs.insert(path, input);
}
}
}
pub fn array_scoped(
&mut self,
path: &str,
index: Option<usize>,
direction: i32,
) -> Result<(), String> {
let mut candidate = self.value.clone();
let count = candidate
.pointer(path)
.and_then(Value::as_array)
.ok_or("List no longer exists.")?
.len();
edit_array(&mut candidate, path, index, direction)?;
self.value = candidate;
self.rebuild_retaining(|old| {
let Some(tail) = old.strip_prefix(&format!("{path}/")) else {
return Some(old.to_owned());
};
let (number, rest) = tail.split_once('/').unwrap_or((tail, ""));
let position = number.parse::<usize>().ok()?;
let position = match index {
Some(index) if direction == 0 && position == index => return None,
Some(index) if direction == 0 && position > index => position - 1,
Some(index) if direction != 0 => {
let target = (index as i32 + direction).clamp(0, count as i32 - 1) as usize;
if position == index {
target
} else if position == target {
index
} else {
position
}
}
_ => position,
};
Some(if rest.is_empty() {
format!("{path}/{position}")
} else {
format!("{path}/{position}/{rest}")
})
});
Ok(())
}
pub fn append_scoped(&mut self, path: &str, value: Value) -> Result<(), String> {
self.value
.pointer_mut(path)
.and_then(Value::as_array_mut)
.ok_or("List no longer exists.")?
.push(value);
self.rebuild_retaining(|path| Some(path.to_owned()));
Ok(())
}
pub fn value_path_scoped(
&mut self,
path: &str,
key: crate::data_values::FieldPathKey,
) -> Result<(), String> {
let row = self
.value
.pointer_mut(path)
.and_then(Value::as_object_mut)
.ok_or("Message value no longer exists.")?;
row.insert("path".into(), json!(key.path));
row.insert("field".into(), json!(key.field));
self.rebuild_retaining(|old| {
(!within(old, &format!("{path}/path")) && !within(old, &format!("{path}/field")))
.then(|| old.to_owned())
});
Ok(())
}
pub fn array(
&mut self,
path: &str,
@@ -219,36 +336,7 @@ impl Draft {
direction: i32,
) -> Result<(), String> {
let mut candidate = self.materialize()?;
let items = candidate
.pointer_mut(path)
.and_then(Value::as_array_mut)
.ok_or("List no longer exists.")?;
match index {
None => items.push(if path.ends_with("literals") {
json!("New value")
} else if path.ends_with("bindings") {
json!({"referenced": Id::nil(), "local": Id::nil()})
} else if path.ends_with("values") {
json!({"path": [], "field": Id::nil(), "expression": ""})
} else if path.ends_with("routes") {
json!({"edge": Id::nil(), "waypoints": [], "label": null})
} else if path.ends_with("waypoints") {
json!({"x": 0.0, "y": 0.0})
} else if path.ends_with("port_anchors") {
json!({"port": Id::nil(), "side": "Right", "offset": 0.5})
} else {
json!(Id::nil())
}),
Some(i) if i < items.len() => {
if direction == 0 {
items.remove(i);
} else {
let target = (i as i32 + direction).clamp(0, items.len() as i32 - 1) as usize;
items.swap(i, target);
}
}
_ => return Err("List item no longer exists.".into()),
}
edit_array(&mut candidate, path, index, direction)?;
self.value = candidate;
self.rebuild();
Ok(())
@@ -350,6 +438,51 @@ impl Draft {
}
}
fn within(path: &str, root: &str) -> bool {
path == root
|| path
.strip_prefix(root)
.is_some_and(|tail| tail.starts_with('/'))
}
fn edit_array(
value: &mut Value,
path: &str,
index: Option<usize>,
direction: i32,
) -> Result<(), String> {
let items = value
.pointer_mut(path)
.and_then(Value::as_array_mut)
.ok_or("List no longer exists.")?;
match index {
None => items.push(if path.ends_with("literals") {
json!("New value")
} else if path.ends_with("bindings") {
json!({"referenced": Id::nil(), "local": Id::nil()})
} else if path.ends_with("values") {
json!({"path": [], "field": Id::nil(), "expression": ""})
} else if path.ends_with("routes") {
json!({"edge": Id::nil(), "waypoints": [], "label": null})
} else if path.ends_with("waypoints") {
json!({"x": 0.0, "y": 0.0})
} else if path.ends_with("port_anchors") {
json!({"port": Id::nil(), "side": "Right", "offset": 0.5})
} else {
json!(Id::nil())
}),
Some(i) if i < items.len() => {
if direction == 0 {
items.remove(i);
} else {
let target = (i as i32 + direction).clamp(0, items.len() as i32 - 1) as usize;
items.swap(i, target);
}
}
_ => return Err("List item no longer exists.".into()),
}
Ok(())
}
fn friendly(name: &str) -> String {
match name {
"id" => "UUID identity".into(),
@@ -564,7 +697,7 @@ fn reference_choices(draft: &Draft, project: &Project, path: &str, current: Id)
}
fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
let value = draft.materialize().unwrap_or_else(|_| draft.value.clone());
let value = draft.context_value();
if path.starts_with("/port_anchors/") && path.ends_with("/port") {
let owner = value
.get("element")
@@ -1385,6 +1518,22 @@ pub fn view<'a>(draft: &'a Draft, project: &'a Project) -> View<'a, Message> {
content.into()
}
/// Render one closed property subtree for a workflow-owned typed draft.
/// The caller routes messages to that draft and authorizes the selected scope.
pub fn scoped<'a>(
draft: &'a Draft,
project: &'a Project,
path: &str,
label: &str,
) -> View<'a, Message> {
match draft.value.pointer(path) {
Some(value) => property(draft, project, value, path, label, false),
None => text("This property is not applicable to the current target.")
.size(12)
.into(),
}
}
fn property<'a>(
draft: &'a Draft,
project: &'a Project,
+256 -5
View File
@@ -3,7 +3,7 @@ use super::*;
use iced::widget::column;
fn element(draft: &Draft, project: &Project) -> Option<Element> {
serde_json::from_value(draft.materialize().unwrap_or_else(|_| draft.value.clone()))
serde_json::from_value(draft.context_value())
.ok()
.or_else(|| project.elements.get(&draft.original).cloned())
}
@@ -113,9 +113,9 @@ pub(super) fn property<'a>(
let minimum_path = path.replace("maximum", "minimum");
json!(
draft
.materialize()
.ok()
.and_then(|v| v.pointer(&minimum_path).and_then(Value::as_u64))
.context_value()
.pointer(&minimum_path)
.and_then(Value::as_u64)
.unwrap_or(1)
.max(1)
)
@@ -311,7 +311,7 @@ fn message_value<'a>(
value: &'a Value,
path: &str,
) -> View<'a, Message> {
let edited = draft.materialize().unwrap_or_else(|_| draft.value.clone());
let edited = draft.context_value();
let row = edited.pointer(path).unwrap_or(value).clone();
let current = serde_json::from_value::<MessageValue>(row.clone()).ok();
let owner = element(draft, project);
@@ -513,6 +513,257 @@ fn field_page(
mod tests {
use super::*;
fn semantic_state(app: &crate::app::App) -> &crate::semantic_review::State {
let Some(crate::workflow::Workflow::Semantic(state)) = &app.workflow else {
panic!()
};
state
}
fn semantic_send(app: &mut crate::app::App, event: crate::semantic_review::Event) {
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(event)));
}
fn semantic_decide(app: &mut crate::app::App, facet: FieldFacet, decision: SemanticDecision) {
use crate::semantic_review::Event;
semantic_send(app, Event::Select(FacetSelector::Field(facet)));
semantic_send(
app,
Event::Decide(FacetSelector::Field(facet), Some(decision)),
);
}
fn default_buttons(draft: &Draft, project: &Project, path: &str) -> usize {
use iced::advanced::widget::{Tree, tree::Tag};
let button: View<'_, Message> = button(text("Button")).into();
let tag = Tree::new(button.as_widget()).tag;
let view = property(draft, project, draft.value.pointer(path).unwrap(), path).unwrap();
fn count(tree: &Tree, tag: Tag) -> usize {
usize::from(tree.tag == tag)
+ tree
.children
.iter()
.map(|child| count(child, tag))
.sum::<usize>()
}
count(&Tree::new(view.as_widget()), tag)
}
#[test]
fn semantic_reference_choices_follow_selected_mechanism_and_revert_on_keep() {
use crate::semantic_review::{Event, State};
let mut p = Project::empty();
let oa = p
.add_element(Kind::ExchangeItem, Layer::Oa, "Source", None)
.unwrap();
let sa = p
.add_element(Kind::ExchangeItem, Layer::Sa, "Target", None)
.unwrap();
let operation = p
.add_element(Kind::ExchangeItem, Layer::Sa, "Operation base", None)
.unwrap();
let flow = p
.add_element(Kind::ExchangeItem, Layer::Sa, "Flow base", None)
.unwrap();
for (id, next) in [
(oa, ExchangeMechanism::Operation),
(sa, ExchangeMechanism::Flow),
(operation, ExchangeMechanism::Operation),
(flow, ExchangeMechanism::Flow),
] {
let Some(SharedBody::ExchangeItem { mechanism, .. }) =
p.elements.get_mut(&id).unwrap().shared_mut()
else {
panic!()
};
*mechanism = next;
}
let trace = Id::new_v4();
p.traces.insert(
trace,
Trace {
id: trace,
alias: "item_trace".into(),
name: "Review item".into(),
description: String::new(),
oa,
sa,
kind: TraceKind::Manual,
baseline: None,
},
);
let mut state = State::new(&p, oa).unwrap();
let facet = FacetSelector::ExchangeItem(ExchangeItemFacet::Mechanism);
let path = "/body/Sa/Shared/ExchangeItem/supertype";
let raw = state.draft.as_ref().unwrap().value.clone();
state.update(
&p,
Event::Decide(facet.clone(), Some(SemanticDecision::TakeSource)),
);
assert!(state.can_apply(), "{:?}", state.error);
let choices = reference_ids(state.draft.as_ref().unwrap(), &p, path);
assert!(choices.contains(&operation));
assert!(!choices.contains(&flow));
assert_eq!(state.draft.as_ref().unwrap().value, raw);
state.update(&p, Event::Decide(facet, Some(SemanticDecision::KeepTarget)));
let choices = reference_ids(state.draft.as_ref().unwrap(), &p, path);
assert!(!choices.contains(&operation));
assert!(choices.contains(&flow));
}
#[test]
fn independent_review_combined_take_type_informs_actual_default_form_in_both_directions() {
use crate::semantic_review::Event;
for reverse in [false, true] {
for decision in [SemanticDecision::TakeSource, SemanticDecision::Candidate] {
let mut p = Project::empty();
let mut pair = vec![];
for layer in [Layer::Oa, Layer::Sa] {
let source = (layer == Layer::Oa) != reverse;
let record = p
.add_element(Kind::DataType, layer, "Record", None)
.unwrap();
*p.elements.get_mut(&record).unwrap().shared_mut().unwrap() =
SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype: None },
default: None,
unit: String::new(),
};
let field = p
.add_element(Kind::DataField, layer, "Payload", Some(record))
.unwrap();
*p.elements.get_mut(&field).unwrap().shared_mut().unwrap() =
SharedBody::DataField {
type_ref: if source {
TypeRef::Boolean
} else {
TypeRef::Integer
},
minimum: 0,
maximum: Some(1),
unit: String::new(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
};
pair.push(field);
}
let trace = Id::new_v4();
p.traces.insert(
trace,
Trace {
id: trace,
alias: "payload_trace".into(),
name: "Payload review".into(),
description: String::new(),
oa: pair[0],
sa: pair[1],
kind: TraceKind::Manual,
baseline: None,
},
);
let (source, target, layer) = if reverse {
(pair[1], pair[0], "Oa")
} else {
(pair[0], pair[1], "Sa")
};
let root = format!("/body/{layer}/Shared/DataField");
let default = format!("{root}/default");
let type_path = format!("{root}/type_ref");
let minimum = format!("{root}/minimum");
let mut app = crate::app::App::with_project(p);
let before = app.document.snapshot();
let _ = app.update(Message::StartSemanticReview(source));
// An unfinished candidate must not make the context fall back to
// the old target type, or contaminate a later Keep decision.
semantic_decide(&mut app, FieldFacet::Minimum, SemanticDecision::Candidate);
semantic_send(&mut app, Event::Input(minimum.clone(), "-".into()));
semantic_decide(&mut app, FieldFacet::Type, decision);
if decision == SemanticDecision::Candidate {
semantic_send(
&mut app,
Event::Variant(type_path.clone(), json!("Boolean")),
);
}
semantic_decide(&mut app, FieldFacet::Default, SemanticDecision::Candidate);
assert!(!semantic_state(&app).can_apply());
let draft = semantic_state(&app).draft.as_ref().unwrap();
assert!(matches!(
element(draft, app.project()).unwrap().shared(),
Some(SharedBody::DataField {
type_ref: TypeRef::Boolean,
..
})
));
assert_eq!(draft.inputs[&minimum].raw, "-");
semantic_decide(&mut app, FieldFacet::Minimum, SemanticDecision::KeepTarget);
semantic_decide(&mut app, FieldFacet::Default, SemanticDecision::Candidate);
assert!(semantic_state(&app).can_apply());
let draft = semantic_state(&app).draft.as_ref().unwrap();
assert_eq!(default_buttons(draft, app.project(), &default), 2);
// These are the same element/definition/literal helpers used by
// the visible Set literal default and Add literal buttons.
let owner = element(draft, app.project()).unwrap();
let Some(SharedBody::DataField { type_ref, .. }) = owner.shared() else {
panic!()
};
let initial = literal(&definition(app.project(), type_ref).unwrap()).unwrap();
assert_eq!(initial, json!({"Boolean": false}));
if decision == SemanticDecision::TakeSource {
assert_eq!(draft.value.pointer(&type_path), Some(&json!("Integer")));
}
// Reverting the type changes the controls without changing its
// retained candidate. Storage constraints likewise read context.
semantic_decide(&mut app, FieldFacet::Type, SemanticDecision::KeepTarget);
assert!(matches!(
element(semantic_state(&app).draft.as_ref().unwrap(), app.project())
.unwrap()
.shared(),
Some(SharedBody::DataField {
type_ref: TypeRef::Integer,
..
})
));
semantic_decide(&mut app, FieldFacet::Type, decision);
semantic_decide(&mut app, FieldFacet::Storage, SemanticDecision::Candidate);
semantic_send(
&mut app,
Event::Variant(format!("{root}/storage"), json!("Reference")),
);
assert_eq!(
default_buttons(
semantic_state(&app).draft.as_ref().unwrap(),
app.project(),
&default
),
0
);
semantic_decide(&mut app, FieldFacet::Storage, SemanticDecision::KeepTarget);
assert_eq!(
default_buttons(
semantic_state(&app).draft.as_ref().unwrap(),
app.project(),
&default
),
2
);
semantic_decide(&mut app, FieldFacet::Default, SemanticDecision::Candidate);
semantic_send(&mut app, Event::Shape(default, json!([initial])));
assert!(
semantic_state(&app).can_apply(),
"{:?}",
semantic_state(&app).error
);
assert_eq!(app.document.bundle(), &before.bundle);
let _ = app.update(Message::Workflow(crate::workflow::Event::Apply));
assert!(
matches!(app.project().elements[&target].shared(), Some(SharedBody::DataField {
type_ref: TypeRef::Boolean, default: Some(values), minimum: 0, storage: FieldStorage::Value, ..
}) if values == &vec![DataValue::Boolean(false)])
);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
}
}
}
#[test]
fn selecting_qualified_path_preserves_expression_typed_before_the_next_render() {
let (mut f, next) = crate::data_values::tests::recursive_payload();
+2 -2
View File
@@ -42,7 +42,7 @@ pub(super) fn variants(
_value: &Value,
) -> Option<Vec<(String, Value)>> {
if path.ends_with("/Port/contract") {
let current = draft.materialize().unwrap_or_else(|_| draft.value.clone());
let current = draft.context_value();
let sa = current.pointer("/body/Sa").is_some();
if !sa {
return Some(vec![]);
@@ -80,7 +80,7 @@ pub(super) fn variants(
);
}
if path.ends_with("/Port/contract/Component/orientation") {
let current = draft.materialize().unwrap_or_else(|_| draft.value.clone());
let current = draft.context_value();
let kind_path = path.strip_suffix("orientation").unwrap().to_owned() + "kind";
let orientations: &[&str] =
if current.pointer(&kind_path).and_then(Value::as_str) == Some("Standard") {
+31 -9
View File
@@ -102,7 +102,7 @@ impl Topic {
Self::Source => "Textual editing & Git",
Self::Recovery => "Checks & error recovery",
Self::Files => "Files & keyboard shortcuts",
Self::Server => "REST API & automation",
Self::Server => "Hosted models & REST API",
}
}
@@ -751,16 +751,42 @@ fn article(topic: Topic) -> Article {
Topic::Server => {
const {
Article {
summary: "The Rust REST service gives scripts and engineering-tool plugins access to the same model, commands, validation and source representation.",
summary: "Use optional hosted storage from the desktop, scripts and engineering-tool plugins. All clients work with the same model, validation and authored source.",
keywords: "server rest api automation ai plugin curl http etag if-match revision resource_id deploy hosted cad scripting openapi token",
sections: &[
example(
"Start a local service",
"Run the separate arcadia-server binary from a terminal. Its default address is loopback 127.0.0.1:8787. docs/server.md describes storage, authentication and Linux deployment.",
"arcadia-server --data-dir ./arcadia-models\n\nGET http://127.0.0.1:8787/health\nGET http://127.0.0.1:8787/api/v1/openapi.json",
"From the repository root, run the command below in a terminal and leave it running. The default address is loopback 127.0.0.1:8787, with platform-local storage. docs/server.md also covers installed executables, authentication and Linux deployment.",
"cargo run --locked -p oa-server --bin arcadia-server\n\nGET http://127.0.0.1:8787/health\nGET http://127.0.0.1:8787/api/v1/openapi.json",
),
walkthrough(
"Build a safe editing client",
"Open or publish a hosted model",
&[
"Choose Hosted models... in the top bar. Enter the service root, such as http://127.0.0.1:8787, without /api/v1. Enter the configured bearer token if required, then Connect / refresh.",
"Choose Publish as new to upload the current source project, including its files and comments, as a separate hosted resource. Its acknowledgement switches the current Save destination to that resource.",
"To open an existing resource, search the Hosted model catalog, select a model and choose Open selected. More models loads another page. Unsaved work uses the normal save/discard/cancel prompt.",
"Edit diagrams, properties or Textual model, then choose Save to server or press Ctrl+S. Valid drafts are applied; invalid drafts remain available to correct or revert. Edits and Undo stay local until Save.",
],
),
walkthrough(
"Review another client's changes",
&[
"Check server reads the current revision without replacing local content. A stale Save returns 412 and automatically checks the server for review; local work is retained.",
"Review the changed file list. View server text opens a read-only file; Back to review returns to the list. There is no automatic merge.",
"Reload server version adopts the reviewed source project as one undoable local change after checking unapplied drafts. Undo restores the previous accepted local version without changing the server. Reconcile your intended changes before saving again.",
"After an unknown write outcome, use Check previous write when offered. If publication may already have succeeded, inspect the refreshed catalog before publishing another copy.",
],
),
explain(
"Return to a local file",
"Choose Save as local... or Ctrl+Shift+S, then save an .arcadia entry to retain included files and comments. A successful source-project save makes that file the current destination and leaves the hosted resource unchanged. JSON exports the model only and retains the hosted destination and unsaved state. Canceling or a failed save also retains the hosted destination. Forget connection becomes available after leaving the hosted session.",
),
explain(
"Connection credentials",
"The desktop keeps credentials in memory only. Use HTTPS remotely; HTTP is allowed only for localhost or a loopback IP. Put the token in its own field, never the service URL. URLs with credentials, queries or fragments are rejected, and redirects are not followed. To update a token, enter it and Connect / refresh for the same service, then retry the check or Save.",
),
walkthrough(
"Automate model edits",
&[
"Create or import a project using /api/v1/projects or /api/v1/projects/import. Keep the returned service resource_id; it is distinct from the editable model UUID.",
"Read the project's envelope, source or model. Preserve its exact ETag as the precondition for your edit.",
@@ -769,10 +795,6 @@ fn article(topic: Topic) -> Article {
"Query /artifact-links and /traces to connect domain-tool artifacts to model records. The server stores these references; the integration is responsible for resolving its external artifacts.",
],
),
explain(
"Desktop files and hosted models",
"The desktop currently edits local model files. To exchange with the service, export its /source or /model response and open that file; upload a reviewed candidate through the API with its original revision precondition. The desktop does not maintain a live hosted editing session.",
),
explain(
"Deployment and persistence",
"Non-loopback binding requires a configured bearer token. Use the documented local-disk storage and deployment configuration. The service keeps the current revision and serializes accepted updates; it does not provide retained revision history or server-side undo. Stop the service cleanly before copying its committed store for backup.",
+621
View File
@@ -0,0 +1,621 @@
//! Optional HTTP transport. It never adopts a document or persists credentials.
use oa_core::{
Id, Project,
source_project::{self, CompiledSourceProject, ProjectLimits, SourceBundle},
};
use reqwest::{Client, Method, Url, header};
use serde::{Deserialize, de::DeserializeOwned};
use std::{fmt, net::IpAddr, sync::Arc, time::Duration};
// The service permits two 8 MiB representations plus envelope framing. Keep
// transport/parser budgets finite even when connecting to a misconfigured host.
const MAX_RESPONSE_BYTES: usize = 17 * 1024 * 1024;
const MAX_PROBLEM_BYTES: usize = 256 * 1024;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(40);
const SOURCE_LIMITS: ProjectLimits = ProjectLimits {
max_files: 256,
max_include_depth: 32,
max_include_edges: 1_024,
model: oa_core::language::ParseLimits {
max_source_bytes: 8 * 1024 * 1024,
max_graph_objects: 5_000,
max_records: 20_000,
max_step_references: 50_000,
},
};
#[derive(Clone, Default)]
pub struct Secret(pub String);
impl fmt::Debug for Secret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Secret([redacted])")
}
}
#[derive(Clone)]
pub struct Connection {
base: Url,
client: Client,
token: Secret,
}
impl fmt::Debug for Connection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Do not expose headers, credentials, or user-supplied URL text.
f.debug_struct("Connection").finish_non_exhaustive()
}
}
#[derive(Clone, Debug)]
pub struct Remote {
pub resource_id: Id,
pub revision: u64,
pub etag: String,
pub compiled: Arc<CompiledSourceProject>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct Catalog {
pub items: Vec<Summary>,
pub next_after: Option<Id>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct Summary {
pub resource_id: Id,
pub revision: u64,
pub model_id: Id,
pub name: String,
pub elements: usize,
pub relations: usize,
}
#[derive(Clone, Debug)]
pub struct Failure {
pub detail: String,
pub code: String,
pub status: Option<u16>,
/// A mutation may have committed. Read current state before another write.
pub uncertain: bool,
/// Publication was acknowledged, but its accepted source could not be read.
pub published: Option<Id>,
}
impl Failure {
fn local(code: &str, detail: &str) -> Self {
Self {
detail: detail.into(),
code: code.into(),
status: None,
uncertain: false,
published: None,
}
}
}
impl fmt::Display for Failure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.detail)
}
}
impl std::error::Error for Failure {}
#[derive(Deserialize)]
struct Envelope {
resource_id: Id,
revision: u64,
model: Project,
source_project: SourceBundle,
}
#[derive(Deserialize)]
struct AcceptedSource {
revision: u64,
source_project: SourceBundle,
}
#[derive(Deserialize)]
struct Problem {
code: String,
detail: String,
#[serde(default)]
issues: Vec<ProblemIssue>,
}
#[derive(Deserialize)]
struct ProblemIssue {
message: String,
path: Option<String>,
line: Option<usize>,
column: Option<usize>,
}
struct Reply {
status: u16,
etag: Option<String>,
body: Vec<u8>,
}
impl Connection {
/// `base` is the service root, optionally beneath a reverse-proxy path.
/// Requests append `api/v1/`; credentials belong in `token`, never the URL.
pub fn new(base: &str, token: Secret) -> Result<Self, Failure> {
Self::with_timeout(base, token, REQUEST_TIMEOUT)
}
fn with_timeout(base: &str, token: Secret, timeout: Duration) -> Result<Self, Failure> {
let invalid = || {
Failure::local(
"invalid_endpoint",
"Enter an HTTPS service URL, or HTTP on localhost/loopback, without credentials, query parameters or a fragment.",
)
};
let base = base.trim();
if base.contains('\\')
|| base.chars().any(char::is_control)
|| base.split_once("://").is_some_and(|(_, rest)| {
rest.split(['/', '?', '#'])
.next()
.is_some_and(|authority| authority.contains('@'))
})
{
return Err(invalid());
}
let mut base = Url::parse(base).map_err(|_| invalid())?;
let host = base.host_str().ok_or_else(invalid)?;
let loopback = host.eq_ignore_ascii_case("localhost")
|| host
.trim_matches(['[', ']'])
.parse::<IpAddr>()
.is_ok_and(|ip| ip.is_loopback());
if !(base.scheme() == "https" || (base.scheme() == "http" && loopback))
|| !base.username().is_empty()
|| base.password().is_some()
|| base.query().is_some()
|| base.fragment().is_some()
{
return Err(invalid());
}
if !base.path().ends_with('/') {
// set_path accepts the existing URL's encoded path without decoding
// it; this preserves an optional reverse-proxy prefix exactly.
base.set_path(&format!("{}/", base.path()));
}
let mut headers = header::HeaderMap::new();
headers.insert(
header::ACCEPT,
header::HeaderValue::from_static("application/json"),
);
if !token.0.is_empty() {
if !token.0.bytes().all(|byte| byte.is_ascii_graphic()) {
return Err(Failure::local(
"invalid_token",
"The bearer token must contain printable ASCII without whitespace.",
));
}
let mut value =
header::HeaderValue::from_str(&format!("Bearer {}", token.0)).map_err(|_| {
Failure::local(
"invalid_token",
"The bearer token is not a valid HTTP credential.",
)
})?;
value.set_sensitive(true);
headers.insert(header::AUTHORIZATION, value);
}
let mut builder = Client::builder()
.default_headers(headers)
.redirect(reqwest::redirect::Policy::none())
.retry(reqwest::retry::never())
.connect_timeout(Duration::from_secs(10).min(timeout))
.timeout(timeout)
.user_agent("Arcadia-Studio/hosted-desktop");
if loopback {
builder = builder.no_proxy();
}
let client = builder.build().map_err(|_| {
Failure::local(
"client_unavailable",
"The HTTP client could not be initialized.",
)
})?;
Ok(Self {
base,
client,
token,
})
}
pub fn base(&self) -> &str {
self.base.as_str()
}
fn endpoint(&self, path: &str) -> Url {
// All path suffixes are literals or UUIDs, never user-provided paths.
self.base
.join(&format!("api/v1/{path}"))
.expect("validated hierarchical base")
}
fn redact(&self, text: String) -> String {
let text = if self.token.0.is_empty() {
text
} else {
text.replace(&self.token.0, "[redacted]")
};
// Limit user-visible diagnostics independently of the response budget.
text.chars()
.filter(|c| !c.is_control() || matches!(c, '\n' | '\t'))
.take(8_192)
.collect()
}
fn invalid_reply(&self, reply: &Reply, mutation: bool, detail: &str) -> Failure {
Failure {
detail: detail.into(),
code: "invalid_response".into(),
status: Some(reply.status),
uncertain: mutation,
published: None,
}
}
fn decode<T: DeserializeOwned>(&self, reply: &Reply, mutation: bool) -> Result<T, Failure> {
serde_json::from_slice(&reply.body).map_err(|_| {
self.invalid_reply(
reply,
mutation,
"The service returned an invalid JSON response. Local work is unchanged.",
)
})
}
fn etag(&self, reply: &Reply, mutation: bool) -> Result<String, Failure> {
reply
.etag
.as_deref()
.filter(|value| valid_etag(value))
.map(str::to_owned)
.ok_or_else(|| {
self.invalid_reply(
reply,
mutation,
"The service did not return a valid strong ETag. Local work is unchanged.",
)
})
}
async fn request(
&self,
method: Method,
url: Url,
bundle: Option<SourceBundle>,
etag: Option<&str>,
) -> Result<Reply, Failure> {
let mutation = method != Method::GET;
let expected_status = if method == Method::POST { 201 } else { 200 };
let mut request = self.client.request(method, url);
if let Some(bundle) = bundle {
let bytes = serde_json::to_vec(&bundle).map_err(|_| {
Failure::local(
"invalid_source_project",
"The source project could not be encoded for the service.",
)
})?;
if bytes.len() > 8 * 1024 * 1024 {
return Err(Failure::local(
"request_too_large",
"The source project exceeds the service's 8 MiB JSON request limit. Your local model remains available; save it locally or reduce the hosted project.",
));
}
request = request
.header(header::CONTENT_TYPE, "application/json")
.body(bytes);
}
if let Some(etag) = etag {
if !valid_etag(etag) {
return Err(Failure::local(
"invalid_etag",
"A captured strong server ETag is required to save. Check the server version first.",
));
}
request = request.header(header::IF_MATCH, etag);
}
let mut response = request.send().await.map_err(|error| Failure {
detail: if error.is_timeout() {
"The service request timed out. Check the server before retrying a write."
} else {
"The service could not be reached or the connection was interrupted. Check the URL, service and credentials."
}.into(),
code: if error.is_timeout() { "timeout" } else { "connection_failed" }.into(),
status: None,
uncertain: mutation && !error.is_connect() && !error.is_builder(),
published: None,
})?;
let status = response.status().as_u16();
let limit = if response.status().is_success() {
MAX_RESPONSE_BYTES
} else {
MAX_PROBLEM_BYTES
};
let uncertain =
mutation && (response.status().is_success() || response.status().is_server_error());
let oversized = || Failure {
detail:
"The service response exceeds the desktop transport limit. Local work is unchanged."
.into(),
code: "response_too_large".into(),
status: Some(status),
uncertain,
published: None,
};
if response
.content_length()
.is_some_and(|size| size > limit as u64)
{
return Err(oversized());
}
let etag = response
.headers()
.get(header::ETAG)
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
let mut body = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|_| Failure {
detail:
"The service response was interrupted. Check the server before retrying a write."
.into(),
code: "response_interrupted".into(),
status: Some(status),
uncertain,
published: None,
})? {
if chunk.len() > limit.saturating_sub(body.len()) {
return Err(oversized());
}
body.extend_from_slice(&chunk);
}
if status != expected_status {
let mut failure = Failure {
detail: format!("The service returned HTTP {status}. Local work is unchanged."),
code: if (300..400).contains(&status) {
"redirect_refused"
} else {
"http_error"
}
.into(),
status: Some(status),
uncertain,
published: None,
};
if let Ok(problem) = serde_json::from_slice::<Problem>(&body) {
failure.uncertain |= mutation && problem.code == "outcome_unknown";
// These service failures explicitly precede admission/commit.
// Other 5xx responses remain uncertain, including storage I/O.
if matches!(
(status, problem.code.as_str()),
(503, "overloaded") | (507, "store_capacity")
) {
failure.uncertain = false;
}
if !problem.code.is_empty()
&& problem.code.len() <= 80
&& problem
.code
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
{
failure.code = self.redact(problem.code);
}
let mut detail = problem.detail;
for issue in problem.issues.into_iter().take(20) {
detail.push('\n');
if let Some(path) = issue.path {
detail.push_str(&path);
if let Some(line) = issue.line {
detail.push_str(&format!(":{line}"));
if let Some(column) = issue.column {
detail.push_str(&format!(":{column}"));
}
}
detail.push_str(": ");
}
detail.push_str(&issue.message);
}
failure.detail = self.redact(detail);
}
return Err(failure);
}
Ok(Reply { status, etag, body })
}
pub async fn catalog(&self, query: &str, after: Option<Id>) -> Result<Catalog, Failure> {
let mut url = self.endpoint("projects");
{
let mut pairs = url.query_pairs_mut();
pairs.append_pair("limit", "100");
if !query.is_empty() {
pairs.append_pair("q", query);
}
if let Some(after) = after {
pairs.append_pair("after", &after.to_string());
}
}
let reply = self.request(Method::GET, url, None, None).await?;
let catalog: Catalog = self.decode(&reply, false)?;
let mut previous = after;
for item in &catalog.items {
if item.resource_id.is_nil()
|| item.model_id.is_nil()
|| item.revision == 0
|| previous.is_some_and(|id| item.resource_id <= id)
{
return Err(self.invalid_reply(
&reply,
false,
"The service returned an invalid project catalog.",
));
}
previous = Some(item.resource_id);
}
if catalog.items.len() > 100
|| catalog
.next_after
.is_some_and(|id| Some(id) != previous || catalog.items.is_empty())
{
return Err(self.invalid_reply(
&reply,
false,
"The service returned invalid catalog pagination.",
));
}
Ok(catalog)
}
pub async fn open(&self, id: Id) -> Result<Remote, Failure> {
let reply = self
.request(
Method::GET,
self.endpoint(&format!("projects/{id}")),
None,
None,
)
.await?;
let envelope: Envelope = self.decode(&reply, false)?;
let etag = self.etag(&reply, false)?;
if envelope.resource_id != id
|| id.is_nil()
|| envelope.revision == 0
|| envelope.model.id.is_nil()
{
return Err(self.invalid_reply(
&reply,
false,
"The service returned a different project identity or invalid revision.",
));
}
let compiled = self.compile(envelope.source_project, &reply, false).await?;
if compiled.decoded.project != envelope.model {
return Err(self.invalid_reply(
&reply,
false,
"The hosted source and model disagree. The document was not opened.",
));
}
Ok(Remote {
resource_id: id,
revision: envelope.revision,
etag,
compiled,
})
}
pub async fn publish(&self, bundle: SourceBundle) -> Result<Remote, Failure> {
let reply = self
.request(
Method::POST,
self.endpoint("projects/import-source-project"),
Some(bundle),
None,
)
.await?;
let receipt: Summary = self.decode(&reply, true)?;
if receipt.resource_id.is_nil() || receipt.model_id.is_nil() || receipt.revision == 0 {
return Err(self.invalid_reply(&reply, true, "The service returned an invalid publication receipt. Inspect the catalog before publishing again."));
}
let published = Some(receipt.resource_id);
let etag = self.etag(&reply, true).map_err(|mut failure| {
failure.published = published;
failure
})?;
let remote = self.open(receipt.resource_id).await.map_err(|mut failure| {
failure.uncertain = true;
failure.published = published;
failure.detail = format!("Publication was acknowledged, but its source could not be read. Open the published resource before trying another publication.\n{}", failure.detail);
failure
})?;
if remote.revision != receipt.revision
|| remote.etag != etag
|| remote.compiled.decoded.project.id != receipt.model_id
{
let mut failure = self.invalid_reply(&reply, true, "The published resource changed before its source was downloaded. Review the published resource before publishing again.");
failure.published = published;
return Err(failure);
}
Ok(remote)
}
pub async fn save(&self, id: Id, etag: &str, bundle: SourceBundle) -> Result<Remote, Failure> {
let reply = self
.request(
Method::PUT,
self.endpoint(&format!("projects/{id}/source-project")),
Some(bundle),
Some(etag),
)
.await?;
let accepted: AcceptedSource = self.decode(&reply, true)?;
let etag = self.etag(&reply, true)?;
if id.is_nil() || accepted.revision == 0 {
return Err(self.invalid_reply(
&reply,
true,
"The service returned an invalid saved revision.",
));
}
let compiled = self.compile(accepted.source_project, &reply, true).await?;
Ok(Remote {
resource_id: id,
revision: accepted.revision,
etag,
compiled,
})
}
async fn compile(
&self,
bundle: SourceBundle,
reply: &Reply,
mutation: bool,
) -> Result<Arc<CompiledSourceProject>, Failure> {
let result = tokio::task::spawn_blocking(move || {
let compiled = source_project::compile_bundle(&bundle, SOURCE_LIMITS)?;
Ok::<_, Vec<source_project::SourceDiagnostic>>((compiled, bundle))
})
.await
.map_err(|_| {
self.invalid_reply(
reply,
mutation,
"The hosted source could not be checked. Local work is unchanged.",
)
})?;
match result {
Ok((compiled, bundle)) if compiled.bundle == bundle && !compiled.decoded.project.id.is_nil() => Ok(Arc::new(compiled)),
Ok(_) => Err(self.invalid_reply(reply, mutation, "The service returned source without stable current-format identities. Local work is unchanged.")),
Err(issues) => {
let mut failure = self.invalid_reply(reply, mutation, "The hosted source could not be compiled. Local work is unchanged.");
for issue in issues.into_iter().take(10) {
failure.detail.push_str(&format!("\n{}:{}:{}: {}", issue.path, issue.diagnostic.line, issue.diagnostic.column, issue.diagnostic.message));
}
failure.detail = self.redact(failure.detail);
Err(failure)
}
}
}
}
fn valid_etag(value: &str) -> bool {
(2..=1_024).contains(&value.len())
&& value.starts_with('"')
&& value.ends_with('"')
&& value.as_bytes()[1..value.len() - 1]
.iter()
.all(|byte| matches!(*byte, 0x21 | 0x23..=0x7e))
}
#[cfg(test)]
#[path = "client_tests.rs"]
mod tests;
@@ -0,0 +1,609 @@
use super::*;
use axum::{
Json, Router,
http::StatusCode,
response::IntoResponse,
routing::{get, post, put},
};
use serde_json::json;
use std::{
collections::BTreeMap,
sync::atomic::{AtomicUsize, Ordering},
};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
sync::oneshot,
task::JoinHandle,
};
struct TestServer {
base: String,
stop: Option<oneshot::Sender<()>>,
task: JoinHandle<()>,
_directory: Option<tempfile::TempDir>,
}
impl TestServer {
async fn fixture(router: Router) -> Self {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let (stop, done) = oneshot::channel();
let task = tokio::spawn(async move {
axum::serve(listener, router)
.with_graceful_shutdown(async {
let _ = done.await;
})
.await
.unwrap();
});
Self {
base,
stop: Some(stop),
task,
_directory: None,
}
}
async fn production(token: Option<String>, prefix: bool) -> Self {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().to_owned();
let store = Arc::new(
tokio::task::spawn_blocking(move || oa_server::store::Store::open(path))
.await
.unwrap()
.unwrap(),
);
let router = oa_server::app(store, token);
let router = if prefix {
Router::new().nest("/teams/engineering", router)
} else {
router
};
let mut server = Self::fixture(router).await;
server._directory = Some(directory);
if prefix {
server.base.push_str("/teams/engineering");
}
server
}
fn connection(&self) -> Connection {
Connection::new(&self.base, Secret::default()).unwrap()
}
async fn finish(mut self) {
self.stop.take().unwrap().send(()).unwrap();
(&mut self.task).await.unwrap();
}
}
impl Drop for TestServer {
fn drop(&mut self) {
self.task.abort();
}
}
fn authored() -> SourceBundle {
let bundle = SourceBundle {
entry: "harbor.arcadia".into(),
files: BTreeMap::from([
("harbor.arcadia".into(), "// Harbor rationale 航海\r\narcadia 4; project harbor \"Harbor & rescue\" {\r\n include \"work/operations.arcadia\";\r\n}\r\n".into()),
("work/operations.arcadia".into(), "// Keep this authored organization\r\noa_activity assess \"Assess assistance\" {}\r\nsa_function assist \"Assist operator\" {}\r\n".into()),
]),
};
source_project::compile_bundle(&bundle, SOURCE_LIMITS)
.unwrap()
.bundle
}
fn envelope(bundle: SourceBundle, resource_id: Id, revision: u64) -> serde_json::Value {
let model = source_project::compile_bundle(&bundle, SOURCE_LIMITS)
.unwrap()
.decoded
.project;
json!({"resource_id":resource_id,"revision":revision,"model":model,"source_project":bundle})
}
#[test]
fn hosted_endpoint_validation_redacts_secrets_and_preserves_proxy_paths() {
for base in [
"http://example.com",
"http://192.168.1.4",
"ftp://localhost",
"not a url",
"https://user:secret@example.com",
"https://@example.com",
"https://user@example.com",
"https://example.com/?token=secret",
"https://example.com/#secret",
"https://example.com\\hidden",
"https://example.com/\nsecret",
] {
let failure = Connection::new(base, Secret("secret".into())).unwrap_err();
assert_eq!(failure.code, "invalid_endpoint");
assert!(!failure.detail.contains("secret"));
assert!(!failure.uncertain);
}
for base in [
"http://localhost:8787",
"http://127.0.0.2:8787",
"http://[::1]:8787",
"https://example.com/prefix",
] {
assert!(Connection::new(base, Secret::default()).is_ok(), "{base}");
}
let client = Connection::new(
"https://example.com/teams/space%20name",
Secret("private-token".into()),
)
.unwrap();
assert_eq!(client.base(), "https://example.com/teams/space%20name/");
assert_eq!(
client.endpoint("projects").as_str(),
"https://example.com/teams/space%20name/api/v1/projects"
);
assert!(!format!("{client:?}").contains("private-token"));
assert_eq!(
format!("{:?}", Secret("private-token".into())),
"Secret([redacted])"
);
assert_eq!(
Connection::new("http://localhost", Secret("bad\nheader".into()))
.unwrap_err()
.code,
"invalid_token"
);
for etag in [
"*",
"W/\"weak\"",
"unquoted",
"\"one\",\"two\"",
"\"new\nline\"",
] {
assert!(!valid_etag(etag), "{etag}");
}
assert!(valid_etag("\"opaque:tag/+,=?\""));
}
#[tokio::test]
async fn hosted_production_roundtrip_preserves_comments_noops_and_conflicts() {
let server = TestServer::production(None, false).await;
let client = server.connection();
assert!(client.catalog("", None).await.unwrap().items.is_empty());
let bundle = authored();
let original = client.publish(bundle.clone()).await.unwrap();
assert_ne!(original.resource_id, original.compiled.decoded.project.id);
assert_eq!(original.compiled.bundle, bundle);
assert_eq!(original.revision, 1);
let catalog = client.catalog("Harbor &", None).await.unwrap();
assert_eq!(catalog.items.len(), 1);
assert_eq!(catalog.items[0].resource_id, original.resource_id);
assert_eq!(
catalog.items[0].model_id,
original.compiled.decoded.project.id
);
assert_eq!(catalog.items[0].name, "Harbor & rescue");
assert_eq!(
(catalog.items[0].elements, catalog.items[0].relations),
(2, 0)
);
assert!(
client
.catalog("?q=missing&limit=1000", None)
.await
.unwrap()
.items
.is_empty()
);
assert!(
client
.catalog("", Some(original.resource_id))
.await
.unwrap()
.items
.is_empty()
);
let same = client
.save(original.resource_id, &original.etag, bundle.clone())
.await
.unwrap();
assert_eq!(same.etag, original.etag);
assert_eq!(same.revision, original.revision);
let mut changed = bundle.clone();
changed
.files
.get_mut("work/operations.arcadia")
.unwrap()
.push_str("// Reviewed by the team\r\n");
let saved = client
.save(original.resource_id, &original.etag, changed.clone())
.await
.unwrap();
assert_eq!(saved.resource_id, original.resource_id);
assert_eq!(saved.revision, 2);
assert_ne!(saved.etag, original.etag);
assert_eq!(saved.compiled.bundle, changed);
assert_eq!(
saved.compiled.decoded.project,
original.compiled.decoded.project
);
let conflict = client
.save(original.resource_id, &original.etag, bundle)
.await
.unwrap_err();
assert_eq!(conflict.status, Some(412));
assert_eq!(conflict.code, "revision_conflict");
assert!(!conflict.uncertain);
assert_eq!(
client
.open(original.resource_id)
.await
.unwrap()
.compiled
.bundle,
changed
);
server.finish().await;
}
#[tokio::test]
async fn hosted_production_auth_proxy_prefix_and_located_rejection() {
let token = "hosted-test-token-with-at-least-32-characters";
let server = TestServer::production(Some(token.into()), true).await;
let unauthorized = server.connection().catalog("", None).await.unwrap_err();
assert_eq!(
(unauthorized.status, unauthorized.code.as_str()),
(Some(401), "unauthorized")
);
let client = Connection::new(&server.base, Secret(token.into())).unwrap();
let original = client.publish(authored()).await.unwrap();
assert_eq!(
client.catalog("Harbor &", None).await.unwrap().items.len(),
1
);
let mut invalid = original.compiled.bundle.clone();
invalid
.files
.get_mut("work/operations.arcadia")
.unwrap()
.push_str("this is not valid source;");
let rejected = client
.save(original.resource_id, &original.etag, invalid)
.await
.unwrap_err();
assert_eq!(rejected.status, Some(422));
assert!(!rejected.uncertain);
assert!(rejected.detail.contains("work/operations.arcadia:"));
assert_eq!(
client.open(original.resource_id).await.unwrap().etag,
original.etag
);
let missing = client.open(Id::new_v4()).await.unwrap_err();
assert_eq!(missing.status, Some(404));
assert!(!missing.uncertain);
server.finish().await;
}
#[tokio::test]
async fn hosted_redirect_is_not_followed_or_retried_and_problem_tokens_are_redacted() {
let redirected = Arc::new(AtomicUsize::new(0));
let calls = Arc::clone(&redirected);
let destination = TestServer::fixture(Router::new().fallback(move || {
let calls = Arc::clone(&calls);
async move {
calls.fetch_add(1, Ordering::SeqCst);
StatusCode::OK
}
}))
.await;
let location = destination.base.clone();
let attempts = Arc::new(AtomicUsize::new(0));
let seen = Arc::clone(&attempts);
let server = TestServer::fixture(Router::new().route("/api/v1/projects/import-source-project", post(move || {
let location = location.clone();
let seen = Arc::clone(&seen);
async move { seen.fetch_add(1, Ordering::SeqCst); (StatusCode::TEMPORARY_REDIRECT, [(header::LOCATION, location)]) }
})).route("/api/v1/projects", get(|| async {
(StatusCode::FORBIDDEN, Json(json!({"code":"denied","detail":"Do not print private-test-token", "issues":[{"message":"private-test-token","path":"secret/private-test-token.arcadia","line":4,"column":2}]})))
}))).await;
let client = Connection::new(&server.base, Secret("private-test-token".into())).unwrap();
let failure = client.publish(authored()).await.unwrap_err();
assert_eq!(failure.code, "redirect_refused");
assert!(!failure.uncertain);
assert_eq!(attempts.load(Ordering::SeqCst), 1);
assert_eq!(redirected.load(Ordering::SeqCst), 0);
let failure = client.catalog("", None).await.unwrap_err();
assert!(failure.detail.contains("[redacted]"));
assert!(!format!("{failure:?}").contains("private-test-token"));
server.finish().await;
destination.finish().await;
}
#[tokio::test]
async fn hosted_open_rejects_incoherent_envelopes_missing_etags_and_unstable_ids() {
let id = Id::new_v4();
let stable = authored();
let mut mismatched = envelope(stable.clone(), id, 1);
mismatched["model"]["name"] = json!("different model");
let mut wrong_id = envelope(stable.clone(), Id::new_v4(), 1);
// Keep this independently useful even if the model's schema changes.
wrong_id["revision"] = json!(1);
let mut unstable = envelope(stable.clone(), id, 1);
unstable["source_project"] = json!({"entry":"model.arcadia","files":{"model.arcadia":"arcadia 4; project unstable \"Unstable\" {}"}});
for (body, etag) in [
(mismatched, Some("\"opaque\"")),
(wrong_id, Some("\"opaque\"")),
(envelope(stable.clone(), id, 0), Some("\"opaque\"")),
(envelope(stable, id, 1), None),
(unstable, Some("\"opaque\"")),
] {
let server = TestServer::fixture(Router::new().route(
"/api/v1/projects/{id}",
get(move || {
let body = body.clone();
async move {
let mut response = Json(body).into_response();
if let Some(etag) = etag {
response
.headers_mut()
.insert(header::ETAG, etag.parse().unwrap());
}
response
}
}),
))
.await;
let failure = server.connection().open(id).await.unwrap_err();
assert_eq!(failure.code, "invalid_response");
assert!(!failure.uncertain);
server.finish().await;
}
}
#[tokio::test]
async fn hosted_put_preserves_an_opaque_etag_and_compiles_the_accepted_bundle() {
let id = Id::new_v4();
let etag = "\"opaque:tag/+,=?\"";
let bundle = authored();
let response_bundle = bundle.clone();
let server = TestServer::fixture(Router::new().route(
"/api/v1/projects/{id}/source-project",
put(
move |headers: header::HeaderMap, Json(body): Json<SourceBundle>| {
let expected_bundle = response_bundle.clone();
async move {
assert_eq!(headers.get(header::IF_MATCH).unwrap(), etag);
assert_eq!(body, expected_bundle);
(
[(header::ETAG, "\"accepted:new-tag\"")],
Json(json!({"revision":8,"changed":false,"source_project":body})),
)
}
},
),
))
.await;
let client = server.connection();
let saved = client.save(id, etag, bundle.clone()).await.unwrap();
assert_eq!(saved.etag, "\"accepted:new-tag\"");
assert_eq!(saved.resource_id, id);
assert_eq!(saved.revision, 8);
assert_eq!(saved.compiled.bundle, bundle.clone());
assert_eq!(
client.save(id, "*", bundle).await.unwrap_err().code,
"invalid_etag"
);
server.finish().await;
}
#[tokio::test]
async fn hosted_publication_retains_known_resource_after_failed_followup() {
let id = Id::new_v4();
let attempts = Arc::new(AtomicUsize::new(0));
let calls = Arc::clone(&attempts);
let server = TestServer::fixture(Router::new().route("/api/v1/projects/import-source-project", post(move || {
let calls = Arc::clone(&calls);
async move {
calls.fetch_add(1, Ordering::SeqCst);
(StatusCode::CREATED, [(header::ETAG, "\"receipt\"")], Json(json!({"resource_id":id,"revision":1,"model_id":Id::new_v4(),"name":"Published","elements":0,"relations":0})))
}
})).route("/api/v1/projects/{id}", get(|| async { StatusCode::SERVICE_UNAVAILABLE }))).await;
let failure = server.connection().publish(authored()).await.unwrap_err();
assert_eq!(failure.published, Some(id));
assert!(failure.uncertain);
assert_eq!(failure.status, Some(503));
assert_eq!(attempts.load(Ordering::SeqCst), 1);
server.finish().await;
}
/// Reads the entire request before simulating a lost/malformed response. The
/// counter proves the production client never repeats an admitted publication.
async fn raw_fixture(reply: Vec<u8>) -> (String, Arc<AtomicUsize>, JoinHandle<()>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let attempts = Arc::new(AtomicUsize::new(0));
let calls = Arc::clone(&attempts);
let task = tokio::spawn(async move {
loop {
let (mut stream, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
let mut buffer = [0; 4096];
loop {
let read = stream.read(&mut buffer).await.unwrap();
if read == 0 {
break;
}
request.extend_from_slice(&buffer[..read]);
if let Some(end) = request.windows(4).position(|part| part == b"\r\n\r\n") {
let headers = String::from_utf8_lossy(&request[..end]).to_ascii_lowercase();
let length = headers
.lines()
.find_map(|line| {
line.strip_prefix("content-length:")
.and_then(|value| value.trim().parse::<usize>().ok())
})
.unwrap_or(0);
if request.len() >= end + 4 + length {
break;
}
}
}
calls.fetch_add(1, Ordering::SeqCst);
let _ = stream.write_all(&reply).await;
let _ = stream.shutdown().await;
}
});
(base, attempts, task)
}
#[tokio::test]
async fn hosted_lost_publication_response_is_uncertain_without_retry() {
let (base, attempts, task) = raw_fixture(Vec::new()).await;
let client = Connection::new(&base, Secret::default()).unwrap();
let failure = client.publish(authored()).await.unwrap_err();
assert!(failure.uncertain);
assert_eq!(failure.published, None);
assert_eq!(attempts.load(Ordering::SeqCst), 1);
task.abort();
}
#[tokio::test]
async fn hosted_response_limits_cover_declared_and_chunked_bodies() {
let declared = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
MAX_RESPONSE_BYTES + 1
)
.into_bytes();
let mut chunked =
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n".to_vec();
let chunk = vec![b' '; 64 * 1024];
for _ in 0..=(MAX_RESPONSE_BYTES / chunk.len()) {
chunked.extend_from_slice(format!("{:x}\r\n", chunk.len()).as_bytes());
chunked.extend_from_slice(&chunk);
chunked.extend_from_slice(b"\r\n");
}
chunked.extend_from_slice(b"0\r\n\r\n");
for reply in [declared, chunked] {
let (base, _, task) = raw_fixture(reply).await;
let failure = Connection::new(&base, Secret::default())
.unwrap()
.catalog("", None)
.await
.unwrap_err();
assert_eq!(failure.code, "response_too_large");
assert!(!failure.uncertain);
task.abort();
}
}
#[tokio::test]
async fn hosted_timeout_and_disconnect_leave_mutation_outcomes_explicit() {
let received = Arc::new(AtomicUsize::new(0));
let calls = Arc::clone(&received);
let server = TestServer::fixture(Router::new().route(
"/api/v1/projects/import-source-project",
post(move || {
let calls = Arc::clone(&calls);
async move {
calls.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(100)).await;
StatusCode::CREATED
}
}),
))
.await;
let client =
Connection::with_timeout(&server.base, Secret::default(), Duration::from_millis(30))
.unwrap();
let failure = client.publish(authored()).await.unwrap_err();
assert_eq!(failure.code, "timeout");
assert!(failure.uncertain);
assert_eq!(received.load(Ordering::SeqCst), 1);
let base = server.base.clone();
server.finish().await;
let failure = Connection::with_timeout(&base, Secret::default(), Duration::from_secs(2))
.unwrap()
.catalog("", None)
.await
.unwrap_err();
assert!(matches!(
failure.code.as_str(),
"connection_failed" | "timeout"
));
assert!(!failure.uncertain);
}
#[tokio::test]
async fn hosted_publication_race_keeps_receipt_identity_for_review() {
let id = Id::new_v4();
let bundle = authored();
let published_model_id = source_project::compile_bundle(&bundle, SOURCE_LIMITS)
.unwrap()
.decoded
.project
.id;
let mut changed = bundle.clone();
changed
.files
.get_mut("work/operations.arcadia")
.unwrap()
.push_str("// Concurrent external review\r\n");
let head = envelope(changed, id, 2);
let server = TestServer::fixture(Router::new().route("/api/v1/projects/import-source-project", post(move || async move {
(StatusCode::CREATED, [(header::ETAG, "\"first-revision\"")], Json(json!({"resource_id":id,"revision":1,"model_id":published_model_id,"name":"Published","elements":2,"relations":0})))
})).route("/api/v1/projects/{id}", get(move || {
let head = head.clone();
async move { ([(header::ETAG, "\"next-revision\"")], Json(head)) }
}))).await;
let failure = server.connection().publish(bundle).await.unwrap_err();
assert!(failure.uncertain);
assert_eq!(failure.published, Some(id));
assert!(failure.detail.contains("changed before"));
server.finish().await;
}
#[tokio::test]
async fn hosted_structured_mutation_failures_distinguish_precommit_rejections() {
for (status, code, uncertain) in [
(StatusCode::PAYLOAD_TOO_LARGE, "request_too_large", false),
(StatusCode::INSUFFICIENT_STORAGE, "store_capacity", false),
(StatusCode::SERVICE_UNAVAILABLE, "overloaded", false),
(StatusCode::SERVICE_UNAVAILABLE, "storage_unavailable", true),
(StatusCode::GATEWAY_TIMEOUT, "outcome_unknown", true),
] {
let server = TestServer::fixture(Router::new().route(
"/api/v1/projects/import-source-project",
post(move || async move {
(
status,
Json(json!({"code":code,"detail":"Service-specific actionable detail"})),
)
}),
))
.await;
let failure = server.connection().publish(authored()).await.unwrap_err();
assert_eq!(failure.status, Some(status.as_u16()));
assert_eq!(failure.code, code);
assert_eq!(failure.uncertain, uncertain, "{code}");
assert_eq!(failure.detail, "Service-specific actionable detail");
server.finish().await;
}
}
#[tokio::test]
async fn hosted_oversized_local_bundle_is_rejected_before_network_admission() {
let mut bundle = authored();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.push_str(&" ".repeat(8 * 1024 * 1024));
let failure = Connection::new("http://127.0.0.1:1", Secret::default())
.unwrap()
.publish(bundle)
.await
.unwrap_err();
assert_eq!(failure.code, "request_too_large");
assert!(!failure.uncertain);
assert!(failure.published.is_none());
}
+148
View File
@@ -0,0 +1,148 @@
//! Optional hosted transport state. The Document remains the local editing history.
pub mod client;
pub mod view;
use client::{Catalog, Connection, Failure, Remote, Secret, Summary};
use oa_core::{Id, source_project::CompiledSourceProject};
use std::sync::Arc;
#[derive(Debug, Clone)]
pub enum Event {
Show,
Close,
Base(String),
Token(Secret),
Query(String),
Connect,
More,
Select(Id),
OpenSelected,
Publish,
Save,
Check,
Reload,
ReviewFile(String),
ReviewAction(iced::widget::text_editor::Action),
ReviewBack,
Local,
Forget,
Completed(Id, Id, Result<Outcome, Failure>),
}
#[derive(Debug, Clone)]
pub enum Outcome {
Catalog(Catalog),
Remote(Remote),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Operation {
Catalog(bool),
Open(Id),
Publish,
Save,
Check,
}
#[derive(Debug, Clone)]
pub struct Session {
pub connection: Connection,
pub resource_id: Id,
pub revision: u64,
pub etag: String,
}
#[derive(Debug, Clone)]
pub struct Request {
pub id: Id,
pub document: Id,
pub revision: u64,
pub operation: Operation,
pub connection: Connection,
pub resource_id: Option<Id>,
pub etag: Option<String>,
pub snapshot: Arc<CompiledSourceProject>,
}
#[derive(Debug, Clone)]
pub struct Review {
pub connection: Connection,
pub remote: Remote,
}
pub struct State {
pub visible: bool,
pub base: String,
pub token: Secret,
pub query: String,
pub catalog: Vec<Summary>,
pub catalog_stale: bool,
pub next: Option<Id>,
pub selected: Option<Id>,
pub connection: Option<Connection>,
pub session: Option<Session>,
pub request: Option<Request>,
pub uncertain: Option<Request>,
pub review: Option<Review>,
pub review_path: Option<String>,
pub review_editor: iced::widget::text_editor::Content,
pub error: Option<Failure>,
pub open_target: Option<(Connection, Id)>,
}
impl Default for State {
fn default() -> Self {
Self {
visible: false,
base: "http://127.0.0.1:8787".into(),
token: Secret::default(),
query: String::new(),
catalog: vec![],
catalog_stale: true,
next: None,
selected: None,
connection: None,
session: None,
request: None,
uncertain: None,
review: None,
review_path: None,
review_editor: iced::widget::text_editor::Content::new(),
error: None,
open_target: None,
}
}
}
impl State {
pub fn invalidate_catalog(&mut self) {
self.catalog_stale = true;
self.next = None;
}
pub fn catalog_message(&self) -> Option<&'static str> {
if self.connection.is_none() {
Some("Connect / refresh to load models for this service.")
} else if self.catalog_stale {
Some("Catalog may be out of date. Connect / refresh to update these results.")
} else if self.catalog.is_empty() {
Some("No models found. Publish the current model or change the search.")
} else {
None
}
}
/// Keep explicit connection settings in memory, but never carry a resource
/// binding or in-flight result into another local document.
pub fn leave_document(&mut self) {
self.visible = false;
self.session = None;
self.request = None;
self.uncertain = None;
self.review = None;
self.review_path = None;
self.review_editor = iced::widget::text_editor::Content::new();
self.error = None;
self.open_target = None;
}
}
+238
View File
@@ -0,0 +1,238 @@
//! Hosted dialog. Main-header integration is a small caller-owned hook.
use super::{Event, Operation, client::Secret};
use crate::{
app::{App, Message},
style,
views::{action, label},
};
use iced::{
Element, Fill, Font,
widget::{self, button, column, container, row, scrollable, text, text_editor, text_input},
};
use std::collections::BTreeSet;
pub fn overlay<'a>(app: &'a App, base: Element<'a, Message>) -> Element<'a, Message> {
if !app.hosted.visible
|| app.pending.is_some()
|| app.file_prompt.is_some()
|| app.workflow.is_some()
|| app.delete_pending
{
return base;
}
widget::stack![
base,
widget::opaque(
container(
container(dialog(app))
.padding(22)
.width((app.window_size.width - 64.).clamp(680., 1000.))
.height((app.window_size.height - 80.).max(400.))
.style(style::card)
)
.center(Fill)
.style(|_| container::Style {
background: Some(iced::Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()),
..Default::default()
})
)
]
.into()
}
fn control<'a>(title: &'a str, event: Event, enabled: bool) -> widget::Button<'a, Message> {
button(text(title).size(12))
.padding([9, 12])
.style(style::nav)
.on_press_maybe(enabled.then_some(Message::Hosted(event)))
}
fn dialog(app: &App) -> Element<'_, Message> {
let state = &app.hosted;
let enabled = !app.busy;
if let Some(path) = &state.review_path {
return column![
text("Server source - read only").size(22),
text(path).size(13),
text_editor(&state.review_editor)
.font(Font::MONOSPACE)
.size(13)
.height(Fill)
.on_action(|action| Message::Hosted(Event::ReviewAction(action))),
control("Back to review", Event::ReviewBack, enabled),
]
.spacing(12)
.into();
}
let mut content = column![
label("SERVICE ADDRESS"),
text_input("http://127.0.0.1:8787", &state.base)
.padding(10)
.on_input_maybe(enabled.then_some(|value| Message::Hosted(Event::Base(value)))),
label("BEARER TOKEN - KEPT IN MEMORY ONLY"),
text_input("Optional for a loopback service", &state.token.0)
.secure(true)
.padding(10)
.on_input_maybe(
enabled.then_some(|value| Message::Hosted(Event::Token(Secret(value))))
),
row![
control("Connect / refresh", Event::Connect, enabled),
control(
"Forget connection",
Event::Forget,
enabled && state.session.is_none()
),
]
.spacing(8),
]
.spacing(8);
if let Some(session) = &state.session {
content = content.push(container(column![
text("Current hosted model").size(16),
text(session.connection.base()).size(12),
text(format!("Resource {} - revision {}", session.resource_id, session.revision)).size(12),
label("Edits and Undo stay local. Save sends the complete authored source project."),
row![
control("Check server", Event::Check, enabled),
control("Save as local...", Event::Local, enabled),
].spacing(8),
].spacing(8)).padding(12).style(style::panel));
}
if let Some(error) = &state.error {
content = content.push(
container(
column![
text(if error.uncertain {
"The write outcome is unknown"
} else {
"Hosted action needs attention"
})
.size(16),
text(&error.detail).size(13),
label("Your local model and source history are retained."),
]
.spacing(7),
)
.padding(12)
.style(style::panel),
);
}
if let Some(failed) = &state.uncertain {
if failed.operation == Operation::Publish {
content = content.push(text("A previous publication may already exist. Inspect the catalog before publishing another copy.").size(13));
}
if failed.resource_id.is_some() {
content = content.push(control("Check previous write", Event::Check, enabled));
}
}
if let Some(review) = &state.review {
let remote = &review.remote;
let local = &app.document.bundle().files;
let server = &remote.compiled.bundle.files;
let paths: BTreeSet<_> = local.keys().chain(server.keys()).collect();
let mut changes = column![
text(format!("Review server revision {}", remote.revision)).size(17),
label(format!("{} - resource {}", review.connection.base(), remote.resource_id)),
label("Reload replaces accepted local content as one undoable change. Unapplied drafts are checked first."),
].spacing(8);
if app.document.bundle().entry != remote.compiled.bundle.entry {
changes = changes.push(
text(format!(
"Entry: {} -> {}",
app.document.bundle().entry,
remote.compiled.bundle.entry
))
.size(12),
);
}
for path in paths {
if local.get(path) != server.get(path) {
let change = match (local.contains_key(path), server.contains_key(path)) {
(false, true) => "Added on server",
(true, false) => "Removed on server",
_ => "Changed",
};
changes = changes.push(
row![
text(format!("{change}: {path}")).size(12).width(Fill),
control(
"View server text",
Event::ReviewFile(path.clone()),
enabled && server.contains_key(path)
),
]
.spacing(8)
.align_y(iced::Center),
);
}
}
changes = changes.push(control("Reload server version", Event::Reload, enabled));
content = content.push(container(changes).padding(12).style(style::panel));
}
content = content.push(text("Hosted model catalog").size(17)).push(
row![
text_input("Search model names", &state.query)
.padding(9)
.on_input_maybe(enabled.then_some(|value| Message::Hosted(Event::Query(value))))
.on_submit(Message::Hosted(Event::Connect)),
control("Search", Event::Connect, enabled),
]
.spacing(8),
);
if let Some(message) = state.catalog_message() {
content = content.push(label(message));
}
for item in &state.catalog {
let selected = state.selected == Some(item.resource_id);
content = content.push(
button(
column![
text(&item.name).size(14),
label(format!(
"{} - revision {} - {} objects / {} links",
item.resource_id, item.revision, item.elements, item.relations
)),
]
.spacing(4),
)
.padding(10)
.width(Fill)
.style(if selected {
style::selected
} else {
style::nav
})
.on_press_maybe(enabled.then_some(Message::Hosted(Event::Select(item.resource_id)))),
);
}
if !state.catalog_stale && state.next.is_some() {
content = content.push(control("More models", Event::More, enabled));
}
let publish_label = if state
.uncertain
.as_ref()
.is_some_and(|r| r.operation == Operation::Publish)
{
"Publish another copy"
} else {
"Publish as new"
};
column![
text("Hosted models").size(24),
label("Optional shared storage for desktop, scripts and engineering integrations."),
scrollable(content.spacing(12)).height(Fill),
text(if app.busy { app.status.as_str() } else { "Publishing creates a separate hosted resource. Existing resources change only through Save to server." }).size(12),
row![
control("Close", Event::Close, enabled),
control(publish_label, Event::Publish, enabled),
control("Open selected", Event::OpenSelected, enabled && state.selected.is_some() && state.connection.is_some()),
control("Save to server", Event::Save, enabled && state.session.is_some() && state.review.is_none()),
].spacing(8),
].spacing(12).into()
}
// Kept here so a caller can integrate the feature without duplicating event wiring.
pub fn header_action() -> widget::Button<'static, Message> {
action("Hosted models...", Message::Hosted(Event::Show))
}
+2
View File
@@ -13,12 +13,14 @@ mod explorer_view;
mod forms;
mod guide;
mod highlight;
mod hosted;
mod model_menu;
mod navigation;
mod port_edit;
mod port_preview;
mod port_workflow;
mod route_edit;
mod semantic_review;
mod source_navigation;
mod source_tree;
mod source_workspace;
+6 -1
View File
@@ -415,10 +415,15 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
if p.layer_of(id).is_some() {
entries.push(item("Create relationship…", Message::AddRelation, true));
entries.push(item(
"Create / update counterpart…",
"Create counterpart / review prose…",
Message::StartTransition,
true,
));
entries.push(item(
"Review semantics…",
Message::StartSemanticReview(id),
p.traces.values().any(|t| t.oa == id || t.sa == id),
));
}
entries.push(item(
"Attach artifact reference…",
+571
View File
@@ -0,0 +1,571 @@
//! Deliberate semantic review of one existing trace; drafts never mutate the model.
use crate::{
app::{Choice, Message},
forms::Draft,
};
use iced::widget::text_editor;
use oa_core::{transitions::*, *};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
mod scalar;
mod view;
pub(crate) use view::summary;
#[cfg(test)]
mod tests;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tab {
Facts,
References,
Impacts,
}
#[derive(Debug, Clone)]
pub enum Event {
Trace(Choice),
Direction(TransitionDirection),
Select(FacetSelector),
Decide(FacetSelector, Option<SemanticDecision>),
Input(String, String),
Text(String, text_editor::Action),
Shape(String, Value),
Variant(String, Value),
Array(String, Option<usize>, i32),
Append(String, Value),
ValuePath(String, crate::data_values::FieldPathKey),
Tab(Tab),
ShowUnchanged(bool),
ReferenceOa(Choice),
ReferenceSa(Choice),
ReferenceSearch(String),
AddReference,
RemoveReference(Id),
AcceptImpact(usize, bool),
Preview,
}
pub struct State {
pub anchor: Id,
pub trace: Option<Id>,
pub direction: TransitionDirection,
pub facts: Vec<FacetDrift>,
pub selected: Option<FacetSelector>,
pub decisions: BTreeMap<FacetSelector, SemanticDecision>,
pub references: BTreeMap<Id, Id>,
pub draft: Option<Draft>,
pub plan: Option<SemanticTransitionPlan>,
pub proposed: Option<Project>,
pub error: Option<String>,
pub tab: Tab,
pub show_unchanged: bool,
pub reference_oa: Option<Choice>,
pub reference_sa: Option<Choice>,
pub reference_search: String,
accepted_impacts: Vec<SemanticImpact>,
}
impl State {
pub fn new(project: &Project, selected: Id) -> Result<Self, String> {
let explicit = project.traces.get(&selected);
let anchor = explicit.map_or(selected, |trace| trace.oa);
let layer = project
.layer_of(anchor)
.ok_or("Select an OA or SA object, relationship or trace to review.")?;
let choices: Vec<_> = project
.traces
.values()
.filter(|t| t.oa == anchor || t.sa == anchor)
.collect();
if choices.is_empty() {
return Err("This object has no trace. Create a counterpart or link existing counterparts first.".into());
}
let trace = explicit
.map(|t| t.id)
.or_else(|| (choices.len() == 1).then(|| choices[0].id));
let mut state = Self {
anchor,
trace,
direction: if layer == Layer::Oa {
TransitionDirection::OaToSa
} else {
TransitionDirection::SaToOa
},
facts: vec![],
selected: None,
decisions: BTreeMap::new(),
references: BTreeMap::new(),
draft: None,
plan: None,
proposed: None,
error: None,
tab: Tab::Facts,
show_unchanged: false,
reference_oa: None,
reference_sa: None,
reference_search: String::new(),
accepted_impacts: vec![],
};
state.load_trace(project);
Ok(state)
}
pub fn target(&self, project: &Project) -> Option<Id> {
let trace = project.traces.get(&self.trace?)?;
Some(if self.direction == TransitionDirection::OaToSa {
trace.sa
} else {
trace.oa
})
}
fn load_trace(&mut self, project: &Project) {
self.facts.clear();
self.decisions.clear();
self.selected = None;
self.accepted_impacts.clear();
self.references.clear();
self.draft = self
.target(project)
.and_then(|id| Draft::from_project(project, id));
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
self.references.insert(trace.oa, trace.sa);
}
self.refresh(project);
}
pub fn update(&mut self, project: &Project, event: Event) {
let mut replan = true;
let result = match event {
Event::Trace(choice) => {
if !project
.traces
.get(&choice.id)
.is_some_and(|t| t.oa == self.anchor || t.sa == self.anchor)
{
Err("Choose one of this object's existing traces.".into())
} else {
self.trace = Some(choice.id);
self.load_trace(project);
return;
}
}
Event::Direction(direction) => {
self.direction = direction;
self.load_trace(project);
return;
}
Event::Select(facet) => {
self.selected = Some(facet);
replan = false;
Ok(())
}
Event::Decide(facet, decision) => {
if let Some(decision) = decision {
if !self.editable(project, &facet)
&& !(decision == SemanticDecision::KeepTarget
&& self.can_acknowledge_constructor(project, &facet))
{
Err("This fact needs the structured review workflow; its history remains unchanged.".into())
} else {
self.decisions.insert(facet, decision);
Ok(())
}
} else {
self.decisions.remove(&facet);
Ok(())
}
}
Event::Input(path, value) => self.edit(project, &path, |d| {
d.input(&path, value);
Ok(())
}),
Event::Text(path, action) => self.edit(project, &path, |d| {
d.edit_text(&path, action);
Ok(())
}),
Event::Shape(path, value) => {
self.edit(project, &path, |d| d.shape_scoped(&path, value))
}
Event::Variant(path, value) => self.edit(project, &path, |d| {
if d.value
.pointer(&path)
.is_some_and(|v| crate::forms::same_variant(v, &value))
{
Ok(())
} else {
d.shape_scoped(&path, value)
}
}),
Event::Array(path, index, direction) => {
self.edit(project, &path, |d| d.array_scoped(&path, index, direction))
}
Event::Append(path, value) => {
self.edit(project, &path, |d| d.append_scoped(&path, value))
}
Event::ValuePath(path, key) => {
self.edit(project, &path, |d| d.value_path_scoped(&path, key))
}
Event::Tab(tab) => {
self.tab = tab;
replan = false;
Ok(())
}
Event::ShowUnchanged(show) => {
self.show_unchanged = show;
replan = false;
Ok(())
}
Event::ReferenceOa(choice) => {
self.reference_oa = Some(choice);
replan = false;
Ok(())
}
Event::ReferenceSa(choice) => {
self.reference_sa = Some(choice);
replan = false;
Ok(())
}
Event::ReferenceSearch(value) => {
self.reference_search = value;
replan = false;
Ok(())
}
Event::AddReference => match (&self.reference_oa, &self.reference_sa) {
(Some(oa), Some(sa)) => {
self.references.insert(oa.id, sa.id);
Ok(())
}
_ => Err("Choose both an Operational and a System reference.".into()),
},
Event::RemoveReference(id) => {
self.references.remove(&id);
Ok(())
}
Event::AcceptImpact(index, accepted) => {
if let Some(impact) = self
.plan
.as_ref()
.and_then(|p| p.impacts.get(index))
.cloned()
{
self.accepted_impacts.retain(|i| i.object != impact.object);
if accepted {
self.accepted_impacts.push(impact);
}
}
self.refresh(project);
return;
}
Event::Preview => Ok(()),
};
if let Err(error) = result {
self.error = Some(error);
self.plan = None;
self.proposed = None;
return;
}
if replan {
self.accepted_impacts.clear();
self.refresh(project);
}
}
fn edit(
&mut self,
project: &Project,
path: &str,
action: impl FnOnce(&mut Draft) -> Result<(), String>,
) -> Result<(), String> {
let facet = self.selected.as_ref().ok_or("Choose a property first.")?;
if self.decisions.get(facet) != Some(&SemanticDecision::Candidate) {
return Err("Choose Set final value before editing this property.".into());
}
let root = self
.target(project)
.and_then(|id| scalar::binding(project, id, facet))
.ok_or("Selected scalar property is unavailable.")?;
if path != root && !path.starts_with(&format!("{root}/")) {
return Err("Only the selected scalar property can change in this draft.".into());
}
let draft = self.draft.as_mut().ok_or("Choose an existing target.")?;
// The scoped view sends only descendants of the selected scalar property.
// Protected identity and top-level body/perspective controls are never exposed.
if path == "/id" || path == "/alias" || path == "/body" || path == "/layer" {
return Err("Semantic candidates retain identity, alias and perspective.".into());
}
action(draft)
}
pub fn editable(&self, project: &Project, facet: &FacetSelector) -> bool {
self.target(project)
.and_then(|id| scalar::binding(project, id, facet))
.is_some()
&& !scalar::grouped(facet)
}
pub fn can_acknowledge_constructor(&self, project: &Project, facet: &FacetSelector) -> bool {
if !scalar::grouped(facet) {
return false;
}
let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) else {
return false;
};
if trace
.baseline
.as_ref()
.and_then(|b| b.semantic.as_ref())
.is_some_and(|b| b.facets.iter().any(|f| f.selector() == *facet))
{
return false;
}
self.facts
.iter()
.find(|f| f.selector == *facet)
.is_some_and(|f| {
scalar::value(&f.current, Layer::Oa).is_some()
&& scalar::observation(&f.current, Layer::Oa)
== scalar::observation(&f.current, Layer::Sa)
})
}
fn request(&self, project: &Project) -> Result<SemanticTransitionRequest, String> {
let trace = self
.trace
.and_then(|id| project.traces.get(&id))
.ok_or("Choose the trace whose review history you want to update.")?;
let (source, target) = if self.direction == TransitionDirection::OaToSa {
(trace.oa, trace.sa)
} else {
(trace.sa, trace.oa)
};
let mut transition = TransitionRequest::new(self.direction, vec![source]);
transition.mappings.push(TransitionMapping {
source,
target: Some(target),
target_kind: None,
});
let mut request = SemanticTransitionRequest::new(transition);
request.traces.insert(source, trace.id);
request.references = self.references.clone();
request.reviews.push(SemanticReviewRequest {
source,
resolutions: self
.decisions
.iter()
.map(|(facet, decision)| FacetResolution {
facet: facet.clone(),
decision: *decision,
})
.collect(),
reset: vec![],
});
request.accepted_impacts = self.accepted_impacts.clone();
if self
.decisions
.values()
.any(|d| *d == SemanticDecision::Candidate)
{
let final_value = self.combined_value(project, false)?;
let candidate = if project.elements.contains_key(&target) {
SemanticRecord::Element(
serde_json::from_value(final_value)
.map_err(|e| format!("Final target value: {e}"))?,
)
} else {
SemanticRecord::Relation(
serde_json::from_value(final_value)
.map_err(|e| format!("Final target value: {e}"))?,
)
};
request.candidates.push(candidate);
}
Ok(request)
}
/// Compose selected scalar values for both the candidate and dependent form
/// controls. Incomplete inputs retain their stored shape only for controls;
/// request construction always requires every selected input to parse.
fn combined_value(&self, project: &Project, form_context: bool) -> Result<Value, String> {
let trace = self
.trace
.and_then(|id| project.traces.get(&id))
.ok_or("Choose a trace.")?;
let (source, target) = if self.direction == TransitionDirection::OaToSa {
(trace.oa, trace.sa)
} else {
(trace.sa, trace.oa)
};
let mut final_value = Draft::from_project(project, target)
.ok_or("Target has no editable record.")?
.value;
let draft = self.draft.as_ref().ok_or("Target draft is unavailable.")?;
let mut map = BTreeMap::new();
for (oa, sa) in &self.references {
let (from, to) = if self.direction == TransitionDirection::OaToSa {
(*oa, *sa)
} else {
(*sa, *oa)
};
if map.insert(from, to).is_some_and(|old| old != to) {
return Err("Reverse reference correspondence is ambiguous.".into());
}
}
map.insert(source, target);
for (facet, decision) in &self.decisions {
if *decision == SemanticDecision::KeepTarget {
continue;
}
let path = scalar::binding(project, target, facet)
.ok_or("Selected property is not applicable to the target.")?;
let value = match decision {
SemanticDecision::KeepTarget => continue,
SemanticDecision::Candidate => {
let value = match draft.materialize_scopes(std::slice::from_ref(&path)) {
Ok(value) => value,
Err(_) if form_context => draft.value.clone(),
Err(error) => return Err(error),
};
value
.pointer(&path)
.cloned()
.ok_or("Candidate property is no longer available.")?
}
SemanticDecision::TakeSource => {
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
scalar::source_value(&fact, self.direction.source_layer(), &map)?
}
};
*final_value
.pointer_mut(&path)
.ok_or("Final target property is unavailable.")? = value;
}
Ok(final_value)
}
pub fn refresh(&mut self, project: &Project) {
self.plan = None;
self.proposed = None;
self.error = None;
let context = self.combined_value(project, true).ok();
if let Some(draft) = &mut self.draft {
draft.set_context(context);
}
let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) else {
self.error = Some(
"Several traces are available. Choose the correspondence and review history above."
.into(),
);
return;
};
match semantic_facets(project, trace, &self.references) {
Ok(facts) => self.facts = facts,
Err(error) => {
self.error = Some(error);
return;
}
}
if self
.selected
.as_ref()
.is_none_or(|s| !self.facts.iter().any(|f| &f.selector == s))
{
self.selected = self
.facts
.iter()
.find(|f| f.drift != Drift::Unchanged)
.or(self.facts.first())
.map(|f| f.selector.clone());
}
let request = match self.request(project) {
Ok(request) => request,
Err(error) => {
self.error = Some(error);
return;
}
};
match plan_semantic_transition(project, request) {
Ok(plan) => {
if plan.conflicts.is_empty() {
match prepare_semantic_transition(project, &plan) {
Ok(prepared) => self.proposed = Some(prepared.project),
Err(error) => self.error = Some(error.to_string()),
}
}
self.plan = Some(plan);
}
Err(error) => self.error = Some(error.to_string()),
}
}
pub fn command(&mut self, project: &Project) -> Result<Command, String> {
if self.decisions.is_empty() {
return Err("Choose at least one property to review.".into());
}
if let Some(error) = &self.error {
return Err(error.clone());
}
let plan = self
.plan
.as_ref()
.ok_or("Refresh the review preview before applying.")?;
prepare_semantic_transition(project, plan).map_err(|e| e.to_string())?;
Ok(Command::Batch(plan.commands.clone()))
}
pub fn can_apply(&self) -> bool {
!self.decisions.is_empty()
&& self.error.is_none()
&& self
.plan
.as_ref()
.is_some_and(|p| p.conflicts.is_empty() && p.prepared_fingerprint.is_some())
}
pub fn all_references(&self, project: &Project, layer: Layer) -> Vec<Choice> {
let historical: BTreeSet<_> = self
.facts
.iter()
.flat_map(|f| scalar::references(&f.current))
.chain(
self.trace
.and_then(|id| project.traces.get(&id))
.and_then(|t| t.baseline.as_ref())
.and_then(|b| b.semantic.as_ref())
.into_iter()
.flat_map(|b| b.facets.iter().flat_map(scalar::references)),
)
.collect();
let mut ids: BTreeSet<_> = project
.elements
.keys()
.chain(project.relations.keys())
.chain(project.artifact_links.keys())
.filter(|id| project.layer_of(**id) == Some(layer))
.copied()
.collect();
ids.extend(historical.into_iter().filter(|id| !project.contains(*id)));
let search = self.reference_search.to_lowercase();
let mut choices: Vec<_> = ids
.into_iter()
.map(|id| view::choice(project, id))
.filter(|c| search.is_empty() || c.label.to_lowercase().contains(&search))
.collect();
choices.sort_by(|a, b| a.label.cmp(&b.label));
choices
}
}
pub fn form_event(message: Message) -> Message {
let event = match message {
Message::Field(path, value) => Event::Input(path, value),
Message::FieldText(path, action) => Event::Text(path, action),
Message::FieldShape(path, value) => Event::Shape(path, value),
Message::FieldVariant(path, value) => Event::Variant(path, value),
Message::FieldArray(path, index, direction) => Event::Array(path, index, direction),
Message::FieldAppend(path, value) => Event::Append(path, value),
Message::FieldValuePath(path, key) => Event::ValuePath(path, key),
_ => Event::Preview,
};
Message::Workflow(crate::workflow::Event::Semantic(event))
}
@@ -0,0 +1,288 @@
//! Fixed typed scalar bindings for property forms. Core validates every result.
use oa_core::*;
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
struct Own {
whole: Value,
prefix: Vec<String>,
kind: String,
}
fn enum_tag(value: &Value) -> Option<&str> {
value.as_str().or_else(|| {
value
.as_object()
.and_then(|v| v.keys().next().map(String::as_str))
})
}
impl Own {
fn get(project: &Project, id: Id) -> Option<Self> {
let (whole, element) = if let Some(e) = project.elements.get(&id) {
(serde_json::to_value(e).ok()?, true)
} else {
(
serde_json::to_value(project.relations.get(&id)?).ok()?,
false,
)
};
let mut prefix = vec!["body".to_string()];
let mut value = &whole["body"];
if element {
let layer = enum_tag(value)?.to_string();
prefix.push(layer.clone());
value = &value[&layer];
if enum_tag(value) == Some("Shared") {
prefix.push("Shared".into());
value = &value["Shared"];
}
}
let kind = enum_tag(value)?.to_string();
prefix.push(kind.clone());
Some(Self {
whole,
prefix,
kind,
})
}
}
fn at<'a>(mut value: &'a Value, path: &[String]) -> Option<&'a Value> {
for part in path {
value = value.get(part)?;
}
Some(value)
}
fn path(own: &Own, facet: &FacetSelector) -> Option<Vec<String>> {
use FacetSelector as F;
let (family, parts): (&str, &[&str]) = match facet {
F::Header(HeaderFacet::Name) => return Some(vec!["name".into()]),
F::Header(HeaderFacet::Description) => return Some(vec!["description".into()]),
F::Header(HeaderFacet::Parent) => {
return own.whole.get("parent").map(|_| vec!["parent".into()]);
}
F::ExchangeItem(ExchangeItemFacet::Nature) => ("ExchangeItem", &["kind"]),
F::ExchangeItem(ExchangeItemFacet::Mechanism) => ("ExchangeItem", &["mechanism"]),
F::ExchangeItem(ExchangeItemFacet::Supertype) => ("ExchangeItem", &["supertype"]),
F::Interface(InterfaceFacet::Supertype) => ("Interface", &["supertype"]),
F::Data(DataFacet::Constructor) => ("DataType", &["definition"]),
F::Data(DataFacet::IntegerMinimum) => ("DataType", &["definition", "Integer", "minimum"]),
F::Data(DataFacet::IntegerMaximum) => ("DataType", &["definition", "Integer", "maximum"]),
F::Data(DataFacet::RealMinimum) => ("DataType", &["definition", "Real", "minimum"]),
F::Data(DataFacet::RealMaximum) => ("DataType", &["definition", "Real", "maximum"]),
F::Data(DataFacet::StringMinimumLength) => {
("DataType", &["definition", "String", "minimum_length"])
}
F::Data(DataFacet::StringMaximumLength) => {
("DataType", &["definition", "String", "maximum_length"])
}
F::Data(DataFacet::RecordSupertype) => ("DataType", &["definition", "Record", "supertype"]),
F::Data(DataFacet::Default) => ("DataType", &["default"]),
F::Data(DataFacet::Unit) => ("DataType", &["unit"]),
F::Field(FieldFacet::Type) => ("DataField", &["type_ref"]),
F::Field(FieldFacet::Minimum) => ("DataField", &["minimum"]),
F::Field(FieldFacet::Maximum) => ("DataField", &["maximum"]),
F::Field(FieldFacet::Unit) => ("DataField", &["unit"]),
F::Field(FieldFacet::Direction) => ("DataField", &["direction"]),
F::Field(FieldFacet::Storage) => ("DataField", &["storage"]),
F::Field(FieldFacet::Default) => ("DataField", &["default"]),
F::Port(PortFacet::Constructor) => ("Port", &["contract"]),
F::Port(PortFacet::ComponentKind) => ("Port", &["contract", "Component", "kind"]),
F::Port(PortFacet::Orientation) => ("Port", &["contract", "Component", "orientation"]),
F::Port(PortFacet::LegacyDirection) => ("Port", &["legacy", "direction"]),
F::Port(PortFacet::LegacyInterface) => ("Port", &["legacy", "interface"]),
F::Scenario(ScenarioFacet::Viewpoint) => ("Scenario", &["viewpoint"]),
F::Scenario(ScenarioFacet::Context) => ("Scenario", &["context"]),
F::Scenario(ScenarioFacet::Precondition) => ("Scenario", &["precondition"]),
F::Scenario(ScenarioFacet::Postcondition) => ("Scenario", &["postcondition"]),
F::Lifeline(LifelineFacet::Represented) => ("Lifeline", &["represented"]),
F::Occurrence(OccurrenceFacet::Constructor) => ("Occurrence", &["body"]),
F::Occurrence(OccurrenceFacet::Precondition) => ("Occurrence", &["precondition"]),
F::Occurrence(OccurrenceFacet::Postcondition) => ("Occurrence", &["postcondition"]),
F::Occurrence(OccurrenceFacet::Exchange) => {
("Occurrence", &["body", "Exchange", "exchange"])
}
F::Occurrence(OccurrenceFacet::Sender) => ("Occurrence", &["body", "Exchange", "sender"]),
F::Occurrence(OccurrenceFacet::Receiver) => {
("Occurrence", &["body", "Exchange", "receiver"])
}
F::Occurrence(OccurrenceFacet::MessageKind) => {
("Occurrence", &["body", "Exchange", "message_kind"])
}
F::Occurrence(OccurrenceFacet::TimerExpression) => (
"Occurrence",
&["body", "Exchange", "message_kind", "Timer", "expression"],
),
F::Occurrence(OccurrenceFacet::ReplyTo) => {
("Occurrence", &["body", "Exchange", "reply_to"])
}
F::Occurrence(OccurrenceFacet::Action) => ("Occurrence", &["body", "Action", "action"]),
F::Occurrence(OccurrenceFacet::ActionLifeline) => {
("Occurrence", &["body", "Action", "lifeline"])
}
F::Occurrence(OccurrenceFacet::ControlKind) => ("Occurrence", &["body", "Control", "kind"]),
F::Occurrence(OccurrenceFacet::Sequence) => {
("Occurrence", &["body", "SequenceReference", "sequence"])
}
F::Occurrence(OccurrenceFacet::Scenario) => {
("Occurrence", &["body", "ScenarioUse", "scenario"])
}
F::Occurrence(OccurrenceFacet::State) => ("Occurrence", &["body", "State", "state"]),
F::Occurrence(OccurrenceFacet::StateLifeline) => {
("Occurrence", &["body", "State", "lifeline"])
}
F::Occurrence(OccurrenceFacet::FragmentOperator) => {
("Occurrence", &["body", "Fragment", "operator"])
}
F::Occurrence(OccurrenceFacet::BranchGuard) => ("Occurrence", &["body", "Branch", "guard"]),
F::Event(EventFacet::Constructor) => ("Event", &["body"]),
F::Event(EventFacet::TimeKind) => ("Event", &["body", "Time", "kind"]),
F::Event(EventFacet::TimeExpression) => ("Event", &["body", "Time", "expression"]),
F::Event(EventFacet::ChangeExpression) => ("Event", &["body", "Change", "expression"]),
F::State(StateFacet::Kind) => ("State", &["kind"]),
F::State(StateFacet::Entry) => ("State", &["entry"]),
F::State(StateFacet::Exit) => ("State", &["exit"]),
F::State(StateFacet::DoActivity) => ("State", &["do_activity"]),
F::Constraint(ConstraintFacet::Expression) => ("Constraint", &["expression"]),
F::Relation(RelationFacet::Source) => ("*", &["source"]),
F::Relation(RelationFacet::Target) => ("*", &["target"]),
F::Relation(RelationFacet::AllocationOrder) => ("InterfaceItemAllocation", &["order"]),
F::Relation(RelationFacet::Transmission) => ("InterfaceItemAllocation", &["transmission"]),
F::Relation(RelationFacet::Acquisition) => ("InterfaceItemAllocation", &["acquisition"]),
F::Relation(RelationFacet::SourceRole) => ("DataAssociation", &["source_role"]),
F::Relation(RelationFacet::TargetRole) => ("DataAssociation", &["target_role"]),
F::Relation(RelationFacet::SourceMinimum) => ("DataAssociation", &["source_minimum"]),
F::Relation(RelationFacet::SourceMaximum) => ("DataAssociation", &["source_maximum"]),
F::Relation(RelationFacet::TargetMinimum) => ("DataAssociation", &["target_minimum"]),
F::Relation(RelationFacet::TargetMaximum) => ("DataAssociation", &["target_maximum"]),
F::Relation(RelationFacet::SourceNavigable) => ("DataAssociation", &["source_navigable"]),
F::Relation(RelationFacet::TargetNavigable) => ("DataAssociation", &["target_navigable"]),
F::Relation(RelationFacet::SourcePort) => ("FunctionalExchange", &["source_port"]),
F::Relation(RelationFacet::TargetPort) => ("FunctionalExchange", &["target_port"]),
F::Relation(RelationFacet::Oriented) => ("BoundaryExchange", &["oriented"]),
F::Relation(RelationFacet::ExtendCondition) => ("CapabilityExtend", &["condition"]),
F::Relation(RelationFacet::SequenceGuard) => ("SequenceLink", &["guard"]),
F::Relation(RelationFacet::TransitionKind) => ("StateTransition", &["transition_kind"]),
F::Relation(RelationFacet::Trigger) => ("StateTransition", &["trigger"]),
F::Relation(RelationFacet::Guard) => ("StateTransition", &["guard"]),
F::Relation(RelationFacet::Effect) => ("StateTransition", &["effect"]),
F::Relation(RelationFacet::StateActionPhase) => ("StateAction", &["phase"]),
_ => return None,
};
if family != "*" && own.kind != family {
return None;
}
let mut path = own.prefix.clone();
path.extend(parts.iter().map(|s| s.to_string()));
at(&own.whole, &path).map(|_| path)
}
pub(super) fn binding(project: &Project, id: Id, facet: &FacetSelector) -> Option<String> {
let own = Own::get(project, id)?;
path(&own, facet).map(|parts| format!("/{}", parts.join("/")))
}
pub(super) fn grouped(facet: &FacetSelector) -> bool {
matches!(
facet,
FacetSelector::Data(DataFacet::Constructor)
| FacetSelector::Port(PortFacet::Constructor)
| FacetSelector::Occurrence(
OccurrenceFacet::Constructor | OccurrenceFacet::MessageKind
)
| FacetSelector::Event(EventFacet::Constructor)
)
}
fn pair_json(fact: &FacetReview) -> Value {
let json = serde_json::to_value(fact).expect("Typed semantic fact");
let (family, value) = json
.as_object()
.expect("Typed family")
.iter()
.next()
.expect("Family");
match family.as_str() {
"Member" | "Order" | "Literal" | "Annotation" | "Binding" | "Child" | "Incident" => {
value["review"].clone()
}
"Artifact" => value["review"]
.as_object()
.expect("Artifact leaf")
.values()
.next()
.expect("Artifact value")
.clone(),
_ => value
.as_object()
.expect("Scalar leaf")
.values()
.next()
.expect("Scalar value")
.clone(),
}
}
pub(super) fn observation(fact: &FacetReview, layer: Layer) -> Value {
pair_json(fact)[match layer {
Layer::Oa => "oa",
Layer::Sa => "sa",
}]
.clone()
}
pub(super) fn value(fact: &FacetReview, layer: Layer) -> Option<Value> {
observation(fact, layer).get("Value").cloned()
}
pub(super) fn source_value(
fact: &FacetReview,
source: Layer,
correspondence: &BTreeMap<Id, Id>,
) -> Result<Value, String> {
// Retain only the source observation before invoking the core's typed UUID
// visitor. UUID-shaped prose must never be treated as a reference.
let mut json = serde_json::to_value(fact).map_err(|e| e.to_string())?;
let leaf = json
.as_object_mut()
.ok_or("Missing scalar family")?
.values_mut()
.next()
.ok_or("Missing scalar family")?
.as_object_mut()
.ok_or("Missing scalar leaf")?
.values_mut()
.next()
.ok_or("Missing scalar leaf")?;
let selected = observation(fact, source);
leaf["oa"] = selected.clone();
leaf["sa"] = selected;
let typed = serde_json::from_value(json).map_err(|e| e.to_string())?;
let mut baseline = SemanticBaseline {
facets: vec![typed],
};
let mut missing = BTreeSet::new();
visit_semantic_references(&mut baseline, |id| {
if let Some(mapped) = correspondence.get(id) {
*id = *mapped;
} else {
missing.insert(*id);
}
});
if !missing.is_empty() {
return Err(format!(
"Choose reference counterparts before taking this value: {}",
missing
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
));
}
value(&baseline.facets[0], source)
.ok_or("Source property is not applicable; review its constructor first.".into())
}
pub(super) fn references(fact: &FacetReview) -> BTreeSet<Id> {
let mut ids = BTreeSet::new();
visit_semantic_references(
&mut SemanticBaseline {
facets: vec![fact.clone()],
},
|id| {
ids.insert(*id);
},
);
ids
}
@@ -0,0 +1,988 @@
use super::*;
use crate::{app::App, document::Document, workflow::Workflow};
use serde_json::json;
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(event)));
}
fn apply(app: &mut App) {
let _ = app.update(Message::Workflow(crate::workflow::Event::Apply));
}
fn state(app: &App) -> &State {
let Some(Workflow::Semantic(state)) = &app.workflow else {
panic!("Semantic workflow missing: {}", app.status)
};
state
}
fn fixture() -> (Project, Id, Id, Id) {
let mut p = Project::empty();
let oa = p
.add_element(Kind::DataType, Layer::Oa, "Operational range", None)
.unwrap();
let sa = p
.add_element(Kind::DataType, Layer::Sa, "System range", None)
.unwrap();
for (id, min, max, unit) in [(oa, 0, 20, "m"), (sa, 1, 30, "cm")] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Integer {
minimum: Some(min),
maximum: Some(max),
},
default: None,
unit: unit.into(),
};
}
let trace = add_trace(&mut p, oa, sa, "Range rationale");
(p, oa, sa, trace)
}
fn add_trace(p: &mut Project, oa: Id, sa: Id, name: &str) -> Id {
let id = Id::new_v4();
p.traces.insert(
id,
Trace {
id,
alias: format!("trace_{}", id.simple()),
name: name.into(),
description: String::new(),
oa,
sa,
kind: TraceKind::Manual,
baseline: None,
},
);
id
}
fn start(app: &mut App, source: Id) {
let _ = app.update(Message::StartSemanticReview(source));
}
fn facet(value: DataFacet) -> FacetSelector {
FacetSelector::Data(value)
}
#[test]
fn independent_review_clear_bound_ignores_retained_invalid_other_facet() {
let (p, oa, sa, _) = fixture();
let mut app = App::with_project(p);
let before = app.document.snapshot();
start(&mut app, oa);
let minimum = "/body/Sa/Shared/DataType/definition/Integer/minimum";
let maximum = "/body/Sa/Shared/DataType/definition/Integer/maximum";
decide(
&mut app,
facet(DataFacet::IntegerMinimum),
SemanticDecision::Candidate,
);
send(&mut app, Event::Input(minimum.into(), "-".into()));
decide(
&mut app,
facet(DataFacet::IntegerMinimum),
SemanticDecision::KeepTarget,
);
decide(
&mut app,
facet(DataFacet::IntegerMaximum),
SemanticDecision::Candidate,
);
assert!(state(&app).can_apply());
// Exact event emitted by the upper bound's visible Clear bound button.
send(&mut app, Event::Shape(maximum.into(), Value::Null));
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[minimum].raw, "-");
assert!(
state(&app)
.draft
.as_ref()
.unwrap()
.value
.pointer(maximum)
.unwrap()
.is_null()
);
// Clear must also discard the selected invalid value, without requiring it
// to parse before removal. A valid but uncommitted neighboring input survives.
send(&mut app, Event::Shape(maximum.into(), json!(30)));
send(&mut app, Event::Input(maximum.into(), "29".into()));
decide(
&mut app,
facet(DataFacet::IntegerMinimum),
SemanticDecision::Candidate,
);
assert!(!state(&app).can_apply());
send(&mut app, Event::Shape(minimum.into(), Value::Null));
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
assert_eq!(
state(&app).draft.as_ref().unwrap().inputs[maximum].raw,
"29"
);
assert!(
!state(&app)
.draft
.as_ref()
.unwrap()
.inputs
.contains_key(minimum)
);
assert_eq!(app.document.bundle(), &before.bundle);
apply(&mut app);
assert!(matches!(
app.project().elements[&sa].shared(),
Some(SharedBody::DataType {
definition: DataTypeDefinition::Integer {
minimum: None,
maximum: Some(29)
},
..
})
));
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
}
#[test]
fn scoped_literal_list_edits_preserve_unfinished_values_and_other_facet_buffers() {
let mut p = Project::empty();
let mut pair = vec![];
for layer in [Layer::Oa, Layer::Sa] {
let item = p
.add_element(Kind::ExchangeItem, layer, "Payload", None)
.unwrap();
let field = p
.add_element(Kind::DataField, layer, "Numbers", Some(item))
.unwrap();
*p.elements.get_mut(&field).unwrap().shared_mut().unwrap() = SharedBody::DataField {
type_ref: TypeRef::Integer,
minimum: 0,
maximum: None,
unit: String::new(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: Some(vec![DataValue::Integer(1), DataValue::Integer(2)]),
};
pair.push(field);
}
add_trace(&mut p, pair[0], pair[1], "Numbers review");
let mut app = App::with_project(p);
start(&mut app, pair[0]);
let root = "/body/Sa/Shared/DataField";
let minimum = format!("{root}/minimum");
let default = format!("{root}/default");
let first = format!("{default}/0/Integer");
let second = format!("{default}/1/Integer");
decide(
&mut app,
FacetSelector::Field(FieldFacet::Minimum),
SemanticDecision::Candidate,
);
send(&mut app, Event::Input(minimum.clone(), "-".into()));
decide(
&mut app,
FacetSelector::Field(FieldFacet::Minimum),
SemanticDecision::KeepTarget,
);
decide(
&mut app,
FacetSelector::Field(FieldFacet::Default),
SemanticDecision::Candidate,
);
send(&mut app, Event::Input(first.clone(), "-".into()));
send(&mut app, Event::Input(second.clone(), "23".into()));
send(
&mut app,
Event::Append(default.clone(), json!({"Integer": 3})),
);
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[&first].raw, "-");
assert_eq!(
state(&app).draft.as_ref().unwrap().inputs[&second].raw,
"23"
);
// Same-variant reselection must not throw away the invalid authored value.
send(
&mut app,
Event::Variant(format!("{default}/0"), json!({"Integer": 0})),
);
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[&first].raw, "-");
send(&mut app, Event::Array(default.clone(), Some(0), 1));
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[&first].raw, "23");
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[&second].raw, "-");
assert!(!state(&app).can_apply());
send(&mut app, Event::Array(default, Some(1), 0));
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
assert_eq!(
state(&app).draft.as_ref().unwrap().inputs[&minimum].raw,
"-"
);
apply(&mut app);
assert!(
matches!(app.project().elements[&pair[1]].shared(), Some(SharedBody::DataField {
default: Some(values), minimum: 0, ..
}) if values == &vec![DataValue::Integer(23), DataValue::Integer(3)])
);
}
#[test]
fn independent_review_keyed_fact_titles_keep_live_deleted_and_qualified_identity() {
let mut p = Project::empty();
let a = Id::new_v4();
let b = Id::new_v4();
let first = FacetSelector::Child(KeyPair {
oa: Some(a),
sa: None,
});
let second = FacetSelector::Child(KeyPair {
oa: Some(b),
sa: None,
});
let first_title = super::view::title(&p, &first);
let second_title = super::view::title(&p, &second);
assert_ne!(first_title, second_title);
assert!(first_title.contains(&a.to_string()));
assert!(second_title.contains(&b.to_string()));
let one = p
.add_element(Kind::ExchangeItem, Layer::Oa, "Same name", None)
.unwrap();
let two = p
.add_element(Kind::ExchangeItem, Layer::Oa, "Same name", None)
.unwrap();
let field = p
.add_element(Kind::DataField, Layer::Oa, "Value", Some(one))
.unwrap();
let annotation = |path| {
FacetSelector::Annotation(KeyPair {
oa: Some(AnnotationKey { path, field }),
sa: None,
})
};
let first_title = super::view::title(&p, &annotation(vec![one, a]));
let second_title = super::view::title(&p, &annotation(vec![two, b]));
assert_ne!(first_title, second_title);
for id in [one, field] {
assert!(first_title.contains(&format!("@{}", p.elements[&id].alias)));
}
assert!(first_title.contains(&a.to_string()));
assert!(second_title.contains(&b.to_string()));
assert_ne!(super::view::title(&p, &annotation(vec![])), first_title);
}
fn decide(app: &mut App, selected: FacetSelector, decision: SemanticDecision) {
send(app, Event::Select(selected.clone()));
send(app, Event::Decide(selected, Some(decision)));
}
#[test]
fn scalar_keep_take_candidate_commit_exact_source_and_history_in_both_directions() {
for reverse in [false, true] {
let (p, oa, sa, trace) = fixture();
let mut app = App::with_project(p);
let mut bundle = app.document.bundle().clone();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.insert_str(0, "// Retain this engineering rationale.\r\n");
app.document.apply_bundle(bundle).unwrap();
let before = app.document.snapshot();
let revision = app.document.revision();
let (source, target, prefix, expected_max, kept_min) = if reverse {
(sa, oa, "/body/Oa/Shared/DataType", 30, 0)
} else {
(oa, sa, "/body/Sa/Shared/DataType", 20, 1)
};
start(&mut app, source);
decide(
&mut app,
facet(DataFacet::IntegerMinimum),
SemanticDecision::KeepTarget,
);
decide(
&mut app,
facet(DataFacet::IntegerMaximum),
SemanticDecision::TakeSource,
);
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Input(format!("{prefix}/unit"), "kg\n exact suffix ".into()),
);
let prepared =
prepare_semantic_transition(app.project(), state(&app).plan.as_ref().unwrap())
.unwrap()
.project;
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
assert_eq!(
app.project(),
&before.decoded.project,
"Preview must not mutate accepted state"
);
apply(&mut app);
assert!(app.workflow.is_none(), "{}", app.status);
assert_eq!(
app.project(),
&prepared,
"Apply must equal the exact reviewed project including diagrams"
);
assert_eq!(app.document.revision(), revision + 1);
let Some(SharedBody::DataType {
definition: DataTypeDefinition::Integer { minimum, maximum },
unit,
..
}) = app.project().elements[&target].shared()
else {
panic!()
};
assert_eq!((*minimum, *maximum), (Some(kept_min), Some(expected_max)));
assert_eq!(unit, "kg\n exact suffix ");
let baseline = app.project().traces[&trace]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap();
assert_eq!(baseline.facets.len(), 3, "Unselected facts stay unreviewed");
assert!(
app.document.bundle().files[&app.document.bundle().entry]
.contains("// Retain this engineering rationale.")
);
let accepted = app.document.snapshot();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.project(), &before.decoded.project);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert_eq!(app.project(), &accepted.decoded.project);
}
}
#[test]
fn invalid_numeric_draft_and_protected_edits_retain_workflow_and_source() {
let (p, oa, _, _) = fixture();
let mut app = App::with_project(p);
let before = app.document.snapshot();
start(&mut app, oa);
decide(
&mut app,
facet(DataFacet::IntegerMinimum),
SemanticDecision::Candidate,
);
let path = "/body/Sa/Shared/DataType/definition/Integer/minimum";
send(&mut app, Event::Input(path.into(), "-".into()));
assert!(!state(&app).can_apply());
send(&mut app, Event::Tab(Tab::References));
assert!(
state(&app).error.is_some(),
"Navigation must retain visible validation errors"
);
apply(&mut app);
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[path].raw, "-");
assert_eq!(app.document.bundle(), &before.bundle);
send(&mut app, Event::Input(path.into(), "2".into()));
send(&mut app, Event::Input("/alias".into(), "unexpected".into()));
assert!(!state(&app).can_apply());
assert!(
state(&app)
.error
.as_ref()
.unwrap()
.contains("selected scalar")
);
assert_eq!(app.project(), &before.decoded.project);
send(&mut app, Event::Preview);
assert!(state(&app).can_apply());
}
#[test]
fn explicit_trace_selection_updates_only_that_history() {
let (mut p, oa, sa, first) = fixture();
let second = add_trace(&mut p, oa, sa, "Independent assessment");
p.traces.get_mut(&second).unwrap().kind = TraceKind::Item;
let mut app = App::with_project(p);
start(&mut app, oa);
assert!(state(&app).trace.is_none());
assert!(!state(&app).can_apply());
send(
&mut app,
Event::Trace(Choice {
id: second,
label: "Independent assessment".into(),
}),
);
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::KeepTarget,
);
apply(&mut app);
assert!(app.workflow.is_none(), "{}", app.status);
assert!(
app.project().traces[&first]
.baseline
.as_ref()
.and_then(|b| b.semantic.as_ref())
.is_none()
);
assert!(
app.project().traces[&second]
.baseline
.as_ref()
.unwrap()
.semantic
.is_some()
);
}
#[test]
fn stale_plan_and_source_writeback_failure_do_not_lose_decisions_or_history() {
let (p, oa, _, _) = fixture();
let mut app = App::with_project(p);
start(&mut app, oa);
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::KeepTarget,
);
app.document
.execute(Command::Rename(oa, "Changed outside preview".into()))
.unwrap();
let before = app.document.snapshot();
let revision = app.document.revision();
apply(&mut app);
assert!(state(&app).error.as_ref().unwrap().contains("stale"));
assert_eq!(state(&app).decisions.len(), 1);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.document.revision(), revision);
let (p, oa, _, _) = fixture();
let mut app = App::with_project(p);
let bundle = app.document.bundle().clone();
let mut limits = source_project::ProjectLimits::default();
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum::<usize>() + 10;
app.document = Document::from_bundle(bundle, limits).unwrap();
let before = app.document.snapshot();
start(&mut app, oa);
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Input(
"/body/Sa/Shared/DataType/unit".into(),
"long final unit ".repeat(40),
),
);
assert!(state(&app).can_apply());
apply(&mut app);
assert!(state(&app).error.is_some());
assert_eq!(state(&app).decisions.len(), 1);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.document.revision(), 0);
assert!(!app.document.can_undo());
}
#[test]
fn candidate_mixed_with_reference_take_uses_typed_correspondence_not_text_guessing() {
let (mut p, oa, sa, _) = fixture();
let parent_oa = p
.add_element(Kind::DataType, Layer::Oa, "OA parent", None)
.unwrap();
let parent_sa = p
.add_element(Kind::DataType, Layer::Sa, "SA parent", None)
.unwrap();
for id in [parent_oa, parent_sa] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype: None },
default: None,
unit: String::new(),
};
}
// ExchangeItem supertypes are an independent optional typed reference.
for (id, parent) in [(oa, Some(parent_oa)), (sa, None)] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype: parent },
default: None,
unit: String::new(),
};
}
let mut app = App::with_project(p);
start(&mut app, oa);
decide(
&mut app,
facet(DataFacet::RecordSupertype),
SemanticDecision::TakeSource,
);
assert!(!state(&app).can_apply());
send(
&mut app,
Event::ReferenceOa(Choice {
id: parent_oa,
label: "OA parent".into(),
}),
);
send(
&mut app,
Event::ReferenceSa(Choice {
id: parent_sa,
label: "SA parent".into(),
}),
);
send(&mut app, Event::AddReference);
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Input(
"/body/Sa/Shared/DataType/unit".into(),
parent_oa.to_string(),
),
);
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
apply(&mut app);
let Some(SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype },
unit,
..
}) = app.project().elements[&sa].shared()
else {
panic!()
};
assert_eq!(*supertype, Some(parent_sa));
assert_eq!(
unit,
&parent_oa.to_string(),
"UUID-shaped prose must not be remapped"
);
}
#[test]
fn read_only_structured_facts_and_partial_rollup_are_honest() {
let (p, oa, _, trace) = fixture();
let mut app = App::with_project(p);
start(&mut app, oa);
send(
&mut app,
Event::Decide(
facet(DataFacet::Constructor),
Some(SemanticDecision::TakeSource),
),
);
assert!(state(&app).decisions.is_empty());
assert!(state(&app).error.is_some());
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::KeepTarget,
);
apply(&mut app);
let rollup = trace_semantic_drift(
app.project(),
&app.project().traces[&trace],
&BTreeMap::new(),
)
.unwrap();
assert!(rollup.own_unreviewed);
assert!(summary(app.project(), &app.project().traces[&trace]).contains("Unreviewed"));
let mut request = State::new(app.project(), trace).unwrap();
request.update(
app.project(),
Event::Select(facet(DataFacet::IntegerMaximum)),
);
let _ = request.view(app.project());
assert_eq!(
scalar::value(
&capture_facet(
app.project(),
oa,
request.target(app.project()).unwrap(),
&facet(DataFacet::Default)
)
.unwrap(),
Layer::Oa
),
Some(json!(null))
);
}
#[test]
fn first_equal_constructor_acknowledges_only_kind_and_allows_independent_leaves() {
let (p, oa, _, trace) = fixture();
let mut app = App::with_project(p);
start(&mut app, oa);
assert!(state(&app).can_acknowledge_constructor(app.project(), &facet(DataFacet::Constructor)));
decide(
&mut app,
facet(DataFacet::Constructor),
SemanticDecision::KeepTarget,
);
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Input("/body/Sa/Shared/DataType/unit".into(), "local unit".into()),
);
assert!(
state(&app).can_apply(),
"{:?}",
state(&app).plan.as_ref().map(|p| &p.conflicts)
);
apply(&mut app);
let facts = &app.project().traces[&trace]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap()
.facets;
assert_eq!(facts.len(), 2);
assert!(
facts
.iter()
.any(|f| f.selector() == facet(DataFacet::Constructor))
);
assert!(
!facts
.iter()
.any(|f| f.selector() == facet(DataFacet::IntegerMinimum))
);
start(&mut app, oa);
assert!(
!state(&app).can_acknowledge_constructor(app.project(), &facet(DataFacet::Constructor))
);
let remaining: Vec<_> = state(&app)
.facts
.iter()
.filter(|f| f.drift == Drift::Unreviewed)
.map(|f| f.selector.clone())
.collect();
for selector in remaining {
decide(&mut app, selector, SemanticDecision::KeepTarget);
}
apply(&mut app);
assert!(app.workflow.is_none(), "{}", app.status);
assert!(
!trace_semantic_drift(
app.project(),
&app.project().traces[&trace],
&BTreeMap::new()
)
.unwrap()
.own_unreviewed
);
}
#[test]
fn state_kind_scalar_uses_typed_candidate_and_core_validation() {
let mut p = Project::empty();
let mut pair = vec![];
for layer in [Layer::Oa, Layer::Sa] {
let machine = p
.add_element(Kind::StateMachine, layer, "Machine", None)
.unwrap();
let region = p
.add_element(Kind::Region, layer, "Region", Some(machine))
.unwrap();
pair.push(
p.add_element(Kind::State, layer, "Ready", Some(region))
.unwrap(),
);
}
add_trace(&mut p, pair[0], pair[1], "State review");
let mut app = App::with_project(p);
start(&mut app, pair[0]);
decide(
&mut app,
FacetSelector::State(StateFacet::Kind),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Shape("/body/Sa/Shared/State/kind".into(), json!("Final")),
);
assert!(
state(&app).can_apply(),
"{:?}",
state(&app).plan.as_ref().map(|p| &p.conflicts)
);
let expected = state(&app).proposed.clone().unwrap();
apply(&mut app);
assert_eq!(app.project(), &expected);
assert_eq!(app.project().elements[&pair[1]].kind(), Kind::Final);
}
#[test]
fn relation_candidates_preserve_endpoints_and_combine_selected_roles_and_bounds() {
let mut p = Project::empty();
let mut pair = vec![];
for layer in [Layer::Oa, Layer::Sa] {
let a = p
.add_element(Kind::DataType, layer, "Origin type", None)
.unwrap();
let b = p
.add_element(Kind::DataType, layer, "Destination type", None)
.unwrap();
for id in [a, b] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype: None },
default: None,
unit: String::new(),
};
}
let relation = p.relate(RelationKind::DataAssociation, "Association", a, b);
if let RelationBody::DataAssociation { source_role, .. } =
&mut p.relations.get_mut(&relation).unwrap().body
{
*source_role = if layer == Layer::Oa {
"Authored source role"
} else {
"Local role"
}
.into();
}
pair.push(relation);
}
add_trace(&mut p, pair[0], pair[1], "Association review");
let original = p.relations[&pair[1]].clone();
let mut app = App::with_project(p);
start(&mut app, pair[0]);
decide(
&mut app,
FacetSelector::Relation(RelationFacet::SourceRole),
SemanticDecision::TakeSource,
);
decide(
&mut app,
FacetSelector::Relation(RelationFacet::TargetMaximum),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Shape("/body/DataAssociation/target_maximum".into(), json!(4)),
);
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
apply(&mut app);
let accepted = &app.project().relations[&pair[1]];
assert_eq!(accepted.alias, original.alias);
assert_eq!(
(accepted.source(), accepted.target()),
(original.source(), original.target())
);
let RelationBody::DataAssociation {
source_role,
target_maximum,
..
} = &accepted.body
else {
panic!()
};
assert_eq!(source_role, "Authored source role");
assert_eq!(*target_maximum, Some(4));
}
#[test]
fn deselected_invalid_candidate_is_retained_but_does_not_poison_another_selected_scalar() {
let (p, oa, sa, _) = fixture();
let mut app = App::with_project(p);
start(&mut app, oa);
let path = "/body/Sa/Shared/DataType/definition/Integer/minimum";
decide(
&mut app,
facet(DataFacet::IntegerMinimum),
SemanticDecision::Candidate,
);
send(&mut app, Event::Input(path.into(), "-".into()));
decide(
&mut app,
facet(DataFacet::IntegerMinimum),
SemanticDecision::KeepTarget,
);
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Input(
"/body/Sa/Shared/DataType/unit".into(),
"reviewed local".into(),
),
);
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[path].raw, "-");
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
apply(&mut app);
let Some(SharedBody::DataType {
definition: DataTypeDefinition::Integer { minimum, .. },
unit,
..
}) = app.project().elements[&sa].shared()
else {
panic!()
};
assert_eq!(*minimum, Some(1));
assert_eq!(unit, "reviewed local");
}
#[test]
fn inherited_annotation_impact_requires_exact_acceptance_and_edits_clear_approval() {
let mut p = Project::empty();
let oa = p
.add_element(Kind::DataType, Layer::Oa, "Operational record", None)
.unwrap();
let sa = p
.add_element(Kind::DataType, Layer::Sa, "System record", None)
.unwrap();
let base = p
.add_element(Kind::DataType, Layer::Sa, "Inherited record", None)
.unwrap();
for (id, supertype) in [(oa, None), (sa, Some(base)), (base, None)] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype },
default: None,
unit: String::new(),
};
}
let leaf = p
.add_element(Kind::DataField, Layer::Sa, "Inherited value", Some(base))
.unwrap();
let item = p
.add_element(Kind::ExchangeItem, Layer::Sa, "Telemetry", None)
.unwrap();
let root = p
.add_element(Kind::DataField, Layer::Sa, "Position", Some(item))
.unwrap();
for (id, type_ref) in [(leaf, TypeRef::Real), (root, TypeRef::Named(sa))] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataField {
type_ref,
minimum: 0,
maximum: Some(1),
unit: String::new(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
};
}
let a = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let b = p
.add_element(Kind::Function, Layer::Sa, "Receive", None)
.unwrap();
let exchange = p.relate(RelationKind::FunctionalExchange, "Telemetry exchange", a, b);
let (source_port, target_port) = match &mut p.relations.get_mut(&exchange).unwrap().body {
RelationBody::FunctionalExchange {
source_port,
target_port,
items,
..
} => {
items.push(item);
(*source_port, *target_port)
}
_ => panic!(),
};
for id in [source_port, target_port] {
match p.elements.get_mut(&id).unwrap().shared_mut().unwrap() {
SharedBody::Port {
contract: PortContract::FunctionInput { incoming_items },
..
} => incoming_items.push(item),
SharedBody::Port {
contract: PortContract::FunctionOutput { outgoing_items },
..
} => outgoing_items.push(item),
_ => panic!(),
}
}
let scenario = p
.add_element(Kind::Scenario, Layer::Sa, "Telemetry scenario", None)
.unwrap();
let message = p
.add_element(Kind::Occurrence, Layer::Sa, "Send position", Some(scenario))
.unwrap();
*p.elements.get_mut(&message).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: Some(exchange),
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![MessageValue {
path: vec![root],
field: leaf,
expression: "current position".into(),
}],
},
precondition: String::new(),
postcondition: String::new(),
};
p.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.push(message);
add_trace(&mut p, oa, sa, "Record inheritance review");
let mut app = App::with_project(p);
let before = app.document.snapshot();
start(&mut app, oa);
decide(
&mut app,
facet(DataFacet::RecordSupertype),
SemanticDecision::TakeSource,
);
assert!(!state(&app).can_apply());
let impacts = &state(&app).plan.as_ref().unwrap().impacts;
assert_eq!(impacts.len(), 1);
assert_eq!(impacts[0].object, message);
let exact = impacts[0].clone();
send(&mut app, Event::AcceptImpact(0, true));
assert!(
state(&app).can_apply(),
"{:?}",
state(&app).plan.as_ref().map(|p| &p.conflicts)
);
assert_eq!(
state(&app).plan.as_ref().unwrap().request.accepted_impacts,
vec![exact]
);
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::KeepTarget,
);
assert!(
state(&app)
.plan
.as_ref()
.unwrap()
.request
.accepted_impacts
.is_empty()
);
assert!(!state(&app).can_apply());
send(&mut app, Event::AcceptImpact(0, true));
let expected = state(&app).proposed.clone().unwrap();
assert_eq!(app.document.bundle(), &before.bundle);
apply(&mut app);
assert_eq!(app.project(), &expected);
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = app.project().elements[&message].shared()
else {
panic!()
};
assert!(values.is_empty());
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
}
@@ -0,0 +1,592 @@
use super::*;
use crate::{style, views::short};
use iced::{
Element, Fill,
widget::{
Space, button, checkbox, column, container, pick_list, row, scrollable, text, text_input,
},
};
fn event(event: Event) -> Message {
Message::Workflow(crate::workflow::Event::Semantic(event))
}
fn action<'a>(label: impl Into<String>, event_value: Event) -> iced::widget::Button<'a, Message> {
button(text(label.into()).size(12))
.padding([7, 9])
.style(style::nav)
.on_press(event(event_value))
}
pub(super) fn choice(project: &Project, id: Id) -> Choice {
let alias = project
.elements
.get(&id)
.map(|v| v.alias.as_str())
.or_else(|| project.relations.get(&id).map(|v| v.alias.as_str()))
.or_else(|| project.artifact_links.get(&id).map(|v| v.alias.as_str()));
Choice {
id,
label: alias.map_or_else(
|| format!("Deleted reference · {id}"),
|alias| format!("{} · @{alias}", project.named(id)),
),
}
}
fn words(value: &str) -> String {
let mut out = String::new();
for (index, c) in value.chars().enumerate() {
if c == '_' {
out.push(' ');
} else {
if index > 0 && c.is_uppercase() {
out.push(' ');
}
out.push(c);
}
}
out
}
pub(super) fn group(facet: &FacetSelector) -> &'static str {
match facet {
FacetSelector::Header(_) => "Name and ownership",
FacetSelector::Data(_) | FacetSelector::Field(_) | FacetSelector::Literal(_) => {
"Data and defaults"
}
FacetSelector::Port(_) | FacetSelector::Interface(_) | FacetSelector::ExchangeItem(_) => {
"Contracts and interfaces"
}
FacetSelector::Scenario(_)
| FacetSelector::Lifeline(_)
| FacetSelector::Occurrence(_)
| FacetSelector::Event(_)
| FacetSelector::State(_)
| FacetSelector::Annotation(_)
| FacetSelector::Binding(_) => "Behavior and scenarios",
FacetSelector::Relation(_) | FacetSelector::Constraint(_) => {
"Relationships and constraints"
}
FacetSelector::Member { .. } | FacetSelector::Order(_) => "Membership and order",
FacetSelector::Child(_)
| FacetSelector::Incident { .. }
| FacetSelector::Artifact { .. } => "Attached records",
}
}
pub(super) fn title(project: &Project, facet: &FacetSelector) -> String {
let keys = |keys: &KeyPair<Id>| {
format!(
"{} ↔ {}",
keys.oa
.map(|id| choice(project, id).label)
.unwrap_or_else(|| "Absent".into()),
keys.sa
.map(|id| choice(project, id).label)
.unwrap_or_else(|| "Absent".into())
)
};
match facet {
FacetSelector::Member {
collection,
keys: pair,
} => format!("{} · {}", words(&format!("{collection:?}")), keys(pair)),
FacetSelector::Order(collection) => format!("{} order", words(&format!("{collection:?}"))),
FacetSelector::Literal(pair) => format!(
"Literal · {} ↔ {}",
pair.oa.as_deref().unwrap_or("Absent"),
pair.sa.as_deref().unwrap_or("Absent")
),
FacetSelector::Annotation(pair) => format!(
"Message value · {} ↔ {}",
pair.oa
.as_ref()
.map(|key| annotation_title(project, key))
.unwrap_or_else(|| "Absent".into()),
pair.sa
.as_ref()
.map(|key| annotation_title(project, key))
.unwrap_or_else(|| "Absent".into())
),
FacetSelector::Binding(pair) => format!("Lifeline binding · {}", keys(pair)),
FacetSelector::Child(pair) => format!("Owned child · {}", keys(pair)),
FacetSelector::Incident { keys: pair, end } => format!("Incident {end:?} · {}", keys(pair)),
FacetSelector::Artifact { keys: pair, facet } => {
format!("Artifact {} · {}", words(&format!("{facet:?}")), keys(pair))
}
_ => {
let json = serde_json::to_value(facet).expect("Typed selector");
let value = json
.as_object()
.expect("Family")
.values()
.next()
.expect("Leaf");
words(value.as_str().unwrap_or("Property"))
}
}
}
fn annotation_title(project: &Project, key: &AnnotationKey) -> String {
key.path
.iter()
.chain(std::iter::once(&key.field))
.map(|id| choice(project, *id).label)
.collect::<Vec<_>>()
.join(" / ")
}
fn drift(drift: Drift) -> &'static str {
match drift {
Drift::Unreviewed => "Unreviewed",
Drift::Unchanged => "Unchanged since review",
Drift::OaChanged => "Operational changed",
Drift::SaChanged => "System changed",
Drift::BothChanged => "Both changed",
}
}
pub(crate) fn summary(project: &Project, trace: &Trace) -> String {
let references = BTreeMap::from([(trace.oa, trace.sa)]);
match trace_semantic_drift(project, trace, &references) {
Ok(value) => format!(
"Semantics: {}{} · Linked: {}{}",
drift(value.own),
if value.own_unreviewed && value.own != Drift::Unreviewed {
" · incomplete review"
} else {
""
},
drift(value.linked),
if value.linked_unreviewed && value.linked != Drift::Unreviewed {
" · incomplete review"
} else {
""
}
),
Err(error) => format!("Semantic review unavailable: {error}"),
}
}
fn formatted(project: &Project, value: &Value, references: &BTreeSet<Id>) -> String {
match value {
Value::Null => "Not set".into(),
Value::Bool(value) => if *value { "Yes" } else { "No" }.into(),
Value::Number(value) => value.to_string(),
Value::String(value) => {
if let Ok(id) = value.parse::<Id>()
&& references.contains(&id)
{
choice(project, id).label
} else if value.is_empty() {
"Empty text".into()
} else {
format!("“{value}”")
}
}
Value::Array(values) if values.is_empty() => "Empty list (0 members)".into(),
Value::Array(values) => values
.iter()
.enumerate()
.map(|(i, v)| format!("{}. {}", i + 1, formatted(project, v, references)))
.collect::<Vec<_>>()
.join("\n"),
Value::Object(values) => values
.iter()
.map(|(key, value)| {
format!("{}: {}", words(key), formatted(project, value, references))
})
.collect::<Vec<_>>()
.join("\n"),
}
}
fn observed(project: &Project, fact: Option<&FacetReview>, layer: Layer) -> String {
let Some(fact) = fact else {
return "Unreviewed — no previous observation".into();
};
let value = scalar::observation(fact, layer);
if value.as_str() == Some("NotApplicable") {
"Not applicable".into()
} else {
formatted(project, &value["Value"], &scalar::references(fact))
}
}
impl State {
pub fn view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
let trace = self.trace.and_then(|id| project.traces.get(&id));
let mut traces: Vec<_> = project
.traces
.values()
.filter(|t| t.oa == self.anchor || t.sa == self.anchor)
.map(|t| Choice {
id: t.id,
label: format!(
"{} · {:?} · {} ↔ {} · @{}",
t.name,
t.kind,
project.named(t.oa),
project.named(t.sa),
t.alias
),
})
.collect();
traces.sort_by(|a, b| a.label.cmp(&b.label));
let selected_trace = traces.iter().find(|c| Some(c.id) == self.trace).cloned();
let direction = if self.direction == TransitionDirection::OaToSa {
"Use Operational changes in System"
} else {
"Use System changes in Operational"
};
let mut content = column![
text("Review semantics").size(22),
row![
pick_list(traces, selected_trace, |c| event(Event::Trace(c))).placeholder("Choose a trace / review history").text_size(12).padding(7).width(Fill),
action(direction, Event::Direction(if self.direction == TransitionDirection::OaToSa { TransitionDirection::SaToOa } else { TransitionDirection::OaToSa })),
].spacing(8),
text("Changing trace or direction starts a new review draft. Other pending edits remain separate.").size(10).color(style::MUTED),
row![action("Properties", Event::Tab(Tab::Facts)), action(format!("Reference pairs ({})", self.references.len()), Event::Tab(Tab::References)), action(format!("Additional changes ({})", self.plan.as_ref().map_or(0, |p| p.impacts.len())), Event::Tab(Tab::Impacts)), Space::new().width(Fill), action("Refresh preview", Event::Preview)].spacing(6),
].spacing(8);
let body = match self.tab {
Tab::Facts => self.facts_view(project),
Tab::References => self.references_view(project),
Tab::Impacts => self.impacts_view(project),
};
content = content.push(body);
if let Some(error) = &self.error {
content = content.push(
text(error)
.size(12)
.color(iced::Color::from_rgb8(178, 54, 58)),
);
}
if let Some(plan) = &self.plan {
for error in &plan.conflicts {
content = content.push(
text(error)
.size(12)
.color(iced::Color::from_rgb8(178, 54, 58)),
);
}
}
let unreviewed = self
.facts
.iter()
.filter(|f| f.drift == Drift::Unreviewed)
.count();
content = content.push(text(format!("{} selected decisions · {unreviewed} currently unreviewed facts · Constructors and structured collections require further review.", self.decisions.len())).size(10).color(style::MUTED));
if let Some(trace) = trace {
content = content.push(text(summary(project, trace)).size(10).color(style::MUTED));
}
content
.push(
row![
text(
"Apply updates the selected history and model together; Undo restores both."
)
.size(10)
.width(Fill),
button(text("Cancel").size(12))
.padding(8)
.style(style::nav)
.on_press(Message::Workflow(crate::workflow::Event::Cancel)),
button(text("Apply reviewed changes").size(12))
.padding(8)
.on_press_maybe(
self.can_apply()
.then_some(Message::Workflow(crate::workflow::Event::Apply))
),
]
.spacing(8)
.align_y(iced::Center),
)
.into()
}
fn facts_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
let mut list = column![
checkbox(self.show_unchanged)
.label("Include unchanged facts")
.text_size(11)
.on_toggle(|v| event(Event::ShowUnchanged(v)))
]
.spacing(5);
let mut groups: BTreeMap<&str, Vec<&FacetDrift>> = BTreeMap::new();
for fact in &self.facts {
if self.show_unchanged
|| fact.drift != Drift::Unchanged
|| self.decisions.contains_key(&fact.selector)
|| self.selected.as_ref() == Some(&fact.selector)
{
groups.entry(group(&fact.selector)).or_default().push(fact);
}
}
for (group, facts) in groups {
list = list.push(text(group).size(12).color(style::TEAL));
for fact in facts {
let mark = match self.decisions.get(&fact.selector) {
Some(SemanticDecision::KeepTarget) => "Keep · ",
Some(SemanticDecision::TakeSource) => "Take · ",
Some(SemanticDecision::Candidate) => "Final value · ",
None => "",
};
list = list.push(
button(
column![
text(short(&title(project, &fact.selector), 52)).size(12),
text(format!("{mark}{}", drift(fact.drift))).size(10)
]
.spacing(3),
)
.padding(7)
.width(Fill)
.style(if self.selected.as_ref() == Some(&fact.selector) {
style::selected
} else {
style::nav
})
.on_press(event(Event::Select(fact.selector.clone()))),
);
}
}
row![
scrollable(list).height(305).width(260),
scrollable(self.detail(project)).height(305).width(Fill)
]
.spacing(12)
.into()
}
fn detail<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
let Some(fact) = self
.selected
.as_ref()
.and_then(|s| self.facts.iter().find(|f| &f.selector == s))
else {
return text("Choose a trace and property to compare.")
.size(13)
.into();
};
let trace = self.trace.and_then(|id| project.traces.get(&id));
let previous = trace
.and_then(|t| t.baseline.as_ref())
.and_then(|b| b.semantic.as_ref())
.and_then(|b| b.facets.iter().find(|f| f.selector() == fact.selector));
let side = |layer, label: &str| {
column![
text(format!(
"{label}{}",
if layer == self.direction.target_layer() {
" · destination"
} else {
" · source"
}
))
.size(12)
.color(style::TEAL),
text("Previously reviewed").size(10).color(style::MUTED),
text(observed(project, previous, layer)).size(12),
text("Current").size(10).color(style::MUTED),
text(observed(project, Some(&fact.current), layer)).size(12),
]
.spacing(5)
.width(Fill)
};
let mut detail = column![
text(title(project, &fact.selector)).size(17),
text(drift(fact.drift)).size(11),
row![side(Layer::Oa, "Operational"), side(Layer::Sa, "System")].spacing(14)
]
.spacing(10);
if let Some(proposed) = &self.proposed
&& let Some(trace) = trace
&& let Ok(after) = capture_facet(proposed, trace.oa, trace.sa, &fact.selector)
{
detail = detail
.push(
text("Proposed destination value · validated preview")
.size(11)
.color(style::TEAL),
)
.push(
text(observed(
proposed,
Some(&after),
self.direction.target_layer(),
))
.size(12),
);
} else {
detail = detail.push(
text("No validated final preview. Resolve the messages below before applying.")
.size(11)
.color(style::MUTED),
);
}
if self.editable(project, &fact.selector) {
let destination = if self.direction.target_layer() == Layer::Sa {
"System"
} else {
"Operational"
};
let source = if self.direction.source_layer() == Layer::Oa {
"Operational"
} else {
"System"
};
let mut buttons = row![
action(
format!("Keep {destination}"),
Event::Decide(fact.selector.clone(), Some(SemanticDecision::KeepTarget))
),
action(
format!("Take {source}"),
Event::Decide(fact.selector.clone(), Some(SemanticDecision::TakeSource))
)
]
.spacing(5);
buttons = buttons.push(action(
"Set final value…",
Event::Decide(fact.selector.clone(), Some(SemanticDecision::Candidate)),
));
detail = detail.push(buttons).push(action(
"Leave unselected",
Event::Decide(fact.selector.clone(), None),
));
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate)
&& let Some(draft) = &self.draft
&& let Some(path) = self
.target(project)
.and_then(|id| scalar::binding(project, id, &fact.selector))
{
detail = detail.push(text("Final destination value").size(12)).push(
crate::forms::scoped(draft, project, &path, &title(project, &fact.selector))
.map(form_event),
);
}
} else if self.can_acknowledge_constructor(project, &fact.selector) {
detail = detail.push(text("Both constructor kinds are equal and have no prior observation. Acknowledge only the kind; its properties remain independent.").size(12))
.push(action("Acknowledge equal kind only", Event::Decide(fact.selector.clone(), Some(SemanticDecision::KeepTarget))))
.push(action("Leave unselected", Event::Decide(fact.selector.clone(), None)));
} else {
detail = detail.push(text(if scalar::grouped(&fact.selector) { "Constructor groups are shown for comparison. Group replacement and its complete descendant review are a later milestone." } else { "This structured or inapplicable fact is read-only here. Its review history is retained." }).size(12).color(style::MUTED));
}
if matches!(
fact.selector,
FacetSelector::Child(_)
| FacetSelector::Incident { .. }
| FacetSelector::Artifact { .. }
) {
detail = detail.push(text("Attached facts are separate from referenced definitions. Removing membership never implies deleting a semantic object.").size(11).color(style::MUTED));
}
detail.into()
}
fn references_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
let mut content = column![
text("Reference correspondence · always Operational → System").size(15),
text("Choose explicit counterpart identities before taking reference values. Names do not establish correspondence. Changing an already reviewed keyed pair requires reset in the structured workflow.").size(12),
text_input("Filter reference choices by name or alias", &self.reference_search).on_input(|v| event(Event::ReferenceSearch(v))).padding(7).size(12),
row![
pick_list(self.all_references(project, Layer::Oa), self.reference_oa.clone(), |c| event(Event::ReferenceOa(c))).placeholder("Operational reference").padding(7).text_size(12).width(Fill),
pick_list(self.all_references(project, Layer::Sa), self.reference_sa.clone(), |c| event(Event::ReferenceSa(c))).placeholder("System reference").padding(7).text_size(12).width(Fill),
action("Use pair", Event::AddReference),
].spacing(7),
].spacing(9);
for (oa, sa) in &self.references {
content = content.push(
row![
text(format!(
"{}\n↔ {}",
choice(project, *oa).label,
choice(project, *sa).label
))
.size(12)
.width(Fill),
action("Remove pair", Event::RemoveReference(*oa))
]
.spacing(6),
);
}
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
content = content
.push(text("Referenced definitions · reviewed on their own traces").size(14));
match linked_semantic_impacts(project, trace, &self.references) {
Ok(impacts) => {
for impact in impacts {
content = content.push(
text(format!(
"{} ↔ {} · {}{} · {} trace(s)",
impact
.objects
.oa
.map(|id| choice(project, id).label)
.unwrap_or_else(|| "Absent".into()),
impact
.objects
.sa
.map(|id| choice(project, id).label)
.unwrap_or_else(|| "Absent".into()),
drift(impact.drift),
if impact.unreviewed {
" · incomplete review"
} else {
""
},
impact.traces.len()
))
.size(12),
);
}
}
Err(error) => content = content.push(text(error).size(12)),
}
}
scrollable(content).height(305).into()
}
fn impacts_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
let mut content = column![text("Additional prepared changes").size(16), text("Accept each exact change required by model preparation. Acceptance never overrides a selected Keep or Take. Editing a decision clears these approvals.").size(12)].spacing(10);
if let Some(plan) = &self.plan {
if plan.impacts.is_empty() {
content =
content.push(text("No additional prepared changes in this preview.").size(12));
}
for (index, impact) in plan.impacts.iter().enumerate() {
let before = impact
.before
.as_ref()
.map(|r| serde_json::to_value(r).expect("Typed impact"));
let after = impact
.after
.as_ref()
.map(|r| serde_json::to_value(r).expect("Typed impact"));
let ids = project.identities().into_iter().map(|(id, _)| id).collect();
content = content.push(
container(
column![
checkbox(self.accepted_impacts.contains(impact))
.label(format!("Accept · {}", project.named(impact.object)))
.text_size(12)
.on_toggle(move |accepted| event(Event::AcceptImpact(
index, accepted
))),
text("Before").size(11).color(style::MUTED),
text(
before
.as_ref()
.map(|v| formatted(project, v, &ids))
.unwrap_or_else(|| "Absent · record creation".into())
)
.size(12),
text("After").size(11).color(style::MUTED),
text(
after
.as_ref()
.map(|v| formatted(project, v, &ids))
.unwrap_or_else(|| "Absent · record deletion".into())
)
.size(12),
]
.spacing(6),
)
.padding(10)
.style(style::subtle),
);
}
}
scrollable(content).height(305).into()
}
}
+4 -1
View File
@@ -250,7 +250,10 @@ fn opening_legacy_source_surfaces_report_without_modifying_original_bytes() {
assert_eq!(app.page, Page::Validation);
assert_eq!(app.project(), &original);
assert!(!app.dirty());
assert!(app.source_snapshot.starts_with("arcadia 4;"));
assert!(
app.source_snapshot
.starts_with(&format!("arcadia {SCHEMA_VERSION};"))
);
assert!(serde_json::from_slice::<serde_json::Value>(&bytes).unwrap()["schema_version"] == 2);
}
+41 -22
View File
@@ -55,22 +55,14 @@ pub fn view(app: &App) -> View<'_, Message> {
]
.spacing(10)
.width(220),
column![
text(short(&p.name, 46)).size(17),
label(if app.dirty() {
"Unsaved changes"
} else if app.path.is_some() {
"All changes saved"
} else {
"Local project"
})
]
.spacing(3)
.width(Fill),
column![text(short(&p.name, 46)).size(17), label(app.hosted_label())]
.spacing(3)
.width(Fill),
action("New", Message::Request(Pending::New)),
crate::hosted::view::header_action(),
action("Open", Message::Request(Pending::Open)),
action("Save as", Message::Save(true)),
button(text("Save all").size(12))
button(text(app.save_label()).size(12))
.padding([10, 16])
.on_press(Message::Save(false))
]
@@ -247,6 +239,13 @@ pub fn view(app: &App) -> View<'_, Message> {
.style(style::panel);
let root: View<'_, Message> = column![header, task_nav, body, footer].into();
if let Some(workflow) = &app.workflow {
if matches!(workflow, crate::workflow::Workflow::Semantic(_)) {
return modal_width(
root,
workflow.view(p),
(app.window_size.width - 50.).clamp(800., 1120.),
);
}
return modal(root, workflow.view(p));
}
if let Some(p) = &app.file_prompt {
@@ -299,15 +298,27 @@ pub fn view(app: &App) -> View<'_, Message> {
root
}
fn modal<'a>(base: View<'a, Message>, content: View<'a, Message>) -> View<'a, Message> {
modal_width(base, content, 800.)
}
fn modal_width<'a>(
base: View<'a, Message>,
content: View<'a, Message>,
width: f32,
) -> View<'a, Message> {
widget::stack![
base,
widget::opaque(
container(container(content).padding(26).width(800).style(style::card))
.center(Fill)
.style(|_| container::Style {
background: Some(iced::Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()),
..Default::default()
})
container(
container(content)
.padding(26)
.width(width)
.style(style::card)
)
.center(Fill)
.style(|_| container::Style {
background: Some(iced::Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()),
..Default::default()
})
)
]
.into()
@@ -714,10 +725,14 @@ fn traces(app: &App) -> View<'_, Message> {
"Connect operational needs, system responsibilities and development evidence."
),
row![
button(text("Create / update counterpart…").size(12))
button(text("Create counterpart / review prose…").size(12))
.padding(9)
.on_press(Message::StartTransition),
action("+ Manual trace", Message::AddTrace),
action(
"Review semantics…",
Message::StartSemanticReview(app.selected)
),
action("+ Artifact", Message::AddArtifact)
]
.spacing(8)
@@ -740,10 +755,14 @@ fn traces(app: &App) -> View<'_, Message> {
.spacing(8)
.align_y(iced::Center),
row![
label(format!("{:?} · {current}", t.kind)),
label(format!("{:?} · Prose: {current}", t.kind)),
Space::new().width(Fill),
action("Review link", Message::Select(t.id))
action("Review semantics…", Message::StartSemanticReview(t.id)),
action("Edit link", Message::Select(t.id))
],
text(crate::semantic_review::summary(p, t))
.size(11)
.color(style::MUTED),
text(short(&t.description, 130)).size(12)
]
.spacing(10),
+13 -1
View File
@@ -15,6 +15,7 @@ use std::collections::BTreeSet;
#[derive(Debug, Clone)]
pub enum Event {
Semantic(crate::semantic_review::Event),
Port(crate::port_workflow::Event),
ExchangeItem(Id, bool),
Endpoint(bool, EndpointChoice),
@@ -33,6 +34,7 @@ pub enum Event {
Preview,
}
pub enum Workflow {
Semantic(Box<crate::semantic_review::State>),
Ports(Box<crate::port_workflow::State>),
Relation(Box<Relationship>),
ManualTrace {
@@ -333,6 +335,11 @@ impl Workflow {
/// choices and previews never mutate the Session.
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Project>, String> {
match self {
Self::Semantic(_) => {
return Err(
"Semantic review commits its exact command batch through Document.".into(),
);
}
Self::Ports(state) => return state.update(p, event),
Self::ManualTrace {
name,
@@ -571,6 +578,7 @@ impl Workflow {
}
pub fn title(&self) -> &'static str {
match self {
Self::Semantic(_) => "Review semantics",
Self::Ports(state) => state.title(),
Self::ManualTrace { .. } => "Link existing counterparts",
Self::Relation(_) => "Create relationship",
@@ -584,6 +592,9 @@ impl Workflow {
}
}
pub fn view<'a>(&'a self, p: &'a Project) -> Element<'a, Message> {
if let Self::Semantic(state) = self {
return state.view(p);
}
if let Self::Ports(state) = self {
return state.view(p);
}
@@ -594,7 +605,7 @@ impl Workflow {
None
};
match self {
Self::Ports(_) => unreachable!("Port dialog returned above"),
Self::Ports(_) | Self::Semantic(_) => unreachable!("Dedicated dialog returned above"),
Self::ManualTrace {
name,
source,
@@ -1073,6 +1084,7 @@ impl Workflow {
}
}
let enabled = match self {
Self::Semantic(_) => false,
Self::Ports(_) => false,
Self::ManualTrace { name, target, .. } => target.is_some() && !name.trim().is_empty(),
Self::Relation(_) => relation_plan.as_ref().is_some_and(Result::is_ok),
+170
View File
@@ -72,6 +72,18 @@ pub(crate) fn router() -> Router<AppState> {
"/api/v1/projects/{id}/transitions/apply",
post(commit_transition),
)
.route(
"/api/v1/projects/{id}/semantic-transitions/query",
post(query_semantic_transitions),
)
.route(
"/api/v1/projects/{id}/semantic-transitions/preview",
post(preview_semantic_transition),
)
.route(
"/api/v1/projects/{id}/semantic-transitions/apply",
post(commit_semantic_transition),
)
.fallback(|| async {
ApiError::new(
StatusCode::NOT_FOUND,
@@ -1089,6 +1101,164 @@ struct TransitionApplyRequest {
plan: oa_core::transitions::TransitionPlan,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SemanticQueryRequest {
traces: Vec<Id>,
#[serde(default)]
references: BTreeMap<Id, Id>,
revision: Option<u64>,
}
fn semantic_rejected(error: impl std::fmt::Display) -> ApiError {
ApiError::new(
StatusCode::UNPROCESSABLE_ENTITY,
"semantic_transition_rejected",
error.to_string(),
)
}
async fn query_semantic_transitions(
State(state): State<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
let id = path(id)?;
media(&headers, "application/json")?;
let body = bytes(body)?;
blocking(state, move |store| {
let request: SemanticQueryRequest = json_body(&body)?;
if request.traces.is_empty() || request.traces.len() > 100 || request.references.len() > 5_000 {
return Err(ApiError::bad_request("Semantic queries require 1–100 trace IDs and at most 5,000 reference mappings."));
}
let selected: BTreeSet<_> = request.traces.iter().copied().collect();
if selected.len() != request.traces.len() {
return Err(ApiError::bad_request("Semantic query trace IDs must be unique."));
}
let current = store.get(id)?;
check_read_revision(&current, request.revision)?;
let mut items = Vec::new();
for trace_id in request.traces {
let trace = current.model.traces.get(&trace_id).ok_or_else(|| {
ApiError::new(StatusCode::NOT_FOUND, "trace_not_found", "Selected trace does not exist.")
})?;
let facets = oa_core::semantic::semantic_facets(&current.model, trace, &request.references)
.map_err(semantic_rejected)?;
let drift = oa_core::semantic::trace_semantic_drift(&current.model, trace, &request.references)
.map_err(semantic_rejected)?;
let linked_impacts = oa_core::semantic::linked_semantic_impacts(&current.model, trace, &request.references)
.map_err(semantic_rejected)?;
items.push(json!({"trace":trace.id,"oa":trace.oa,"sa":trace.sa,"facets":facets,"drift":drift,"linked_impacts":linked_impacts}));
}
Ok(response(&current, StatusCode::OK, json!({"revision":current.revision,"items":items})))
}).await
}
fn check_semantic_request(request: &oa_core::semantic::SemanticTransitionRequest) -> ApiResult<()> {
let transition = &request.transition;
let decisions = request.reviews.iter().try_fold(0usize, |count, review| {
count
.checked_add(review.resolutions.len())?
.checked_add(review.reset.len())
});
if transition.sources.len() > 100
|| transition.mappings.len() > 200
|| transition.resolutions.len() > 400
|| request.traces.len() > 100
|| request.references.len() > 5_000
|| request.reviews.len() > 100
|| decisions.is_none_or(|count| count > 5_000)
|| request.candidates.len() > 200
|| request.accepted_impacts.len() > 200
{
return Err(ApiError::bad_request(
"Semantic transitions allow at most 100 sources/traces/reviews, 200 mappings/candidates/accepted impacts, 400 prose resolutions and 5,000 reference mappings or combined facet resolutions/resets.",
));
}
Ok(())
}
async fn preview_semantic_transition(
State(state): State<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
let id = path(id)?;
let expected = expected(&headers)?;
media(&headers, "application/json")?;
let body = bytes(body)?;
blocking(state, move |store| {
let request: oa_core::semantic::SemanticTransitionRequest = json_body(&body)?;
check_semantic_request(&request)?;
let current = store.checked(id, &expected)?;
let plan = oa_core::semantic::plan_semantic_transition(&current.model, request)
.map_err(semantic_rejected)?;
if !plan.commands.is_empty() {
check_commands(&plan.commands)?;
}
if plan.conflicts.is_empty() {
let candidate = oa_core::semantic::prepare_semantic_transition(&current.model, &plan)
.map_err(semantic_rejected)?;
PreparedProject::from_command(&current, candidate)?;
}
Ok(response(
&current,
StatusCode::OK,
json!({"base_revision":current.revision,"plan":plan}),
))
})
.await
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SemanticApplyRequest {
base_revision: u64,
plan: oa_core::semantic::SemanticTransitionPlan,
}
async fn commit_semantic_transition(
State(state): State<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
let id = path(id)?;
let expected = expected(&headers)?;
media(&headers, "application/json")?;
let body = bytes(body)?;
blocking(state, move |store| {
let request: SemanticApplyRequest = json_body(&body)?;
let current = store.checked(id, &expected)?;
if current.revision != request.base_revision {
let mut error = ApiError::new(
StatusCode::PRECONDITION_FAILED,
"plan_revision_conflict",
"Semantic preview revision changed. Create a new preview.",
);
error.current_revision = Some(current.revision);
return Err(error);
}
check_semantic_request(&request.plan.request)?;
if !request.plan.commands.is_empty() {
check_commands(&request.plan.commands)?;
}
let candidate =
oa_core::semantic::prepare_semantic_transition(&current.model, &request.plan)
.map_err(semantic_rejected)?;
let prepared = PreparedProject::from_command(&current, candidate)?;
let (stored, changed) = store.replace(id, &expected, prepared)?;
Ok(response(
&stored,
StatusCode::OK,
json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model)}),
))
})
.await
}
async fn commit_transition(
State(state): State<AppState>,
id: ProjectPath,
+3 -1
View File
@@ -12,6 +12,8 @@ use tokio::{sync::oneshot, task::JoinHandle};
mod data_contracts;
#[path = "http/port_anchors.rs"]
mod port_anchors;
#[path = "http/semantic_transitions.rs"]
mod semantic_transitions;
#[path = "http/source_projects.rs"]
mod source_projects;
@@ -2002,7 +2004,7 @@ async fn current_migration_reports_and_invalid_typed_transaction_preserve_http_s
.text()
.await
.unwrap();
assert!(source.starts_with("arcadia 4;"));
assert!(source.starts_with("arcadia 5;"));
assert_eq!(oa_core::language::parse(&source).unwrap(), stored.model);
let no_op = server
.client
@@ -185,7 +185,7 @@ async fn published_data_contract_queries_atomic_edits_identity_and_restart() {
.text()
.await
.unwrap();
assert!(source.starts_with("arcadia 4;"));
assert!(source.starts_with("arcadia 5;"));
assert_eq!(oa_core::language::parse(&source).unwrap(), removed.model);
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
@@ -218,7 +218,7 @@ async fn authored_schema3_bundle_upgrade_reports_data_defaults_and_preserves_fil
let resource = summary["resource_id"].as_str().unwrap().parse().unwrap();
let stored = server.get(resource).await;
let report = stored.migration.as_ref().unwrap();
assert_eq!((report.from_schema, report.to_schema), (3, 4));
assert_eq!((report.from_schema, report.to_schema), (3, 5));
assert_eq!(report.generated_interface_allocations.len(), 1);
assert!(!report.semantic_defaults.is_empty());
let validated: Value = server
@@ -241,7 +241,7 @@ async fn authored_schema3_bundle_upgrade_reports_data_defaults_and_preserves_fil
1
);
assert_eq!(stored.source_project.files.len(), 2);
assert!(stored.source_project.files["legacy.arcadia"].contains("arcadia 4;"));
assert!(stored.source_project.files["legacy.arcadia"].contains("arcadia 5;"));
for text in [
"// Event rationale",
"// Interface rationale",
@@ -283,9 +283,9 @@ async fn existing_schema3_coupled_envelope_upgrades_in_memory_until_explicit_sav
let server = TestServer::start(directory.path(), None).await;
let opened = server.get(resource).await;
assert_eq!(opened.revision, 17);
assert_eq!(opened.model.schema_version, 4);
assert_eq!(opened.model.schema_version, 5);
assert_eq!(opened.migration.as_ref().unwrap().from_schema, 3);
assert!(opened.source_project.files["project.arcadia"].contains("arcadia 4;"));
assert!(opened.source_project.files["project.arcadia"].contains("arcadia 5;"));
assert_pair(&opened);
assert_eq!(std::fs::read(&filename).unwrap(), original);
assert_eq!(
@@ -717,7 +717,7 @@ async fn qualified_call_reply_values_reject_invalid_edits_and_survive_identity_s
.text()
.await
.unwrap();
assert!(source.starts_with("arcadia 4;"));
assert!(source.starts_with("arcadia 5;"));
assert_eq!(oa_core::language::parse(&source).unwrap(), changed.model);
let saved = std::fs::read(&filename).unwrap();
server.stop().await;
@@ -0,0 +1,675 @@
use super::*;
use oa_core::{semantic::*, transitions::*, *};
fn fixture() -> Project {
oa_core::persistence::decode(include_bytes!(
"../../../oa-core/tests/fixtures/v4-reviewed-prose.json"
))
.unwrap()
}
fn key(n: u128) -> Id {
Id::from_u128(0x40000000_0000_4000_8000_000000000000 + n)
}
fn request(
source: Id,
target: Id,
decisions: Vec<(FacetSelector, SemanticDecision)>,
) -> SemanticTransitionRequest {
let direction = if source == key(3) {
TransitionDirection::SaToOa
} else {
TransitionDirection::OaToSa
};
let mut transition = TransitionRequest::new(direction, vec![source]);
transition.mappings.push(TransitionMapping {
source,
target: Some(target),
target_kind: None,
});
let mut request = SemanticTransitionRequest::new(transition);
request.reviews.push(SemanticReviewRequest {
source,
resolutions: decisions
.into_iter()
.map(|(facet, decision)| FacetResolution { facet, decision })
.collect(),
reset: vec![],
});
request
}
async fn post(
server: &TestServer,
id: Id,
operation: &str,
etag: Option<&str>,
value: &impl serde::Serialize,
) -> reqwest::Response {
let mut call = server
.client
.post(server.url(&format!("/projects/{id}/semantic-transitions/{operation}")))
.json(value);
if let Some(etag) = etag {
call = call.header("if-match", etag);
}
call.send().await.unwrap()
}
async fn ok(response: reqwest::Response) -> Value {
let status = response.status();
let value = response.json().await.unwrap();
assert_eq!(status, StatusCode::OK, "{value}");
value
}
async fn preview(
server: &TestServer,
stored: &StoredProject,
request: &SemanticTransitionRequest,
) -> Value {
ok(post(
server,
stored.resource_id,
"preview",
Some(&stored.etag()),
request,
)
.await)
.await
}
fn assert_pair(stored: &StoredProject) {
assert_eq!(
oa_core::source_project::compile_bundle(&stored.source_project, Default::default())
.unwrap()
.decoded
.project,
stored.model
);
}
#[tokio::test]
async fn semantic_partial_review_query_source_and_restart_share_one_revision() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let (id, _) = server.import(&fixture()).await;
let mut stored = server.get(id).await;
let mut bundle = stored.source_project.clone();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.insert_str(0, "// Authored review rationale\n");
let changed = server
.client
.put(server.url(&format!("/projects/{id}/source-project")))
.header("if-match", stored.etag())
.json(&bundle)
.send()
.await
.unwrap();
assert_eq!(changed.status(), StatusCode::OK);
stored = server.get(id).await;
let disk_path = directory.path().join(format!("{id}.json"));
let before_bytes = std::fs::read(&disk_path).unwrap();
let query = json!({"traces":[key(4)],"references":{},"revision":stored.revision});
let queried = ok(post(&server, id, "query", None, &query).await).await;
assert_eq!(queried["items"][0]["drift"]["own"], "Unreviewed");
let req = request(
key(2),
key(3),
vec![
(
FacetSelector::Data(DataFacet::Unit),
SemanticDecision::KeepTarget,
),
(
FacetSelector::Header(HeaderFacet::Name),
SemanticDecision::TakeSource,
),
],
);
let plan = preview(&server, &stored, &req).await;
assert_eq!(plan["plan"]["conflicts"], json!([]));
assert_eq!(
plan["plan"]["reviews"][0]["acknowledged"]
.as_array()
.unwrap()
.len(),
2
);
assert_eq!(std::fs::read(&disk_path).unwrap(), before_bytes);
assert_eq!(server.get(id).await.etag(), stored.etag());
let applied = ok(post(&server, id, "apply", Some(&stored.etag()), &plan).await).await;
assert_eq!(applied["revision"], stored.revision + 1);
let committed = server.get(id).await;
assert_pair(&committed);
assert!(
committed.source_project.files[&committed.source_project.entry]
.contains("// Authored review rationale")
);
assert_eq!(committed.model.elements[&key(3)].name, "OA dimension now");
assert!(
matches!(committed.model.elements[&key(3)].shared(), Some(SharedBody::DataType { unit, definition: DataTypeDefinition::Real { .. }, .. }) if unit == "ft")
);
let queried = ok(post(&server, id, "query", None, &json!({"traces":[key(4)]})).await).await;
let facts: Vec<FacetDrift> =
serde_json::from_value(queried["items"][0]["facets"].clone()).unwrap();
assert_eq!(
facts
.iter()
.find(|f| f.selector == FacetSelector::Data(DataFacet::Unit))
.unwrap()
.drift,
Drift::Unchanged
);
assert_eq!(
facts
.iter()
.find(|f| f.selector == FacetSelector::Data(DataFacet::Constructor))
.unwrap()
.drift,
Drift::Unreviewed
);
assert!(
queried["items"][0]["drift"]["own_unreviewed"]
.as_bool()
.unwrap()
);
let repeat = preview(&server, &committed, &req).await;
let reapplied = ok(post(&server, id, "apply", Some(&committed.etag()), &repeat).await).await;
assert_eq!(reapplied["changed"], false);
assert_eq!(reapplied["revision"], committed.revision);
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
let reopened = server.get(id).await;
assert_eq!(reopened.model, committed.model);
assert_eq!(reopened.source_project, committed.source_project);
assert_eq!(reopened.etag(), committed.etag());
server.stop().await;
}
#[tokio::test]
async fn semantic_strict_rejection_stale_plans_and_source_only_changes_never_write() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let (id, etag) = server.import(&fixture()).await;
let stored = server.get(id).await;
let disk_path = directory.path().join(format!("{id}.json"));
let before = std::fs::read(&disk_path).unwrap();
let req = request(
key(2),
key(3),
vec![(
FacetSelector::Data(DataFacet::Unit),
SemanticDecision::TakeSource,
)],
);
let plan = preview(&server, &stored, &req).await;
for operation in ["preview", "apply"] {
let body = if operation == "preview" {
serde_json::to_value(&req).unwrap()
} else {
plan.clone()
};
assert_eq!(
post(&server, id, operation, None, &body).await.status(),
StatusCode::PRECONDITION_REQUIRED
);
}
let mut malformed = serde_json::to_value(&req).unwrap();
malformed["reviews"][0]["resolutions"][0]["facet"] = json!({"Data":"arbitrary_path"});
assert_eq!(
post(&server, id, "preview", Some(&etag), &malformed)
.await
.status(),
StatusCode::BAD_REQUEST
);
malformed = serde_json::to_value(&req).unwrap();
malformed["unexpected"] = true.into();
assert_eq!(
post(&server, id, "preview", Some(&etag), &malformed)
.await
.status(),
StatusCode::BAD_REQUEST
);
for (query, expected) in [
(json!({"traces":[]}), StatusCode::BAD_REQUEST),
(json!({"traces":[key(4),key(4)]}), StatusCode::BAD_REQUEST),
(json!({"traces":[key(99)]}), StatusCode::NOT_FOUND),
(
json!({"traces":[key(4)],"revision":99}),
StatusCode::CONFLICT,
),
] {
assert_eq!(
post(&server, id, "query", None, &query).await.status(),
expected
);
}
for mutation in [
"commands",
"base_fingerprint",
"prepared_fingerprint",
"base_revision",
] {
let mut rejected = plan.clone();
let expected = if mutation == "base_revision" {
rejected[mutation] = 99.into();
StatusCode::PRECONDITION_FAILED
} else {
rejected["plan"][mutation] = if mutation == "commands" {
json!([])
} else {
json!("altered")
};
StatusCode::UNPROCESSABLE_ENTITY
};
assert_eq!(
post(&server, id, "apply", Some(&etag), &rejected)
.await
.status(),
expected
);
}
let mut duplicate = req.clone();
let repeated = duplicate.reviews[0].resolutions[0].clone();
duplicate.reviews[0].resolutions.push(repeated);
let conflicted = preview(&server, &stored, &duplicate).await;
assert!(
!conflicted["plan"]["conflicts"]
.as_array()
.unwrap()
.is_empty()
);
assert_eq!(
post(&server, id, "apply", Some(&etag), &conflicted)
.await
.status(),
StatusCode::UNPROCESSABLE_ENTITY
);
assert_eq!(std::fs::read(&disk_path).unwrap(), before);
assert_eq!(server.get(id).await.etag(), etag);
let mut bundle = stored.source_project.clone();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.push_str("\n// Source-only concurrent edit\n");
let changed = server
.client
.put(server.url(&format!("/projects/{id}/source-project")))
.header("if-match", &etag)
.json(&bundle)
.send()
.await
.unwrap();
assert_eq!(changed.status(), StatusCode::OK);
let current = server.get(id).await;
assert_eq!(current.model, stored.model);
let bytes = std::fs::read(&disk_path).unwrap();
for precondition in [&etag, &current.etag()] {
assert_eq!(
post(&server, id, "apply", Some(precondition), &plan)
.await
.status(),
StatusCode::PRECONDITION_FAILED
);
}
assert_eq!(std::fs::read(&disk_path).unwrap(), bytes);
server.stop().await;
}
#[tokio::test]
async fn semantic_candidate_constructor_reverse_direction_and_explicit_trace_selection() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let mut model = fixture();
let mut second = model.traces[&key(4)].clone();
second.id = key(5);
second.alias = "second_review".into();
second.kind = TraceKind::Manual;
model.traces.insert(second.id, second);
let (id, _) = server.import(&model).await;
let stored = server.get(id).await;
let mut req = request(
key(3),
key(2),
vec![
(
FacetSelector::Data(DataFacet::Constructor),
SemanticDecision::TakeSource,
),
(
FacetSelector::Data(DataFacet::Unit),
SemanticDecision::Candidate,
),
],
);
let ambiguous = preview(&server, &stored, &req).await;
assert!(
!ambiguous["plan"]["conflicts"]
.as_array()
.unwrap()
.is_empty()
);
req.traces.insert(key(3), key(5));
let mut candidate = model.elements[&key(2)].clone();
*candidate.shared_mut().unwrap() = model.elements[&key(3)].shared().unwrap().clone();
if let SharedBody::DataType { unit, .. } = candidate.shared_mut().unwrap() {
*unit = "cm".into();
}
req.candidates
.push(SemanticRecord::Element(candidate.clone()));
req.references.insert(key(900), key(901)); // Historical correspondence survives transport unchanged.
let envelope = preview(&server, &stored, &req).await;
assert_eq!(
envelope["plan"]["request"],
serde_json::to_value(&req).unwrap()
);
assert_eq!(envelope["plan"]["conflicts"], json!([]));
ok(post(&server, id, "apply", Some(&stored.etag()), &envelope).await).await;
let applied = server.get(id).await;
assert_eq!(applied.model.elements[&key(2)], candidate);
assert!(
applied.model.traces[&key(4)]
.baseline
.as_ref()
.unwrap()
.semantic
.is_none()
);
assert!(
applied.model.traces[&key(5)]
.baseline
.as_ref()
.unwrap()
.semantic
.is_some()
);
assert_pair(&applied);
server.stop().await;
}
#[tokio::test]
async fn semantic_new_mapping_keeps_preview_generated_identifiers() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let mut model = Project::empty();
let source = model
.add_element(Kind::Activity, Layer::Oa, "Inspect", None)
.unwrap();
let (id, _) = server.import(&model).await;
let stored = server.get(id).await;
let req = SemanticTransitionRequest::new(TransitionRequest::new(
TransitionDirection::OaToSa,
vec![source],
));
let envelope = preview(&server, &stored, &req).await;
assert_eq!(envelope["plan"]["conflicts"], json!([]));
let plan: SemanticTransitionPlan = serde_json::from_value(envelope["plan"].clone()).unwrap();
let expected = oa_core::semantic::apply_semantic_transition(&model, &plan).unwrap();
ok(post(&server, id, "apply", Some(&stored.etag()), &envelope).await).await;
assert_eq!(server.get(id).await.model, expected);
server.stop().await;
}
#[tokio::test]
async fn semantic_resets_and_invalid_candidates_preserve_deliberate_review_boundaries() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let model = fixture();
let reviewed = oa_core::semantic::plan_semantic_transition(
&model,
request(
key(2),
key(3),
vec![
(
FacetSelector::Data(DataFacet::Unit),
SemanticDecision::KeepTarget,
),
(
FacetSelector::Header(HeaderFacet::Name),
SemanticDecision::KeepTarget,
),
],
),
)
.unwrap();
let model = oa_core::semantic::apply_semantic_transition(&model, &reviewed).unwrap();
let (id, _) = server.import(&model).await;
let stored = server.get(id).await;
let before = std::fs::read(directory.path().join(format!("{id}.json"))).unwrap();
let mut req = request(
key(2),
key(3),
vec![(
FacetSelector::Data(DataFacet::Unit),
SemanticDecision::Candidate,
)],
);
let mut candidate = model.elements[&key(3)].clone();
candidate.name = "Unselected incidental edit".into();
req.candidates.push(SemanticRecord::Element(candidate));
let invalid = preview(&server, &stored, &req).await;
assert!(!invalid["plan"]["conflicts"].as_array().unwrap().is_empty());
assert_eq!(
post(&server, id, "apply", Some(&stored.etag()), &invalid)
.await
.status(),
StatusCode::UNPROCESSABLE_ENTITY
);
assert_eq!(
std::fs::read(directory.path().join(format!("{id}.json"))).unwrap(),
before
);
req = request(key(2), key(3), vec![]);
req.reviews[0]
.reset
.push(FacetSelector::Data(DataFacet::Unit));
let reset = preview(&server, &stored, &req).await;
assert_eq!(
reset["plan"]["request"]["reviews"][0]["reset"],
json!([{"Data":"Unit"}])
);
assert_eq!(reset["plan"]["conflicts"], json!([]));
ok(post(&server, id, "apply", Some(&stored.etag()), &reset).await).await;
let current = server.get(id).await;
let facts = &current.model.traces[&key(4)]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap()
.facets;
assert_eq!(facts.len(), 1);
assert_eq!(
facts[0].selector(),
FacetSelector::Header(HeaderFacet::Name)
);
assert_eq!(current.model.elements, model.elements);
assert_pair(&current);
server.stop().await;
}
fn cleanup_fixture() -> (Project, Id, Id, Id) {
let mut p = Project::empty();
let oa = p
.add_element(Kind::DataType, Layer::Oa, "Source record", None)
.unwrap();
let sa = p
.add_element(Kind::DataType, Layer::Sa, "Target record", None)
.unwrap();
let base = p
.add_element(Kind::DataType, Layer::Sa, "Base record", None)
.unwrap();
for (id, supertype) in [(oa, None), (base, None), (sa, Some(base))] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype },
default: None,
unit: String::new(),
};
}
let leaf = p
.add_element(Kind::DataField, Layer::Sa, "Coordinate", Some(base))
.unwrap();
let item = p
.add_element(Kind::ExchangeItem, Layer::Sa, "Location", None)
.unwrap();
let field = p
.add_element(Kind::DataField, Layer::Sa, "Payload", Some(item))
.unwrap();
for (id, type_ref) in [(leaf, TypeRef::Real), (field, TypeRef::Named(sa))] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataField {
type_ref,
minimum: 0,
maximum: Some(1),
unit: String::new(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
};
}
let sender = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let receiver = p
.add_element(Kind::Function, Layer::Sa, "Receive", None)
.unwrap();
let exchange = p.relate(
RelationKind::FunctionalExchange,
"Location report",
sender,
receiver,
);
let RelationBody::FunctionalExchange {
items,
source_port,
target_port,
..
} = &mut p.relations.get_mut(&exchange).unwrap().body
else {
panic!()
};
items.push(item);
for id in [*source_port, *target_port] {
match p.elements.get_mut(&id).unwrap().shared_mut().unwrap() {
SharedBody::Port {
contract: PortContract::FunctionInput { incoming_items },
..
} => incoming_items.push(item),
SharedBody::Port {
contract: PortContract::FunctionOutput { outgoing_items },
..
} => outgoing_items.push(item),
_ => panic!(),
}
}
let scenario = p
.add_element(Kind::Scenario, Layer::Sa, "Report location", None)
.unwrap();
let message = p
.add_element(Kind::Occurrence, Layer::Sa, "Report", Some(scenario))
.unwrap();
*p.elements.get_mut(&message).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: Some(exchange),
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![MessageValue {
path: vec![field],
field: leaf,
expression: "10".into(),
}],
},
precondition: String::new(),
postcondition: String::new(),
};
p.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.push(message);
let trace = Id::new_v4();
p.traces.insert(
trace,
Trace {
id: trace,
alias: "record_trace".into(),
name: "Record correspondence".into(),
description: String::new(),
oa,
sa,
kind: TraceKind::Manual,
baseline: None,
},
);
(p, oa, sa, message)
}
#[tokio::test]
async fn semantic_cleanup_impacts_require_exact_acceptance_before_atomic_apply() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let (model, oa, sa, message) = cleanup_fixture();
let (id, _) = server.import(&model).await;
let stored = server.get(id).await;
let path = directory.path().join(format!("{id}.json"));
let before = std::fs::read(&path).unwrap();
let mut req = request(
oa,
sa,
vec![(
FacetSelector::Data(DataFacet::RecordSupertype),
SemanticDecision::TakeSource,
)],
);
let unresolved = preview(&server, &stored, &req).await;
assert!(
!unresolved["plan"]["conflicts"]
.as_array()
.unwrap()
.is_empty()
);
let impacts: Vec<SemanticImpact> =
serde_json::from_value(unresolved["plan"]["impacts"].clone()).unwrap();
assert_eq!(impacts.len(), 1);
assert_eq!(impacts[0].object, message);
assert_ne!(impacts[0].before, impacts[0].after);
assert_eq!(
post(&server, id, "apply", Some(&stored.etag()), &unresolved)
.await
.status(),
StatusCode::UNPROCESSABLE_ENTITY
);
let mut inaccurate = impacts.clone();
inaccurate[0].after = inaccurate[0].before.clone();
req.accepted_impacts = inaccurate;
let inaccurate = preview(&server, &stored, &req).await;
assert!(
!inaccurate["plan"]["conflicts"]
.as_array()
.unwrap()
.is_empty()
);
assert_eq!(std::fs::read(&path).unwrap(), before);
req.accepted_impacts = impacts;
let approved = preview(&server, &stored, &req).await;
assert_eq!(approved["plan"]["conflicts"], json!([]));
assert_eq!(
approved["plan"]["request"]["accepted_impacts"],
serde_json::to_value(&req.accepted_impacts).unwrap()
);
ok(post(&server, id, "apply", Some(&stored.etag()), &approved).await).await;
let current = server.get(id).await;
assert_eq!(current.revision, stored.revision + 1);
assert!(
matches!(current.model.elements[&message].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { values, .. }, .. }) if values.is_empty())
);
assert_pair(&current);
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
assert_eq!(server.get(id).await.model, current.model);
server.stop().await;
}
@@ -0,0 +1,136 @@
"""Validate actual local HTTP responses against served and checked-in OpenAPI.
Requires jsonschema. Usage: python semantic_http_contract.py /path/to/arcadia-server
The supplied binary is launched on loopback with a fresh temporary store.
"""
import copy
import json
import os
import re
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
from pathlib import Path
from jsonschema import Draft202012Validator, FormatChecker
ROOT = Path(__file__).resolve().parents[3]
local = json.loads((ROOT / "docs/openapi.json").read_text(encoding="utf-8"))
for schema in local["components"]["schemas"].values():
Draft202012Validator.check_schema(schema)
def validator(spec, name):
return Draft202012Validator({"$ref": "#/components/schemas/" + name, "components": spec["components"]},
format_checker=FormatChecker())
with tempfile.TemporaryDirectory(prefix="arcadia-semantic-http-") as directory:
env = dict(os.environ)
env.pop("ARCADIA_SERVER_TOKEN", None)
process = subprocess.Popen([sys.argv[1], "--bind", "127.0.0.1:0", "--data-dir", directory],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
try:
banner = process.stderr.readline()
address = re.search(r"listening on (127\.0\.0\.1:\d+)", banner)
assert address, banner
base = "http://" + address.group(1)
def call(method, path, body=None, etag=None, expected=200):
headers = {"Content-Type": "application/json"}
if etag:
headers["If-Match"] = etag
request = urllib.request.Request(base + path, data=None if body is None else json.dumps(body).encode(),
headers=headers, method=method)
try:
response = urllib.request.urlopen(request, timeout=15)
except urllib.error.HTTPError as error:
response = error
with response:
data = json.load(response)
assert response.status == expected, (response.status, data)
return data, response.headers.get("ETag")
served, _ = call("GET", "/openapi.json")
assert served == local, "Server binary embeds a different OpenAPI file; rebuild it"
checked = 0
def validate(name, value):
global checked
for spec in (local, served):
validator(spec, name).validate(value)
checked += 1
old = json.loads((ROOT / "crates/oa-core/tests/fixtures/v4-reviewed-prose.json").read_text())
imported, etag = call("POST", "/api/v1/projects/import", old, expected=201)
path = "/api/v1/projects/" + imported["resource_id"]
stored, _ = call("GET", path)
validate("StoredProject", stored)
validate("Project", stored["model"])
trace = next(iter(stored["model"]["traces"]))
oa = stored["model"]["traces"][trace]["oa"]
sa = stored["model"]["traces"][trace]["sa"]
query = {"traces": [trace], "references": {oa: sa}, "revision": stored["revision"]}
validate("SemanticQueryRequest", query)
queried, _ = call("POST", path + "/semantic-transitions/query", query)
validate("SemanticQueryResult", queried)
request = {"transition": {"direction": "OaToSa", "sources": [oa], "scope": "SelectedOnly",
"mappings": [{"source": oa, "target": sa, "target_kind": None}], "fields": [],
"resolutions": [], "destination_diagram": None}, "traces": {oa: trace},
"references": {}, "reviews": [{"source": oa, "resolutions": [
{"facet": {"Header": "Name"}, "decision": "TakeSource"},
{"facet": {"Data": "Unit"}, "decision": "KeepTarget"}], "reset": []}],
"candidates": [], "accepted_impacts": []}
validate("SemanticTransitionRequest", request)
one_sided = copy.deepcopy(request)
one_sided["reviews"][0]["resolutions"] = [{"facet": {"Literal": {"oa": "Only source"}}, "decision": "KeepTarget"}]
validate("SemanticTransitionRequest", one_sided)
admitted, _ = call("POST", path + "/semantic-transitions/preview", one_sided, etag)
validate("SemanticTransitionEnvelope", admitted)
assert admitted["plan"]["conflicts"] # Valid typed input, inapplicable to the integer/real fixture.
preview, _ = call("POST", path + "/semantic-transitions/preview", request, etag)
validate("SemanticTransitionEnvelope", preview)
assert not preview["plan"]["conflicts"], preview
result, updated_etag = call("POST", path + "/semantic-transitions/apply", preview, etag)
validate("MutationResult", result)
stored, _ = call("GET", path)
validate("StoredProject", stored)
validate("Project", stored["model"])
baseline = stored["model"]["traces"][trace]["baseline"]
validate("TraceBaseline", baseline)
validate("SemanticBaseline", baseline["semantic"])
query.pop("revision")
queried, _ = call("POST", path + "/semantic-transitions/query", query)
validate("SemanticQueryResult", queried)
problem, _ = call("POST", path + "/semantic-transitions/apply", preview, etag, expected=412)
validate("Problem", problem)
assert updated_etag != etag
invalid = [
("FacetSelector", {"path": ["definition", "minimum"]}),
("FacetSelector", {"Data": "Unsupported"}),
("FacetReview", {"Data": {"IntegerMinimum": {"oa": {"Value": "wrong"}, "sa": {"Value": None}}}}),
("FacetReview", {"Data": {"Unit": {"oa": {"Value": 42}, "sa": "NotApplicable"}}}),
("FacetSelector", {"Literal": {"oa": "literal", "sa": None, "index": 0}}),
("SemanticQueryRequest", {"traces": [trace, trace]}),
("SemanticQueryRequest", {"traces": []}),
("SemanticTransitionEnvelope", {**preview, "unknown": True}),
]
obsolete = copy.deepcopy(stored["model"])
obsolete["schema_version"] = 4
invalid.append(("Project", obsolete))
for name, value in invalid:
assert not validator(local, name).is_valid(value), (name, value)
assert not validator(served, name).is_valid(value), (name, value)
# NotApplicable is distinct from Value(null); each leaf fixes the optionality.
validate("FacetReview", {"Data": {"IntegerMinimum": {"oa": "NotApplicable", "sa": {"Value": None}}}})
validate("FacetSelector", {"Annotation": {"oa": {"path": [oa], "field": sa}, "sa": None}})
validate("FacetSelector", {"Annotation": {"oa": {"path": [oa], "field": sa}}})
validate("SemanticImpact", {"object": oa, "after": {"Element": stored["model"]["elements"][oa]}})
print(f"{len(local['components']['schemas'])} valid schemas; {checked} actual/typed values validated against served+local contracts; {len(invalid)} invalid shapes rejected")
finally:
process.terminate()
process.wait(timeout=15)
+205
View File
@@ -0,0 +1,205 @@
"""Refresh the closed semantic OpenAPI catalog from the frozen Rust leaf table.
Run from any directory with Python 3: semantic_schema.py [--check].
This generates documentation only, never runtime selectors or model mutations.
"""
import copy
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
PATH = ROOT / "docs/openapi.json"
spec = json.loads(PATH.read_text(encoding="utf-8"))
schemas = spec["components"]["schemas"]
types = (ROOT / "crates/oa-core/src/semantic/types.rs").read_text(encoding="utf-8")
model = (ROOT / "crates/oa-core/src/model.rs").read_text(encoding="utf-8")
def ref(name):
return {"$ref": f"#/components/schemas/{name}"}
def obj(properties, required=None):
return {"type": "object", "properties": properties,
"required": list(properties) if required is None else required,
"additionalProperties": False}
def array(item, maximum=None):
result = {"type": "array", "items": item}
if maximum is not None:
result["maxItems"] = maximum
return result
def optional(item):
return {"anyOf": [item, {"type": "null"}]}
def enum(names):
return {"type": "string", "enum": names}
def variant(name, value):
return obj({name: value})
def typed(name):
if name.startswith("Option<"):
return optional(typed(name[7:-1]))
if name.startswith("Vec<"):
return array(typed(name[4:-1]))
primitives = {"String": {"type": "string"}, "bool": {"type": "boolean"},
"u32": {"type": "integer", "minimum": 0, "maximum": 4294967295},
"i64": {"type": "integer", "minimum": -9223372036854775808,
"maximum": 9223372036854775807}, "f64": {"type": "number"}}
return primitives[name] if name in primitives else ref(name)
def reviewed(name):
key = "Reviewed" + re.sub(r"[^A-Za-z0-9]", "", name)
observation = {"oneOf": [enum(["NotApplicable"]), variant("Value", typed(name))]}
schemas[key] = obj({"oa": observation, "sa": copy.deepcopy(observation)})
return ref(key)
def keypair(name):
key = "KeyPair" + name
schemas[key] = obj({"oa": optional(typed(name)), "sa": optional(typed(name))}, [])
return ref(key)
for name in ["DataConstructor", "PortConstructor", "OccurrenceConstructor", "EventConstructor",
"MessageConstructor", "ReferenceCollection", "OrderedCollection", "IncidentEnd",
"ArtifactFacet", "ComponentPortKind", "ComponentPortOrientation", "PortDirection",
"FragmentOperator", "StateActionKind", "ArtifactRelation"]:
source = types if f"pub enum {name}" in types else model
body = re.search(r"pub enum " + name + r"\s*\{([^}]+)\}", source).group(1)
body = re.sub(r"//[^\n]*|#\[[^\]]*\]", "", body)
names = [value.strip() for value in body.split(",") if value.strip()]
assert all(re.fullmatch(r"[A-Za-z]+", value) for value in names), (name, names)
schemas[name] = enum(names)
catalog = types.split("macro_rules! scalar_families", 1)[1].split("pub(super) use", 1)[0]
families = re.findall(r"(\w+), (\w+), (\w+) \{([^}]+)\}", catalog)
assert len(families) == 13
selectors, reviews = [], []
leaf_count = 0
for family, selector, review, body in families:
leaves = re.findall(r"(\w+): ([\w<>]+)", body)
leaf_count += len(leaves)
schemas[selector] = enum([leaf for leaf, _ in leaves])
schemas[review] = {"oneOf": [variant(leaf, reviewed(value)) for leaf, value in leaves]}
selectors.append(variant(family, ref(selector)))
reviews.append(variant(family, ref(review)))
assert leaf_count == 84
schemas["AnnotationKey"] = obj({"path": array(ref("Id")), "field": ref("Id")})
id_keys, text_keys, annotation_keys = keypair("Id"), keypair("String"), keypair("AnnotationKey")
artifact_types = {"Presence": "bool", "Name": "String", "Description": "String", "Owner": "Id",
"Relation": "ArtifactRelation", "Uri": "String", "Revision": "Option<String>",
"Fragment": "Option<String>"}
schemas["ArtifactReview"] = {"oneOf": [variant(k, reviewed(v)) for k, v in artifact_types.items()]}
selectors += [variant("Member", obj({"collection": ref("ReferenceCollection"), "keys": id_keys})),
variant("Order", ref("OrderedCollection")), variant("Literal", text_keys),
variant("Annotation", annotation_keys), variant("Binding", id_keys), variant("Child", id_keys),
variant("Incident", obj({"keys": id_keys, "end": ref("IncidentEnd")})),
variant("Artifact", obj({"keys": id_keys, "facet": ref("ArtifactFacet")}))]
reviews += [variant("Member", obj({"collection": ref("ReferenceCollection"), "keys": id_keys, "review": reviewed("bool")})),
variant("Order", obj({"collection": ref("OrderedCollection"), "review": reviewed("Vec<Id>")})),
variant("Literal", obj({"keys": text_keys, "review": reviewed("bool")})),
variant("Annotation", obj({"keys": annotation_keys, "review": reviewed("Option<MessageValue>")})),
variant("Binding", obj({"keys": id_keys, "review": reviewed("Option<LifelineBinding>")})),
variant("Child", obj({"keys": id_keys, "review": reviewed("bool")})),
variant("Incident", obj({"keys": id_keys, "end": ref("IncidentEnd"), "review": reviewed("bool")})),
variant("Artifact", obj({"keys": id_keys, "review": ref("ArtifactReview")}))]
schemas["FacetSelector"] = {"oneOf": selectors}
schemas["FacetReview"] = {"oneOf": reviews}
schemas["SemanticBaseline"] = obj({"facets": array(ref("FacetReview"))})
schemas["TraceBaseline"]["properties"]["semantic"] = {**optional(ref("SemanticBaseline")), "default": None,
"description": "Absent/null means Unreviewed. Historical typed facts never become live graph references."}
schemas["Project"]["properties"]["schema_version"]["const"] = 5
schemas["MigrationReport"]["properties"]["from_schema"]["enum"] = [1, 2, 3, 4]
schemas["MigrationReport"]["properties"]["to_schema"]["const"] = 5
schemas["MigrationReport"]["description"] = schemas["MigrationReport"]["description"].replace("V1/V2/V3 to V4", "V1/V2/V3/V4 to V5")
spec["info"]["description"] = spec["info"]["description"].replace("schema-4", "schema-5")
spec["paths"]["/api/v1/projects/import"]["post"]["description"] = "Schema-5 native JSON is described below. Schema-1/2/3/4 JSON and source are accepted through explicit frozen readers and migration, also on replacement and candidate validation. Prior prose reviews remain intact; absent semantic review stays Unreviewed. Export emits schema 5 / arcadia 5. See docs/language.rst."
schemas["SourceBundle"]["description"] = schemas["SourceBundle"]["description"].replace("source4 workspace (source3 migrates explicitly)", "source5 workspace (source3/4 migrate explicitly)")
schemas["Drift"] = enum(["Unreviewed", "Unchanged", "OaChanged", "SaChanged", "BothChanged"])
schemas["FacetDrift"] = obj({"selector": ref("FacetSelector"), "current": ref("FacetReview"), "drift": ref("Drift")})
schemas["SemanticDrift"] = obj({"own": ref("Drift"), "linked": ref("Drift"),
"own_unreviewed": typed("bool"), "linked_unreviewed": typed("bool")})
schemas["LinkedSemanticImpact"] = obj({"objects": id_keys, "traces": array(ref("Id")),
"drift": ref("Drift"), "unreviewed": typed("bool")})
for name, members in [("SemanticRecord", ["Element", "Relation", "Artifact"]),
("ImpactRecord", ["Element", "Relation", "Artifact", "Diagram", "Trace"])]:
schemas[name] = {"oneOf": [variant(n, ref("ArtifactLink" if n == "Artifact" else n)) for n in members]}
schemas["SemanticImpact"] = obj({"object": ref("Id"), "before": optional(ref("ImpactRecord")), "after": optional(ref("ImpactRecord"))}, ["object"])
schemas["SemanticDecision"] = enum(["KeepTarget", "TakeSource", "Candidate"])
schemas["FacetResolution"] = obj({"facet": ref("FacetSelector"), "decision": ref("SemanticDecision")})
schemas["SemanticReviewRequest"] = obj({"source": ref("Id"), "resolutions": array(ref("FacetResolution"), 5000),
"reset": {**array(ref("FacetSelector"), 5000), "default": []}}, ["source", "resolutions"])
def mappings(maximum):
return {"type": "object", "propertyNames": {"format": "uuid"},
"additionalProperties": ref("Id"), "maxProperties": maximum, "default": {}}
schemas["SemanticTransitionRequest"] = obj({"transition": ref("TransitionRequest"), "traces": mappings(100),
"references": mappings(5000), "reviews": {**array(ref("SemanticReviewRequest"), 100), "default": []},
"candidates": {**array(ref("SemanticRecord"), 200), "default": []},
"accepted_impacts": {**array(ref("SemanticImpact"), 200), "default": []}}, ["transition"])
schemas["SemanticTransitionRequest"]["description"] = "Canonical OA-to-SA references include historical UUIDs. Traces map selected source IDs to trace IDs. Combined resolutions/reset limit: 5,000. Candidates must equal exactly the result of their selected facets; accepted_impacts explicitly acknowledge exact induced changes."
schemas["SemanticReviewPreview"] = obj({"source": ref("Id"), "target": ref("Id"), "facets": array(ref("FacetDrift")),
"acknowledged": array(ref("FacetSelector")), "retired": array(ref("FacetSelector")), "linked_impacts": array(ref("LinkedSemanticImpact"))})
schemas["SemanticTransitionPlan"] = obj({"base_fingerprint": typed("String"), "request": ref("SemanticTransitionRequest"),
"transition": ref("TransitionPlan"), "reviews": array(ref("SemanticReviewPreview")), "impacts": array(ref("SemanticImpact")),
"conflicts": array(typed("String")), "commands": array(ref("Command"), 200), "prepared_fingerprint": optional(typed("String"))})
schemas["SemanticTransitionPlan"]["required"].remove("prepared_fingerprint")
schemas["SemanticTransitionEnvelope"] = obj({"base_revision": ref("Revision"), "plan": ref("SemanticTransitionPlan")})
schemas["SemanticQueryRequest"] = obj({"traces": {**array(ref("Id"), 100), "minItems": 1, "uniqueItems": True},
"references": mappings(5000), "revision": optional(ref("Revision"))}, ["traces"])
schemas["SemanticQueryItem"] = obj({"trace": ref("Id"), "oa": ref("Id"), "sa": ref("Id"),
"facets": array(ref("FacetDrift")), "drift": ref("SemanticDrift"), "linked_impacts": array(ref("LinkedSemanticImpact"))})
schemas["SemanticQueryResult"] = obj({"revision": ref("Revision"), "items": array(ref("SemanticQueryItem"), 100)})
for operation, request, result in [("query", "SemanticQueryRequest", "SemanticQueryResult"),
("preview", "SemanticTransitionRequest", "SemanticTransitionEnvelope"),
("apply", "SemanticTransitionEnvelope", "MutationResult")]:
endpoint = copy.deepcopy(spec["paths"]["/api/v1/projects/{id}/transitions/" + ("apply" if operation == "apply" else "preview")])
post = endpoint["post"]
post["operationId"] = operation + "SemanticTransition"
post["summary"] = {"query": "Query typed trace drift and linked impacts without mutation",
"preview": "Preview and source-validate a typed semantic transition without writing",
"apply": "Conditionally apply a prepared semantic candidate in one model/source revision"}[operation]
post["requestBody"]["content"]["application/json"]["schema"] = ref(request)
post["responses"]["200"]["content"]["application/json"]["schema"] = ref(result)
post["description"] = "Uses the frozen core typed semantic contract. A supplied plan is a client-owned candidate, not a signed receipt; fingerprints check consistency, not original review authorship. Preview validates source writeback when conflict-free. Apply preserves preview-generated IDs and does not replan random identities."
if operation == "query":
post["parameters"] = [p for p in post["parameters"] if p["in"] != "header"]
post["responses"].pop("412", None)
post["responses"].pop("428", None)
post["responses"]["409"] = {"$ref": "#/components/responses/Problem"}
post["description"] = "Read one immutable snapshot. Optional revision mismatch returns409; missing trace returns404; duplicate/empty trace selection returns400. References are canonical OA-to-SA correspondence. No write, baseline refresh or external artifact fetch."
spec["paths"]["/api/v1/projects/{id}/semantic-transitions/" + operation] = endpoint
def check_refs(value):
if isinstance(value, dict):
if "$ref" in value and value["$ref"].startswith("#/components/schemas/"):
assert value["$ref"].split("/")[-1] in schemas, value
for child in value.values():
check_refs(child)
elif isinstance(value, list):
for child in value:
check_refs(child)
check_refs(spec)
rendered = json.dumps(spec, indent=2, ensure_ascii=False) + "\n"
if "--check" in sys.argv:
assert json.loads(PATH.read_text(encoding="utf-8")) == spec, "Run semantic_schema.py to refresh OpenAPI"
else:
PATH.write_text(rendered, encoding="utf-8")
print(f"Closed catalog: {len(families)} families, {leaf_count} scalar leaves; {len(schemas)} schemas")
+114 -6
View File
@@ -763,6 +763,18 @@ bearer token, if any. Non-loopback binding requires a configured token.</p>
<td><p><span class="docutils literal"><span class="pre">/api/v1/projects/{id}/transitions/apply</span></span></p></td>
<td><p>Conditionally apply that client-held plan and base_revision.</p></td>
</tr>
<tr><td><p>POST</p></td>
<td><p><span class="docutils literal"><span class="pre">/api/v1/projects/{id}/semantic-transitions/query</span></span></p></td>
<td><p>Read selected traces' typed facts, own drift and linked impacts.</p></td>
</tr>
<tr><td><p>POST</p></td>
<td><p><span class="docutils literal"><span class="pre">/api/v1/projects/{id}/semantic-transitions/preview</span></span></p></td>
<td><p>Prepare typed semantic decisions and source writeback without writing.</p></td>
</tr>
<tr><td><p>POST</p></td>
<td><p><span class="docutils literal"><span class="pre">/api/v1/projects/{id}/semantic-transitions/apply</span></span></p></td>
<td><p>Apply that prepared candidate under its original shared revision/ETag.</p></td>
</tr>
</tbody>
</table>
<p><span class="docutils literal"><span class="pre">Content-Type:</span> application/json</span> is required for JSON requests.
@@ -784,8 +796,8 @@ candidate validation reports the same diagnostic in its 200 response.
Parsing, validation and query processing use snapshot workers, so this work
does not hold the catalog lock or serialize unrelated model preparation.</p>
<p>Import accepts schema-1 JSON through the shared explicit migration adapter,
as well as schema-2, schema-3 and current schema-4 JSON. Source import uses the shared language parser
and its supported migrations. Export uses schema 4 / arcadia 4. Native JSON
as well as schema-2, schema-3, schema-4 and current schema-5 JSON. Source import uses the shared language parser
and its supported migrations. Export uses schema 5 / arcadia 5. Native JSON
replacement accepts supported older schemas through the same explicit migration.
Only documented query parameters are accepted. In particular, appending
<span class="docutils literal"><span class="pre">?revision=...</span></span> to model/source export is an error, not historical retrieval.</p>
@@ -796,7 +808,7 @@ Only documented query parameters are accepted. In particular, appending
<pre class="literal-block">{
&quot;entry&quot;: &quot;rescue.arcadia&quot;,
&quot;files&quot;: {
&quot;rescue.arcadia&quot;: &quot;arcadia 4; project rescue \&quot;Rescue\&quot; { include \&quot;oa/work.arcadia\&quot;; }\n&quot;,
&quot;rescue.arcadia&quot;: &quot;arcadia 5; project rescue \&quot;Rescue\&quot; { include \&quot;oa/work.arcadia\&quot;; }\n&quot;,
&quot;oa/work.arcadia&quot;: &quot;// Engineering rationale\noa_activity assess \&quot;Assess assistance\&quot; {}\n&quot;
}
}</pre>
@@ -807,7 +819,7 @@ targets or another hosted resource. All submitted files must be reachable;
missing files, cycles, duplicate keys, case collisions and escaping paths are
rejected. The bundle permits at most 256 files, 32 include edges along a path,
and 1,024 include edges in total. Model and source-byte budgets apply across
the complete bundle, not separately per file. Current bundle syntax is source4; source3 bundles migrate explicitly while
the complete bundle, not separately per file. Current bundle syntax is source5; source3/4 bundles migrate explicitly while
preserving their files/includes/comments. V1/V2 single-file migration remains
available through interchange import.</p>
<p>Compilation inserts UUIDs into headers that omit them. Import returns 201 with
@@ -999,7 +1011,7 @@ contract in the same transaction when removing a used interface allocation.</p>
<p>Explicit schema1/2/3 migration preserves prior identities and typed ports. Reports
add semantic defaults, generated interface allocations and unresolved message
values. Old source3 bundles retain files/includes/comments during upgrade; all
current exports use schema4/source4. Reading an old envelope does not write it or
current exports use schema5/source5. Reading an old envelope does not write it or
advance its ETag. Canonical exports omit transient migration reports.</p>
</section>
<section id="validation">
@@ -1009,7 +1021,7 @@ advance its ETag. Canonical exports omit transient migration reports.</p>
<p>or:</p>
<pre class="literal-block">{&quot;format&quot;:&quot;arcadia&quot;, &quot;source&quot;:&quot;complete source document&quot;}</pre>
<p>or:</p>
<pre class="literal-block">{&quot;format&quot;:&quot;source-project&quot;, &quot;source_project&quot;:{&quot;entry&quot;:&quot;rescue.arcadia&quot;, &quot;files&quot;:{&quot;rescue.arcadia&quot;:&quot;arcadia 4; project rescue \&quot;Rescue\&quot; {}&quot;}}}</pre>
<pre class="literal-block">{&quot;format&quot;:&quot;source-project&quot;, &quot;source_project&quot;:{&quot;entry&quot;:&quot;rescue.arcadia&quot;, &quot;files&quot;:{&quot;rescue.arcadia&quot;:&quot;arcadia 5; project rescue \&quot;Rescue\&quot; {}&quot;}}}</pre>
<p>A well-formed validation request returns 200 with <span class="docutils literal">valid</span> and <span class="docutils literal">issues</span> even
if its candidate is invalid, including a model record that fails typed decoding.
Malformed request JSON or an invalid format/payload envelope is a 400.
@@ -1093,6 +1105,102 @@ authenticated as an untouched server preview; the same authenticated client
can submit ordinary typed commands. The core fingerprint is a change detector,
not a security digest. There is no plan token, expiration store or receipt.</p>
</section>
<section id="typed-semantic-transition-review">
<h2>Typed semantic transition review</h2>
<p>The sibling <span class="docutils literal"><span class="pre">semantic-transitions</span></span> routes use the <a class="reference external" href="language.html#typed-semantic-review-facts">typed review semantics</a> in <span class="docutils literal">language.rst</span> sections 5.1–5.5.
The original prose transition routes keep
their existing contract. All new routes use JSON and the existing authentication,
capacity, worker and error boundaries.</p>
<p>Query accepts <span class="docutils literal"><span class="pre">{&quot;traces&quot;:[&quot;trace-uuid&quot;],&quot;references&quot;:{},&quot;revision&quot;:1}</span></span>.
<span class="docutils literal">references</span> is an optional map of canonical OA UUIDs to SA UUIDs, including
historical deleted identities. It never guesses correspondences from names.
<span class="docutils literal">revision</span> is optional; a mismatch returns 409. Select 1–100 unique existing
trace IDs; an empty or duplicate selection returns 400 and a missing trace
returns 404. The response includes the current revision and items containing
<span class="docutils literal">trace</span>, <span class="docutils literal">oa</span>, <span class="docutils literal">sa</span>, typed <span class="docutils literal">facets</span>, <span class="docutils literal">drift</span> and <span class="docutils literal">linked_impacts</span>.
Own and linked drift and their unreviewed flags remain distinct. Query reads one
immutable snapshot, does not refresh baselines, and does not fetch artifact URIs.</p>
<p>Preview accepts <span class="docutils literal">SemanticTransitionRequest</span>:</p>
<pre class="literal-block">{
&quot;transition&quot;: {
&quot;direction&quot;: &quot;OaToSa&quot;,
&quot;sources&quot;: [&quot;40000000-0000-4000-8000-000000000002&quot;],
&quot;scope&quot;: &quot;SelectedOnly&quot;,
&quot;mappings&quot;: [{
&quot;source&quot;: &quot;40000000-0000-4000-8000-000000000002&quot;,
&quot;target&quot;: &quot;40000000-0000-4000-8000-000000000003&quot;,
&quot;target_kind&quot;: null
}],
&quot;fields&quot;: [], &quot;resolutions&quot;: [], &quot;destination_diagram&quot;: null
},
&quot;traces&quot;: {}, &quot;references&quot;: {},
&quot;reviews&quot;: [{
&quot;source&quot;: &quot;40000000-0000-4000-8000-000000000002&quot;,
&quot;resolutions&quot;: [
{&quot;facet&quot;:{&quot;Header&quot;:&quot;Name&quot;}, &quot;decision&quot;:&quot;TakeSource&quot;},
{&quot;facet&quot;:{&quot;Data&quot;:&quot;Unit&quot;}, &quot;decision&quot;:&quot;KeepTarget&quot;}
],
&quot;reset&quot;: []
}],
&quot;candidates&quot;: [], &quot;accepted_impacts&quot;: []
}</pre>
<p>The example maps an existing OA data type to its traced SA counterpart, takes
only its name and acknowledges intentionally different units. Other facets stay
unreviewed or retain their earlier drift. Select an explicit trace through the
<span class="docutils literal">traces</span> map (source UUID to trace UUID) when multiple trace kinds connect the
pair. <span class="docutils literal">reset</span> deliberately invalidates exact old selectors after changed
correspondence. The optional collections default to empty. Semantic header
decisions use typed facets; leave legacy <span class="docutils literal">transition.fields</span> and
<span class="docutils literal">transition.resolutions</span> empty.</p>
<p>Facet selectors are closed enums, for example <span class="docutils literal"><span class="pre">{&quot;Data&quot;:&quot;IntegerMinimum&quot;}</span></span>
or <span class="docutils literal"><span class="pre">{&quot;Member&quot;:{&quot;collection&quot;:&quot;SignalItems&quot;,&quot;keys&quot;:{&quot;oa&quot;:&quot;oa-uuid&quot;,&quot;sa&quot;:&quot;sa-uuid&quot;}}}</span></span>.
Annotation keys contain the entire path and field; enum literal keys are exact
strings. <span class="docutils literal">KeepTarget</span>, <span class="docutils literal">TakeSource</span> and <span class="docutils literal">Candidate</span> operate on independently
selected facts. Candidate records use existing Element/Relation/ArtifactLink
types, must preserve identity/alias, and must equal precisely the result of
the selected facets. Constructor review covers its coupled introduced/removed
fields. Membership review does not silently acknowledge order; target-only
members remain independent. OpenAPI defines every selector and typed payload.</p>
<p>Preview requires <span class="docutils literal"><span class="pre">If-Match</span></span> and returns <span class="docutils literal"><span class="pre">{&quot;base_revision&quot;:1,&quot;plan&quot;:{...}}</span></span>.
The plan contains the original typed request, mapping plan, review facts,
acknowledged/retired selectors, linked impacts, exact prepared impacts, conflicts,
commands, and base/prepared fingerprints. A conflict-free preview also prepares
the complete authored source bundle and checks its model equivalence and capacity.
No preview changes the model, source, revision or stored bytes.</p>
<p>If ordinary preparation induces additional cleanup, the plan reports exact typed
before/after impacts and a conflict. Review those impacts, place the accepted
records in <span class="docutils literal">accepted_impacts</span>, and request another preview. Acceptance cannot
override a contradictory selected facet. Missing mappings, ambiguous order,
duplicate decisions, invalid candidates or unresolved impacts cannot be applied.
This is the shared core planner and command preparation, with no server-specific
semantic mutation engine.</p>
<p>Apply accepts the entire preview envelope under the same <span class="docutils literal"><span class="pre">If-Match</span></span>. It checks
<span class="docutils literal">base_revision</span>, the model fingerprint, unresolved conflicts, the prepared
candidate fingerprint, ordinary model validation and coupled source preparation,
then atomically stores one accepted model/source revision. The final store check
also catches a concurrent edit during preparation. Source-only edits invalidate
the preview through the shared ETag even when the model fingerprint is unchanged.
Stale ETag/revision returns 412; semantic rejection returns 422 with code
<span class="docutils literal">semantic_transition_rejected</span>. Exact no-op candidates preserve revision.
Read and preview again after a successful changed apply before reviewing further.</p>
<p>A supplied plan remains a client-owned candidate. Fingerprints detect stale or
inconsistent proposals; they are not tamper-proof signatures, authentication
receipts or proof of original review authorship. Apply preserves preview-generated
identities and does not rerun the planner with fresh random IDs. Authenticated
clients already have ordinary model/source editing privileges. The server does
not persist plans, tokens or review sessions.</p>
<p>Requests allow 100 sources, trace choices and reviews; 200 mappings, candidates
and accepted impacts; 5,000 canonical reference mappings; and 5,000 facet
resolutions plus resets across all reviews. The legacy 400 prose-resolution
limit and 200-command transaction limit still apply. Query has the same 5,000
reference-map limit. Core bounded linked traversal errors remain observable as
semantic rejection; no truncated result silently claims complete review.</p>
<p>Schema/source 5 adds <span class="docutils literal">TraceBaseline.semantic</span>. Absence or null is Unreviewed;
migrating older prose baselines does not fabricate semantic history. The persisted
baseline contains only closed typed <span class="docutils literal">FacetReview</span> facts, not whole candidate
records or a generic JSON value tree. Historical UUIDs remain historical, including
after deletion, and source uses explicit <span class="docutils literal">uuid</span> tokens for them.</p>
</section>
<section id="error-contract">
<h2>Error contract</h2>
<p>All errors have <span class="docutils literal"><span class="pre">Content-Type:</span> application/problem+json</span>. Example:</p>
+124 -6
View File
@@ -124,6 +124,15 @@ bearer token, if any. Non-loopback binding requires a configured token.
* - POST
- ``/api/v1/projects/{id}/transitions/apply``
- Conditionally apply that client-held plan and base_revision.
* - POST
- ``/api/v1/projects/{id}/semantic-transitions/query``
- Read selected traces' typed facts, own drift and linked impacts.
* - POST
- ``/api/v1/projects/{id}/semantic-transitions/preview``
- Prepare typed semantic decisions and source writeback without writing.
* - POST
- ``/api/v1/projects/{id}/semantic-transitions/apply``
- Apply that prepared candidate under its original shared revision/ETag.
``Content-Type: application/json`` is required for JSON requests.
``text/plain; charset=utf-8`` is used for Arcadia source. The service does not
@@ -146,8 +155,8 @@ Parsing, validation and query processing use snapshot workers, so this work
does not hold the catalog lock or serialize unrelated model preparation.
Import accepts schema-1 JSON through the shared explicit migration adapter,
as well as schema-2, schema-3 and current schema-4 JSON. Source import uses the shared language parser
and its supported migrations. Export uses schema 4 / arcadia 4. Native JSON
as well as schema-2, schema-3, schema-4 and current schema-5 JSON. Source import uses the shared language parser
and its supported migrations. Export uses schema 5 / arcadia 5. Native JSON
replacement accepts supported older schemas through the same explicit migration.
Only documented query parameters are accepted. In particular, appending
``?revision=...`` to model/source export is an error, not historical retrieval.
@@ -160,7 +169,7 @@ A source bundle contains ``entry`` and a map of ``files``. For example::
{
"entry": "rescue.arcadia",
"files": {
"rescue.arcadia": "arcadia 4; project rescue \"Rescue\" { include \"oa/work.arcadia\"; }\n",
"rescue.arcadia": "arcadia 5; project rescue \"Rescue\" { include \"oa/work.arcadia\"; }\n",
"oa/work.arcadia": "// Engineering rationale\noa_activity assess \"Assess assistance\" {}\n"
}
}
@@ -172,7 +181,7 @@ targets or another hosted resource. All submitted files must be reachable;
missing files, cycles, duplicate keys, case collisions and escaping paths are
rejected. The bundle permits at most 256 files, 32 include edges along a path,
and 1,024 include edges in total. Model and source-byte budgets apply across
the complete bundle, not separately per file. Current bundle syntax is source4; source3 bundles migrate explicitly while
the complete bundle, not separately per file. Current bundle syntax is source5; source3/4 bundles migrate explicitly while
preserving their files/includes/comments. V1/V2 single-file migration remains
available through interchange import.
@@ -394,7 +403,7 @@ contract in the same transaction when removing a used interface allocation.
Explicit schema1/2/3 migration preserves prior identities and typed ports. Reports
add semantic defaults, generated interface allocations and unresolved message
values. Old source3 bundles retain files/includes/comments during upgrade; all
current exports use schema4/source4. Reading an old envelope does not write it or
current exports use schema5/source5. Reading an old envelope does not write it or
advance its ETag. Canonical exports omit transient migration reports.
Validation
@@ -410,7 +419,7 @@ or::
or::
{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 4; project rescue \"Rescue\" {}"}}}
{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 5; project rescue \"Rescue\" {}"}}}
A well-formed validation request returns 200 with ``valid`` and ``issues`` even
if its candidate is invalid, including a model record that fails typed decoding.
@@ -506,6 +515,115 @@ authenticated as an untouched server preview; the same authenticated client
can submit ordinary typed commands. The core fingerprint is a change detector,
not a security digest. There is no plan token, expiration store or receipt.
Typed semantic transition review
--------------------------------
The sibling ``semantic-transitions`` routes use the `typed review semantics
<language.html#typed-semantic-review-facts>`_ in ``language.rst`` sections 5.1–5.5.
The original prose transition routes keep
their existing contract. All new routes use JSON and the existing authentication,
capacity, worker and error boundaries.
Query accepts ``{"traces":["trace-uuid"],"references":{},"revision":1}``.
``references`` is an optional map of canonical OA UUIDs to SA UUIDs, including
historical deleted identities. It never guesses correspondences from names.
``revision`` is optional; a mismatch returns 409. Select 1–100 unique existing
trace IDs; an empty or duplicate selection returns 400 and a missing trace
returns 404. The response includes the current revision and items containing
``trace``, ``oa``, ``sa``, typed ``facets``, ``drift`` and ``linked_impacts``.
Own and linked drift and their unreviewed flags remain distinct. Query reads one
immutable snapshot, does not refresh baselines, and does not fetch artifact URIs.
Preview accepts ``SemanticTransitionRequest``::
{
"transition": {
"direction": "OaToSa",
"sources": ["40000000-0000-4000-8000-000000000002"],
"scope": "SelectedOnly",
"mappings": [{
"source": "40000000-0000-4000-8000-000000000002",
"target": "40000000-0000-4000-8000-000000000003",
"target_kind": null
}],
"fields": [], "resolutions": [], "destination_diagram": null
},
"traces": {}, "references": {},
"reviews": [{
"source": "40000000-0000-4000-8000-000000000002",
"resolutions": [
{"facet":{"Header":"Name"}, "decision":"TakeSource"},
{"facet":{"Data":"Unit"}, "decision":"KeepTarget"}
],
"reset": []
}],
"candidates": [], "accepted_impacts": []
}
The example maps an existing OA data type to its traced SA counterpart, takes
only its name and acknowledges intentionally different units. Other facets stay
unreviewed or retain their earlier drift. Select an explicit trace through the
``traces`` map (source UUID to trace UUID) when multiple trace kinds connect the
pair. ``reset`` deliberately invalidates exact old selectors after changed
correspondence. The optional collections default to empty. Semantic header
decisions use typed facets; leave legacy ``transition.fields`` and
``transition.resolutions`` empty.
Facet selectors are closed enums, for example ``{"Data":"IntegerMinimum"}``
or ``{"Member":{"collection":"SignalItems","keys":{"oa":"oa-uuid","sa":"sa-uuid"}}}``.
Annotation keys contain the entire path and field; enum literal keys are exact
strings. ``KeepTarget``, ``TakeSource`` and ``Candidate`` operate on independently
selected facts. Candidate records use existing Element/Relation/ArtifactLink
types, must preserve identity/alias, and must equal precisely the result of
the selected facets. Constructor review covers its coupled introduced/removed
fields. Membership review does not silently acknowledge order; target-only
members remain independent. OpenAPI defines every selector and typed payload.
Preview requires ``If-Match`` and returns ``{"base_revision":1,"plan":{...}}``.
The plan contains the original typed request, mapping plan, review facts,
acknowledged/retired selectors, linked impacts, exact prepared impacts, conflicts,
commands, and base/prepared fingerprints. A conflict-free preview also prepares
the complete authored source bundle and checks its model equivalence and capacity.
No preview changes the model, source, revision or stored bytes.
If ordinary preparation induces additional cleanup, the plan reports exact typed
before/after impacts and a conflict. Review those impacts, place the accepted
records in ``accepted_impacts``, and request another preview. Acceptance cannot
override a contradictory selected facet. Missing mappings, ambiguous order,
duplicate decisions, invalid candidates or unresolved impacts cannot be applied.
This is the shared core planner and command preparation, with no server-specific
semantic mutation engine.
Apply accepts the entire preview envelope under the same ``If-Match``. It checks
``base_revision``, the model fingerprint, unresolved conflicts, the prepared
candidate fingerprint, ordinary model validation and coupled source preparation,
then atomically stores one accepted model/source revision. The final store check
also catches a concurrent edit during preparation. Source-only edits invalidate
the preview through the shared ETag even when the model fingerprint is unchanged.
Stale ETag/revision returns 412; semantic rejection returns 422 with code
``semantic_transition_rejected``. Exact no-op candidates preserve revision.
Read and preview again after a successful changed apply before reviewing further.
A supplied plan remains a client-owned candidate. Fingerprints detect stale or
inconsistent proposals; they are not tamper-proof signatures, authentication
receipts or proof of original review authorship. Apply preserves preview-generated
identities and does not rerun the planner with fresh random IDs. Authenticated
clients already have ordinary model/source editing privileges. The server does
not persist plans, tokens or review sessions.
Requests allow 100 sources, trace choices and reviews; 200 mappings, candidates
and accepted impacts; 5,000 canonical reference mappings; and 5,000 facet
resolutions plus resets across all reviews. The legacy 400 prose-resolution
limit and 200-command transaction limit still apply. Query has the same 5,000
reference-map limit. Core bounded linked traversal errors remain observable as
semantic rejection; no truncated result silently claims complete review.
Schema/source 5 adds ``TraceBaseline.semantic``. Absence or null is Unreviewed;
migrating older prose baselines does not fabricate semantic history. The persisted
baseline contains only closed typed ``FacetReview`` facts, not whole candidate
records or a generic JSON value tree. Historical UUIDs remain historical, including
after deletion, and source uses explicit ``uuid`` tokens for them.
Error contract
--------------
+407 -36
View File
@@ -623,7 +623,7 @@ a { text-decoration: none; }
<h1 class="title">Arcadia Studio OA / SA Language and Editing Contract</h1>
<dl class="docinfo simple">
<dt class="version">Version<span class="colon">:</span></dt>
<dd class="version">4</dd>
<dd class="version">5</dd>
<dt class="status">Status<span class="colon">:</span></dt>
<dd class="status">Normative current implementation contract; first-release integration</dd>
<dt class="core-implementation">Core implementation<span class="colon">:</span></dt>
@@ -637,12 +637,14 @@ a { text-decoration: none; }
<h2>1. Scope and conformance</h2>
<p>This specification defines the shared Operational Analysis (OA) and System
Analysis (SA) model, its textual language, graphical editing obligations,
validation, persistence, and explicit migration from versions 1, 2 and 3. MUST, MUST NOT,
validation, persistence, and explicit migration from versions 1, 2, 3 and 4. MUST, MUST NOT,
and MAY indicate requirements, prohibitions, and permitted choices.</p>
<p>Schema 4 adds bounded reusable data types, typed scalar defaults, exchange
mechanisms, qualified message values, specialization, identified interface
allocations and reference associations. It retains schema 3 typed ports and
state views. Explicit older readers preserve existing meanings and identities.
<p>Schema 5 adds closed typed semantic review facts to trace baselines, including
historical references that may no longer exist in the live model. It retains
schema 4 bounded reusable data types, typed defaults, exchange mechanisms,
qualified values, specialization, interface allocations and associations, and
schema 3 typed ports and state views. Explicit older readers preserve meanings
and identities without inventing previously reviewed semantic values.
This contract does not by itself establish native acceptance or release completion.</p>
<p>OA describes stakeholders, operational work and needs. SA describes the
selected system boundary, external actors, functions, services and expected
@@ -661,7 +663,7 @@ Actual verification evidence is separate from this contract.</p>
</section>
<section id="one-authoritative-model">
<h2>2. One authoritative model</h2>
<p>A Project stores <span class="docutils literal">schema_version</span> (4), <span class="docutils literal">id</span>, <span class="docutils literal">alias</span>, <span class="docutils literal">name</span>,
<p>A Project stores <span class="docutils literal">schema_version</span> (5), <span class="docutils literal">id</span>, <span class="docutils literal">alias</span>, <span class="docutils literal">name</span>,
<span class="docutils literal">description</span>, and maps named <span class="docutils literal">elements</span>, <span class="docutils literal">relations</span>, <span class="docutils literal">diagrams</span>,
<span class="docutils literal">artifact_links</span>, and <span class="docutils literal">traces</span>. Each map is keyed by the UUID contained in
its record. Duplicate map keys and mismatching keys/identities are errors.</p>
@@ -1015,12 +1017,12 @@ pickers do not offer it. Resolution to a typed SA contract retains this record
without treating it as another active constraint. OA generic ports remain
Legacy; OA Interactions stay activity-to-activity without function ports.
Deleting a referenced legacy interface clears that optional live reference.</p>
<p>A source-3 functional exchange MUST specify both port aliases; the parser does
<p>A current-source functional exchange MUST specify both port aliases; the parser does
not create endpoints. Graphical/command FE creation plans ports atomically,
using unique compatible typed-port reuse or deterministic new endpoints.
Port deletion cascades to incident FEs; FE deletion retains independent ports.</p>
<p>For example:</p>
<pre class="literal-block">arcadia 4;
<pre class="literal-block">arcadia 5;
project ports_example &quot;Typed function contract&quot; {
sa_function send &quot;Send&quot; {}
sa_function receive &quot;Receive&quot; {}
@@ -1042,7 +1044,8 @@ project ports_example &quot;Typed function contract&quot; {
sa_port preserved &quot;Unresolved original&quot; {
parent &#64;send; contract Legacy;
legacy { direction InOut; interface &#64;old_service; };
}</pre>
}
}</pre>
<p>An exclusive region has at most one initial state. Missing initial state is
a completeness warning. Initial states have no incoming transitions and at
most one outgoing transition; their outgoing transition has no trigger/guard.
@@ -1394,7 +1397,7 @@ invalid ranges reject. Composition/shared aggregation, multiple inheritance,
redefinition, instance literals and arbitrary-precision decimal arithmetic are
outside this bounded contract.</p>
<p>This complete data contract example is also exercised through the REST API:</p>
<pre class="code text literal-block"><code>arcadia 4;
<pre class="code text literal-block"><code>arcadia 5;
project engineering_data &quot;Engineering data&quot; {
sa_data_type voltage &quot;Voltage&quot; {
definition { Real { minimum 0.0; maximum 48.0; }; };
@@ -1457,7 +1460,7 @@ remove or relocate incompatible placements in the same transaction. Context
errors use <span class="docutils literal">state_diagram_context</span>; incompatible placements use
<span class="docutils literal">state_diagram_member</span>. Neither case silently hides invalid members.</p>
<p>This complete source example places one state from an existing machine:</p>
<pre class="code text literal-block"><code>arcadia 4;
<pre class="code text literal-block"><code>arcadia 5;
project operating &quot;Operating modes&quot; {
oa_entity crew &quot;Crew&quot; {}
oa_state_machine modes &quot;Modes&quot; { parent &#64;crew; }
@@ -1624,9 +1627,11 @@ are invalid.</p>
</tr>
</tbody>
</table>
<p>A nonempty baseline is <span class="docutils literal">{ oa { name <span class="pre">&quot;...&quot;;</span> description <span class="pre">&quot;...&quot;;</span> }; sa { name <span class="pre">&quot;...&quot;;</span> description <span class="pre">&quot;...&quot;;</span> }; }</span>. It records last-reviewed names
and descriptions, not a full structural snapshot. Implementations MUST NOT
present it as structural synchronization or a lossless inverse transition.
<p>A nonempty baseline retains the required prose records <span class="docutils literal">oa { name <span class="pre">&quot;...&quot;;</span> description <span class="pre">&quot;...&quot;;</span> }; sa { name <span class="pre">&quot;...&quot;;</span> description <span class="pre">&quot;...&quot;;</span> };</span> and optionally
<span class="docutils literal">semantic</span> (default none), defined below. The original prose records and typed
semantic facts are distinct review histories. Importing an older prose baseline
MUST NOT fabricate semantic acknowledgements. Neither history is a full model
snapshot, structural synchronization, or a lossless inverse transition.
Explicit counterpart creation, linking, comparison, and reviewed property
update may start in either layer. Ordinary edits MUST NOT automatically
overwrite the counterpart. Deletion MUST NOT silently cascade across layers.</p>
@@ -1638,15 +1643,358 @@ instead of silently creating a different mapping. Required/subtree process and
chain transitions include their contextual links, owned contributions and
referenced dependencies. New process/chain contribution mappings use Sequence
traces; older Scenario-kind occurrence mappings remain accepted. The current
name/description baseline does not reconcile later topology changes.</p>
<p>Name and description are compared independently against each endpoint's
last-reviewed baseline. An unchanged source MUST NOT overwrite an accepted
prose baseline alone does not reconcile later topology changes.</p>
<p>In the prose-only workflow, name and description are compared independently
against each endpoint's last-reviewed baseline. An unchanged source MUST NOT overwrite an accepted
different target value. A source-only change proposes an update; different
changes on both sides require an explicit decision to take the source, keep
the target, or enter a merged value. An explicit decision records both current
values as reviewed, even when they differ. A target-only edit is preserved
without advancing that field's baseline, so it remains eligible to propagate
in the reverse direction. Equal current values can be recorded as converged.</p>
<section id="typed-semantic-review-facts">
<h3>5.1. Typed semantic review facts</h3>
<p><span class="docutils literal">baseline.semantic</span> is either none or <span class="docutils literal">{ facets [facet, <span class="pre">...];</span> }</span>.
Each facet is a closed, self-tagged typed record, not a free-form path/value
entry. List position does not identify a facet. A scalar fact is exactly
<span class="docutils literal">{ Family { Leaf { oa observation; sa observation; }; }; }</span>. Each observation
is either <span class="docutils literal">NotApplicable</span> or <span class="docutils literal">{ Value value; }</span>, with the value type fixed
by Family/Leaf. Both sides are always canonical OA/SA, even during reverse review.</p>
<p><span class="docutils literal">{ Value none; }</span> means an applicable optional field was absent;
<span class="docutils literal">NotApplicable</span> means the field's branch or record was not applicable.
An absent facet means Unreviewed. These meanings MUST NOT be conflated. Each
side is compared with its own last-reviewed value; deliberately unequal values
can be reviewed and unchanged. Reviewing one minimum does not acknowledge a
maximum, unit, default, header or referenced type definition.</p>
<p>Historical facts are independently acknowledged observations. They need not
compose one coherent former model: two bounds can have different review times.
They MAY outlive the endpoint's former record family, constructor and referenced
objects. Historical admission checks their closed payload types, finite values,
text, keys and uniqueness; it MUST NOT require a currently existing referenced
object or current family applicability. In contrast, a new graphical or planned
resolution MUST address the appropriate current record family and have at least
one currently applicable side. Both-side NotApplicable facts are invalid persisted
acknowledgements. A grouped constructor decision retires such obsolete branch facts.</p>
<p>The following table is the exhaustive scalar catalog. <span class="docutils literal">T?</span> denotes an optional
T, <span class="docutils literal">T[]</span> a list, and <span class="docutils literal">Id</span> a historical UUID. Existing enum and typed payload
definitions in sections 3–4 apply. Every listed leaf wraps <span class="docutils literal">{ oa <span class="pre">...;</span> sa <span class="pre">...;</span> }</span>.</p>
<table>
<caption>Closed scalar semantic facets</caption>
<colgroup>
<col style="width: 15.0%" />
<col style="width: 85.0%" />
</colgroup>
<thead>
<tr><th class="head"><p>Family</p></th>
<th class="head"><p>Leaves and observation value types</p></th>
</tr>
</thead>
<tbody>
<tr><td><p>Header</p></td>
<td><p>Name, Description: string; Parent: Id?</p></td>
</tr>
<tr><td><p>ExchangeItem</p></td>
<td><p>Nature: ExchangeItemKind; Mechanism: ExchangeMechanism; Supertype: Id?</p></td>
</tr>
<tr><td><p>Interface</p></td>
<td><p>Supertype: Id?</p></td>
</tr>
<tr><td><p>Data</p></td>
<td><p>Constructor: DataConstructor; IntegerMinimum, IntegerMaximum: i64?; RealMinimum, RealMaximum: finite f64?; StringMinimumLength: u32; StringMaximumLength: u32?; RecordSupertype: Id?; Default: DataValue?; Unit: string</p></td>
</tr>
<tr><td><p>Field</p></td>
<td><p>Type: TypeRef; Minimum: u32; Maximum: u32?; Unit: string; Direction: ParameterDirection; Storage: FieldStorage; Default: DataValue[]?</p></td>
</tr>
<tr><td><p>Port</p></td>
<td><p>Constructor: PortConstructor; ComponentKind: ComponentPortKind; Orientation: ComponentPortOrientation; LegacyDirection: PortDirection; LegacyInterface: Id?</p></td>
</tr>
<tr><td><p>Scenario</p></td>
<td><p>Viewpoint: ScenarioViewpoint; Context: Id?; Precondition, Postcondition: string</p></td>
</tr>
<tr><td><p>Lifeline</p></td>
<td><p>Represented: Id?</p></td>
</tr>
<tr><td><p>Occurrence</p></td>
<td><p>Constructor: OccurrenceConstructor; Precondition, Postcondition: string; Exchange, Sender, Receiver: Id?; MessageKind: MessageConstructor; TimerExpression: string; ReplyTo, Action, ActionLifeline: Id?; ControlKind: ControlKind; Sequence, Scenario, State, StateLifeline: Id?; FragmentOperator: FragmentOperator; BranchGuard: string</p></td>
</tr>
<tr><td><p>Event</p></td>
<td><p>Constructor: EventConstructor; TimeKind: TimeEventKind; TimeExpression, ChangeExpression: string</p></td>
</tr>
<tr><td><p>State</p></td>
<td><p>Kind: StateKind; Entry, Exit, DoActivity: string</p></td>
</tr>
<tr><td><p>Constraint</p></td>
<td><p>Expression: string</p></td>
</tr>
<tr><td><p>Relation</p></td>
<td><p>Source, Target: Id; AllocationOrder: u32; Transmission: TransmissionProtocol; Acquisition: AcquisitionProtocol; SourceRole, TargetRole: string; SourceMinimum, TargetMinimum: u32; SourceMaximum, TargetMaximum: u32?; SourceNavigable, TargetNavigable: bool; SourcePort, TargetPort: Id; Oriented: bool; ExtendCondition, SequenceGuard: string; TransitionKind: TransitionKind; Trigger, Guard, Effect: string; StateActionPhase: StateActionKind</p></td>
</tr>
</tbody>
</table>
</section>
<section id="members-rows-order-and-attached-records">
<h3>5.2. Members, rows, order, and attached records</h3>
<p>The remaining facet forms are closed records. <span class="docutils literal">keys</span> means <span class="docutils literal">{ oa <span class="pre">key-or-none;</span> sa <span class="pre">key-or-none;</span> }</span>; at least one key MUST be present. <span class="docutils literal">review</span> means the same
required OA/SA observation pair used above.</p>
<table>
<caption>Closed keyed and ordered semantic facets</caption>
<colgroup>
<col style="width: 17.0%" />
<col style="width: 45.0%" />
<col style="width: 38.0%" />
</colgroup>
<thead>
<tr><th class="head"><p>Outer tag</p></th>
<th class="head"><p>Required fields</p></th>
<th class="head"><p>Key / reviewed value type</p></th>
</tr>
</thead>
<tbody>
<tr><td><p>Member</p></td>
<td><p>collection; keys; review</p></td>
<td><p>Id / bool</p></td>
</tr>
<tr><td><p>Order</p></td>
<td><p>collection; review</p></td>
<td><p>No keys / Id[]</p></td>
</tr>
<tr><td><p>Literal</p></td>
<td><p>keys; review</p></td>
<td><p>Exact case-sensitive string / bool</p></td>
</tr>
<tr><td><p>Annotation</p></td>
<td><p>keys; review</p></td>
<td><p><span class="docutils literal">{ path <span class="pre">Id[];</span> field Id; }</span> / MessageValue?</p></td>
</tr>
<tr><td><p>Binding</p></td>
<td><p>keys; review</p></td>
<td><p>Referenced lifeline Id / LifelineBinding?</p></td>
</tr>
<tr><td><p>Child</p></td>
<td><p>keys; review</p></td>
<td><p>Direct child Id / bool</p></td>
</tr>
<tr><td><p>Incident</p></td>
<td><p>keys; end; review</p></td>
<td><p>Relation Id, end Source or Target / bool</p></td>
</tr>
<tr><td><p>Artifact</p></td>
<td><p>keys; review</p></td>
<td><p>Artifact Id / nested artifact facet below</p></td>
</tr>
</tbody>
</table>
<p>Member.collection is exactly Steps, FragmentBranches, FunctionInputItems,
FunctionOutputItems, SignalItems, ExchangeItems, TransitionTriggers,
TransitionEffects or Constrained. Order.collection is exactly Steps or
FragmentBranches. Order is independent of membership and MUST contain each
surviving member exactly once when applied. Membership Keep does not refresh
order history. An automatic insertion requires a unique unchanged mapped anchor;
ambiguous insertion or conflicting inferred orders requires an explicit final
target order. Independent removals can filter the order to surviving members.</p>
<p>Annotation identity is the complete <span class="docutils literal">(path, field)</span> pair. Its MessageValue
payload contains <span class="docutils literal">path</span>, <span class="docutils literal">field</span> and <span class="docutils literal">expression</span>; the payload key MUST
match its side's selector key. Binding payloads contain <span class="docutils literal">referenced</span> and
<span class="docutils literal">local</span>; <span class="docutils literal">referenced</span> MUST match that side's key. Each row is one atomic
fact. Changing its key is an explicit old-row removal/new-row addition, not an
expression-only edit. Defaults containing repeated DataValues are atomic optional
vectors because those values have no stable member identity.</p>
<p>Artifact.review is <span class="docutils literal">{ Leaf { oa observation; sa observation; }; }</span> where Leaf
is Presence: bool; Name, Description or Uri: string; Owner: Id;
Relation: ArtifactRelation; Revision or Fragment: string?. Metadata decisions
are authorized on the artifact's attachment in the initial project. An explicit
Owner candidate may move a target-only link and record its resulting Owner and
other selected metadata. Presence remains relative to the trace owner. Subsequent
metadata decisions require its new owner trace; historical facts remain attached
to the trace that recorded them. No review fetches artifact content.</p>
<p>Duplicate scalar selectors and keyed selectors that occupy the same OA slot or
the same SA slot are invalid, even when the other side's key differs. Historical
order vectors MUST NOT repeat an Id. Review history has a limit of 16384 facets,
128 Ids in an annotation path, 1000000 bytes per string, and 1000000 traversal
steps per baseline. Exceeding these implementation work limits produces a
diagnostic; truncation MUST NOT be accepted as a complete review.</p>
</section>
<section id="constructor-groups-and-decisions">
<h3>5.3. Constructor groups and decisions</h3>
<p>Persisted constructors are discriminator words only:</p>
<ul class="simple">
<li><p>DataConstructor: Boolean, Integer, Real, String, Enumeration, Record.</p></li>
<li><p>PortConstructor: FunctionInput, FunctionOutput, Component, Legacy.</p></li>
<li><p>OccurrenceConstructor: Exchange, Action, Control, SequenceReference,
ScenarioUse, State, Fragment, Branch.</p></li>
<li><p>EventConstructor: Signal, Time, Change.</p></li>
<li><p>MessageConstructor: Asynchronous, Call, Reply, Create, Delete, Timer.</p></li>
</ul>
<p>When a discriminator has no prior fact after explicit resets, both current sides
are applicable and equal, and the proposed target discriminator is unchanged,
KeepTarget, TakeSource or Candidate acknowledges only that discriminator.
It MUST NOT copy a constructor payload, acknowledge unselected descendants or
retire branch history. Descendant facets may be independently selected in the
same review; their decisions retain their own scopes and final-value audits.
Candidate records still MUST NOT change an unselected facet. Missing applicability
on either side does not qualify as an equal discriminator acknowledgement.</p>
<p>A grouped constructor resolution uses a transient typed target record, then
records the discriminator and each acknowledged descendant as separate facts.
It MUST NOT persist a second whole constructor payload overlapping those leaves.
Data includes its branch fields, literals and coupled default, but excludes Unit.
Port includes its contract, Legacy fields and item members. Event includes its
branch fields and Signal items. Occurrence includes its variant payload, members,
order, annotations and bindings, but excludes outer Precondition/Postcondition.
MessageKind includes its discriminator and dependent TimerExpression. State.Kind
is its own structural facet even though it changes the element's derived kind.
Other dependent changes caused by preparation remain explicit impacts.</p>
<p>A grouped constructor Keep requires an actual discriminator proposal or a
discriminator change since its prior review. Repeating Keep on an already reviewed
unequal pair MUST NOT acknowledge unrelated bound/default drift. A real target switch may
reconsider a previously kept difference. When the target already has the proposed
constructor, reconciliation MUST preserve target-only members rather than replace
its whole list. Removed branch facts participate in agreement as NotApplicable;
facts with neither side remaining applicable are retired.</p>
<p>Semantic review supports these explicit decisions in either direction:</p>
<ul class="simple">
<li><p>KeepTarget records the current source and retained target values for only the
selected facet, or the explicitly displayed constructor group.</p></li>
<li><p>TakeSource maps the selected source value through the frozen correspondence and
applies only that facet's delta. Unrelated target edits and target-only collection
members, rows, children and artifacts remain intact. Removing membership unlinks
the target member; it does not authorize deleting the target semantic object.</p></li>
<li><p>Candidate supplies final existing typed Element, Relation or Artifact records.
Only selected facets may differ from the result. Identity, alias and layer are
protected. New artifact creation requires a complete candidate and review of
every introduced metadata facet; removal also reviews the metadata retirement.</p></li>
</ul>
<p>Headers have explicit semantic Name/Description facets; the original prose-only
workflow remains supported with its own baseline. Ordinary source editing can
explicitly edit a valid baseline; neither baseline format attests that a human
performed a particular review workflow.
Decision requests, final candidates and prepared impacts are transient editor/API
data. Source persists the resulting ordinary model and typed baseline facts;
it does not persist KeepTarget/TakeSource/Candidate as extra facet fields.</p>
</section>
<section id="correspondence-agreement-and-atomic-preparation">
<h3>5.4. Correspondence, agreement, and atomic preparation</h3>
<p>Correspondence is an explicit canonical OA-to-SA Id map, including historical
deleted IDs. Names and aliases MUST NOT guess correspondence. Reverse application
uses its inverse and rejects ambiguity. Multiple trace kinds for one endpoint
pair require an explicit trace selection. Changing a keyed pairing requires an
explicit reset naming the prior selector; old pairs are never silently replaced.
If a previously paired member or branch disappears on one side, retain its pair
with the absent/NotApplicable observation, suppressing a redundant one-sided
projection. A newly appearing pair still requires reset of old one-sided history.</p>
<p>All selected source proposals use one immutable initial project. Resets precede
catalog and constructor-scope derivation. Many sources may affect one target only
when their desired values agree on every actual canonical target slot, including
removals and members introduced by another proposal. Child membership and the
actual child's Parent share one slot; incidents share the relation's Source or
Target endpoint; artifact Presence and Owner share the actual artifact owner slot.
Conflicting desired values MUST reject independently of request order.</p>
<p>Materialize the agreed writes once, then audit each desired selected value.
Prepare one ordinary complete Command batch and compare its actual project with
the explicitly reviewed candidate. Additional cleanup, such as inherited message
annotation pruning, is reported as exact object <span class="docutils literal">before</span>/<span class="docutils literal">after</span> typed records
(or absence for creation/deletion). Every induced impact requires explicit review;
an accepted impact still MUST NOT contradict a selected Keep/Take. Repeat the
selected-value audit after preparation. Baseline updates belong to the same atomic
transaction and source-preserving writeback as model edits.</p>
<p>A plan's base fingerprint detects a stale model; its prepared fingerprint checks
candidate consistency. The plan is client-supplied data, not an authenticated
receipt or proof of its original decisions. Applying it validates the complete
ordinary candidate and preserves generated identities; it does not re-run its
request to generate new identities. Hosted clients additionally use the revision
preconditions defined in <span class="docutils literal">api.rst</span>. Invalid/stale plans or failed source writeback
leave the accepted model, source, baseline, revision and history unchanged.</p>
<p>Own drift and linked impacts are separate, with incomplete review explicitly
visible. Absent facts MUST NOT produce a falsely complete Unchanged rollup.
Shared data/interface/event definitions are reviewed on their own traces; their
bounded derived impact walk follows reusable definitions and interface allocations,
not arbitrary architectural neighborhoods, and does not copy them into every
referrer's baseline. Its limits are 4096 objects per side and 32768 edges; exhaustion
is a diagnostic. Successful review remains one undoable transaction. Replanning
unchanged, already acknowledged leaf facts is a no-op only while their requests
remain applicable. A completed constructor switch or first acknowledgement of an
equal discriminator MUST be removed from subsequent requests; repeating it when
no switch remains is rejected. After an artifact moves to another owner,
metadata review through the old owner's trace is no longer
applicable and is rejected. An old plan becomes stale after an actual committed
change.</p>
</section>
<section id="source-examples-and-historical-token-rules">
<h3>5.5. Source examples and historical token rules</h3>
<p>This valid source records deliberately different constructor/unit observations.
Its historical integer minimum is absent; the Real side has no integer branch:</p>
<pre class="literal-block">arcadia 5;
project reviewed_dimensions &quot;Reviewed dimensions&quot; {
oa_data_type oa_dimension &quot;Operational distance&quot; {
definition { Integer { minimum 0; maximum 10; }; };
unit &quot;m&quot;;
}
sa_data_type sa_dimension &quot;System distance&quot; {
definition { Real { minimum 0; maximum 20; }; };
unit &quot;ft&quot;;
}
trace dimension_review &quot;Dimension review&quot; {
oa &#64;oa_dimension;
sa &#64;sa_dimension;
kind Item;
baseline {
oa { name &quot;Approved OA name&quot;; description &quot;&quot;; };
sa { name &quot;Approved SA name&quot;; description &quot;&quot;; };
semantic { facets [
{ Data { Constructor { oa { Value Integer; }; sa { Value Real; }; }; }; },
{ Data { IntegerMinimum { oa { Value none; }; sa NotApplicable; }; }; },
{ Data { Unit { oa { Value &quot;m&quot;; }; sa { Value &quot;ft&quot;; }; }; }; }
]; };
};
}
}</pre>
<p>Historical Id slots require <span class="docutils literal">uuid <span class="pre">&quot;...&quot;</span></span> even if the object still exists.
Only typed reference slots inside <span class="docutils literal">baseline.semantic</span> accept that atom.
For example, the following is a valid historical facet value, including a deleted
named type; <span class="docutils literal">none</span> and bare enum Boolean retain their different typed meanings:</p>
<pre class="literal-block">{ Field { Type {
oa { Value { Named uuid &quot;40000000-0000-4000-8000-000000000090&quot;; }; };
sa { Value Boolean; };
}; }; }</pre>
<p>A qualified annotation uses the same historical UUID atoms in its key and payload.
This complete facet records one old row and an applicable absent target row:</p>
<pre class="literal-block">{ Annotation {
keys {
oa {
path [uuid &quot;40000000-0000-4000-8000-000000000091&quot;];
field uuid &quot;40000000-0000-4000-8000-000000000092&quot;;
};
sa none;
};
review {
oa { Value {
path [uuid &quot;40000000-0000-4000-8000-000000000091&quot;];
field uuid &quot;40000000-0000-4000-8000-000000000092&quot;;
expression &quot;latitude + 1&quot;;
}; };
sa { Value none; };
};
}; }</pre>
<p>Historical enums require bare words. Ordinary strings, including UUID-looking
units, literal keys and expressions, require quoted strings and remain text
during identity remapping. <span class="docutils literal">&#64;alias</span> is invalid anywhere inside a semantic
baseline. Live parents, trace endpoints and all other live references still
require <span class="docutils literal">&#64;alias</span> and MUST NOT accept the historical UUID atom.</p>
<p>The following substitutions in these examples are invalid:</p>
<ul class="simple">
<li><p><span class="docutils literal">oa &quot;NotApplicable&quot;;</span> or <span class="docutils literal">oa { Value &quot;Integer&quot;; };</span> in the Constructor
review: enum observations require bare words of the correct typed enum.</p></li>
<li><p><span class="docutils literal">Named <span class="pre">&quot;40000000-0000-4000-8000-000000000090&quot;;</span></span> or <span class="docutils literal">Named &#64;oa_dimension;</span>:
a historical Id requires the explicit UUID atom.</p></li>
<li><p><span class="docutils literal">Unit { oa { Value uuid <span class="pre">&quot;40000000-0000-4000-8000-000000000090&quot;;</span> }; ... }</span>:
a text field cannot hold a reference atom.</p></li>
<li><p>Repeating the Unit facet, adding an unknown leaf, or supplying a numeric bound
as a quoted string: closed typed admission rejects the whole candidate.</p></li>
<li><p>Changing only the annotation payload's field UUID suffix from 092 to 093,
leaving the key at 092: the row's key and payload disagree.</p></li>
<li><p>Changing the document header to <span class="docutils literal">arcadia 4;</span> while retaining <span class="docutils literal">semantic</span>:
the frozen earlier schema does not admit a future baseline shape.</p></li>
</ul>
</section>
</section>
<section id="text-grammar-and-resolution">
<h2>6. Text grammar and resolution</h2>
@@ -1666,7 +2014,7 @@ spellings can be used in alias positions because references begin with <span cla
<span class="docutils literal"><span class="pre">element-kind</span></span> and <span class="docutils literal"><span class="pre">relation-kind</span></span> are exactly the corresponding declaration
keywords listed above. Typed field
sets and enum constructors are closed by sections 3–5.</p>
<pre class="code text literal-block"><code>document = &quot;arcadia&quot; &quot;4&quot; &quot;;&quot; project
<pre class="code text literal-block"><code>document = &quot;arcadia&quot; &quot;5&quot; &quot;;&quot; project
project = &quot;project&quot; alias string identity? &quot;{&quot; project-entry* &quot;}&quot;
project-entry = &quot;description&quot; string &quot;;&quot; | declaration
declaration = element
@@ -1678,7 +2026,8 @@ element-body = &quot;{&quot; (field-name value &quot;;&quot; | element)* &quot;}
identity = &quot;id&quot; uuid-string
fields = &quot;{&quot; (field-name value &quot;;&quot;)* &quot;}&quot;
value = string | number | &quot;true&quot; | &quot;false&quot; | &quot;none&quot;
| enum-word | &quot;&#64;&quot; alias | list | fields
| enum-word | &quot;&#64;&quot; alias | historical-id | list | fields
historical-id = &quot;uuid&quot; uuid-string
list = &quot;[&quot; (value (&quot;,&quot; value)*)? &quot;]&quot;</code></pre>
<p>The project description defaults to empty and may appear once. There is no
semicolon after a declaration or project closing brace. Every field inside a
@@ -1696,6 +2045,8 @@ bare words cannot substitute for reference-valued fields. Alias resolution
produces UUID references; the alias itself remains stored on its declaration.
Changing an alias in source requires updating its uses. Display names never
resolve references.</p>
<p><span class="docutils literal"><span class="pre">historical-id</span></span> is context-restricted to the typed semantic baseline reference
slots in section 5.5; it is not an alternative spelling of a live reference.</p>
<p>Unknown fields, duplicate fields, incompatible constructor payloads, unsupported
enum spellings, duplicate aliases/identities, and structural validation errors
reject the entire candidate. No declaration silently replaces another.
@@ -1707,12 +2058,12 @@ deeply nested drafts produce a diagnostic instead of exhausting the parser stack
<h3>6.1. Modular source projects</h3>
<p>The pure <span class="docutils literal">source_project</span> core API compiles a virtual source bundle, containing
<span class="docutils literal">entry</span> and a <span class="docutils literal">files</span> map from logical paths to UTF-8 strings. Its entry is a
root-level <span class="docutils literal">.arcadia</span> file with the source4 project header above. A project entry
root-level <span class="docutils literal">.arcadia</span> file with the source5 project header above. A project entry
MAY also be <span class="docutils literal">include string &quot;;&quot;</span>. An included fragment consists only of ordinary
declarations and include statements; it MUST NOT contain another version header,
project wrapper, or project description. Includes MUST occur at document level,
never inside an element body or typed field value. For example:</p>
<pre class="literal-block">arcadia 4;
<pre class="literal-block">arcadia 5;
project rescue &quot;Rescue&quot; id &quot;00000000-0000-4000-8000-000000000001&quot; {
description &quot;Operational model&quot;;
include &quot;oa/participants.arcadia&quot;;
@@ -1770,12 +2121,17 @@ the source index. Columns remain one-based Unicode scalars within the real file;
spans never refer to concatenated or synthetic source.</p>
<p>Single-string parse/lint APIs remain filesystem-free and reject include
statements with an explicit source-bundle requirement. V1/V2 single-file imports
continue through explicit migration. Modular projects accept source3 and source4;
source3 is upgraded as one project, preserving file paths, includes, nested
continue through explicit migration. Modular projects accept source3, source4 and
source5. Source3 is upgraded as one project, preserving file paths, includes, nested
ownership and comments through targeted token edits. Generated allocation
declarations stay in their interface's existing file. Individual include fragments
are not independently migrated. Missing UUIDs stabilize before migration so new
allocation identities are deterministic on subsequent compilation.</p>
allocation identities are deterministic on subsequent compilation.
An authored source4 bundle upgrades through the strict prose-only baseline
adapter; after any missing UUID stabilization, the version token alone changes
to 5. Existing prose, comments, includes and nested ownership remain intact.
Source5 semantic facet comments follow their closed selector identity through
row reordering and reidentification; UUID-looking text does not act as an Id.</p>
</section>
<section id="nested-element-ownership">
<h3>6.2. Nested element ownership</h3>
@@ -1979,9 +2335,17 @@ explicitly before Apply succeeds.</p>
<h2>9. Persistence and explicit migration</h2>
<p>Loading detects JSON from its first non-whitespace <span class="docutils literal">{</span>; other input is parsed
as Arcadia source regardless of filename. Saving an <span class="docutils literal">.arcadia</span> suffix uses
the accepted V4 source bundle (<span class="docutils literal">arcadia 4</span>); other suffixes use V4 JSON. Both forms preserve the same
the accepted V5 source bundle (<span class="docutils literal">arcadia 5</span>); other suffixes use V5 JSON. Both forms preserve the same
complete model. JSON rejects unknown fields throughout typed payloads,
duplicate maps, unsupported versions, and structural errors.</p>
<p>The explicit schema-4 reader retains the exact old Project/Trace/TraceBaseline
envelope with closed fields. It reuses unchanged typed elements, relations,
diagrams and artifacts. Version4 prose baselines migrate with semantic none;
their names/descriptions remain exact even when live endpoint values differ.
The adapter MUST NOT infer semantic review from live values, and version3/4
inputs MUST reject semantic baseline fields. The migration report retains the
original from_schema and current to_schema 5. Reopening a current5 save produces
no migration report. The real V4 reviewed-prose fixtures remain compatibility input.</p>
<p>The schema-3 reader freezes the changed data payloads and old constructor catalog;
schema-4 fields/constructors MUST NOT be silently accepted as schema 3. Existing
typed PortContract records remain unchanged. Information/Material/Energy retain
@@ -2001,9 +2365,9 @@ reuse, retain the old annotation with a located warning rather than guessing.</p
description), <span class="docutils literal">generated_interface_allocations</span> (id, source, target, alias,
order) and <span class="docutils literal">unresolved_message_values</span> (occurrence, field, reason). These new
report arrays default empty when reading older responses. Version 1 normalizes
through V2, then the typed-port V3 adapter and this shared V4 data upgrade. Original
V2 and real V3 fixtures remain unchanged compatibility inputs. Current exports
always use version 4; reading/migrating alone never saves or changes revisions.</p>
through V2, then the typed-port V3 adapter, shared V4 data upgrade and V5 review
adapter. Original V2 and real V3 fixtures remain unchanged compatibility inputs. Current exports
always use version 5; reading/migrating alone never saves or changes revisions.</p>
<p>Native source Save All checks the original contents of observed dependencies
before staging writes. Existing target files are replaced individually and the
entry is installed last. A transaction journal records originals and intended
@@ -2027,7 +2391,7 @@ defaults without changing any prior identity, name, reference, order, geometry,
artifact or trace field. The original V2 golden source/JSON in
<span class="docutils literal"><span class="pre">crates/oa-core/tests/fixtures</span></span> MUST NOT be regenerated from the newer demo;
they verify that every old persistent field survives. Canonical output writes
the added defaults through the explicit schema-3 and schema-4 migrations.</p>
the added defaults through the explicit schema-3, schema-4 and schema-5 migrations.</p>
<p>The V2 adapter preserves every original identity, alias, prose, parent,
placement, trace and artifact. Generic ports become Legacy with their original
direction/interface record. Existing FE endpoint IDs remain unchanged. For
@@ -2044,13 +2408,13 @@ IDs, required resolutions, generated endpoint IDs/aliases/owners, and boundary
orientation defaults. REST import, replacement, candidate validation and
project snapshots surface the same report. Opening an old server envelope
preserves its resource ID/revision and bytes; the next explicit edit writes
schema 4. Model/source exports contain only the canonical model. Migration
schema 5. Model/source exports contain only the canonical model. Migration
reports are transient response metadata, not persisted model facts.</p>
<p>Admission checks account for generated objects and their references before
construction. Both the old V2 contract and the resulting V4 graph are
construction. Both the old V2 contract and the resulting V5 graph are
validated. Migration itself does not save or increase a stored revision.
Canonical second saves are stable. V1 normalization produces the frozen V2
model and then uses these same V2-to-3-to-4 stages.</p>
model and then uses these same V2-to-3-to-4-to-5 stages.</p>
<p>Save validates a snapshot, writes a sibling temporary file, flushes it, and
atomically replaces the destination. Failed serialization or validation must
not truncate a previous file. External concurrent-writer conflict resolution
@@ -2066,20 +2430,20 @@ step becomes a distinct occurrence element owned by its process. Generated
diagram, node, and occurrence identities use UUID v5 with the old Project ID
as namespace and deterministic role/index names. If such an identity collides,
a deterministic suffix is tried. Reimporting the same fully identified V1
input therefore yields the same V4 result, including repeated occurrences.
input therefore yields the same V5 result, including repeated occurrences.
No System or SA content is invented during an OA-only migration.</p>
<p>V1 source aliases are retained when valid in V2. JSON imports generate the old
canonical aliases deterministically. Any alias newly reserved in V2 is changed
by a deterministic suffix; aliases were not persistent V1 semantic data.
A project alias and aliases for new records are generated without colliding
with retained aliases. The existing bundled V1 text and JSON migrate to equal
V4 projects. Future versions remain errors until a separate migration exists.</p>
V5 projects. Future versions remain errors until a separate migration exists.</p>
</section>
<section id="complete-authoring-example">
<h2>10. Complete authoring example</h2>
<p>This document intentionally omits IDs for new authoring. Apply and Save write
the assigned UUIDs explicitly.</p>
<pre class="code text literal-block"><code>arcadia 4;
<pre class="code text literal-block"><code>arcadia 5;
project harbor &quot;Harbor assistance&quot; {
oa_entity control &quot;Harbor control&quot; {}
oa_activity assess &quot;Assess assistance&quot; {}
@@ -2116,6 +2480,13 @@ stable aliases, strict syntax/reference failures, Unicode source spans,
deterministic V1 migration, occurrence identity, coordinate bits, persistence,
and model-history integration. Core validation and session tests cover domain
mutation rules; native UI and API verification are separate evidence.</p>
<p><span class="docutils literal">cargo test <span class="pre">-p</span> <span class="pre">oa-core</span> <span class="pre">--test</span> r3_semantic <span class="pre">--test</span> r3_source <span class="pre">--test</span> r3_migration <span class="pre">--locked</span></span>
checks the closed facet catalog, partial decisions, constructor scopes, canonical
agreement, target-only preservation, historic references, exact prepared impacts,
source/JSON parity, comment ownership and explicit original1–4 migration. The
source suite includes actual included-file save/reopen and prepared review
writeback. These checks establish core/source behavior; they do not establish
graphical or hosted API acceptance by themselves.</p>
<p>The research record in <span class="docutils literal"><span class="pre">docs/research-v2-semantics.md</span></span> distinguishes method
requirements from Studio design choices and links to original UI evidence.
The following sources inform this language without extending its scope:</p>
+399 -35
View File
@@ -1,7 +1,7 @@
Arcadia Studio OA / SA Language and Editing Contract
====================================================
:Version: 4
:Version: 5
:Status: Normative current implementation contract; first-release integration
:Core implementation: ``crates/oa-core``
:Text suffix: ``.arcadia``
@@ -11,13 +11,15 @@ Arcadia Studio OA / SA Language and Editing Contract
This specification defines the shared Operational Analysis (OA) and System
Analysis (SA) model, its textual language, graphical editing obligations,
validation, persistence, and explicit migration from versions 1, 2 and 3. MUST, MUST NOT,
validation, persistence, and explicit migration from versions 1, 2, 3 and 4. MUST, MUST NOT,
and MAY indicate requirements, prohibitions, and permitted choices.
Schema 4 adds bounded reusable data types, typed scalar defaults, exchange
mechanisms, qualified message values, specialization, identified interface
allocations and reference associations. It retains schema 3 typed ports and
state views. Explicit older readers preserve existing meanings and identities.
Schema 5 adds closed typed semantic review facts to trace baselines, including
historical references that may no longer exist in the live model. It retains
schema 4 bounded reusable data types, typed defaults, exchange mechanisms,
qualified values, specialization, interface allocations and associations, and
schema 3 typed ports and state views. Explicit older readers preserve meanings
and identities without inventing previously reviewed semantic values.
This contract does not by itself establish native acceptance or release completion.
OA describes stakeholders, operational work and needs. SA describes the
@@ -39,7 +41,7 @@ Actual verification evidence is separate from this contract.
2. One authoritative model
--------------------------
A Project stores ``schema_version`` (4), ``id``, ``alias``, ``name``,
A Project stores ``schema_version`` (5), ``id``, ``alias``, ``name``,
``description``, and maps named ``elements``, ``relations``, ``diagrams``,
``artifact_links``, and ``traces``. Each map is keyed by the UUID contained in
its record. Duplicate map keys and mismatching keys/identities are errors.
@@ -347,14 +349,14 @@ without treating it as another active constraint. OA generic ports remain
Legacy; OA Interactions stay activity-to-activity without function ports.
Deleting a referenced legacy interface clears that optional live reference.
A source-3 functional exchange MUST specify both port aliases; the parser does
A current-source functional exchange MUST specify both port aliases; the parser does
not create endpoints. Graphical/command FE creation plans ports atomically,
using unique compatible typed-port reuse or deterministic new endpoints.
Port deletion cascades to incident FEs; FE deletion retains independent ports.
For example::
arcadia 4;
arcadia 5;
project ports_example "Typed function contract" {
sa_function send "Send" {}
sa_function receive "Receive" {}
@@ -377,6 +379,7 @@ For example::
parent @send; contract Legacy;
legacy { direction InOut; interface @old_service; };
}
}
An exclusive region has at most one initial state. Missing initial state is
a completeness warning. Initial states have no incoming transitions and at
@@ -736,7 +739,7 @@ This complete data contract example is also exercised through the REST API:
.. code-block:: text
arcadia 4;
arcadia 5;
project engineering_data "Engineering data" {
sa_data_type voltage "Voltage" {
definition { Real { minimum 0.0; maximum 48.0; }; };
@@ -805,7 +808,7 @@ This complete source example places one state from an existing machine:
.. code-block:: text
arcadia 4;
arcadia 5;
project operating "Operating modes" {
oa_entity crew "Crew" {}
oa_state_machine modes "Modes" { parent @crew; }
@@ -975,10 +978,12 @@ are invalid.
* - Manual
- Engineer-defined relationship between OA/SA elements or relations
A nonempty baseline is ``{ oa { name "..."; description "..."; };
sa { name "..."; description "..."; }; }``. It records last-reviewed names
and descriptions, not a full structural snapshot. Implementations MUST NOT
present it as structural synchronization or a lossless inverse transition.
A nonempty baseline retains the required prose records ``oa { name "...";
description "..."; }; sa { name "..."; description "..."; };`` and optionally
``semantic`` (default none), defined below. The original prose records and typed
semantic facts are distinct review histories. Importing an older prose baseline
MUST NOT fabricate semantic acknowledgements. Neither history is a full model
snapshot, structural synchronization, or a lossless inverse transition.
Explicit counterpart creation, linking, comparison, and reviewed property
update may start in either layer. Ordinary edits MUST NOT automatically
overwrite the counterpart. Deletion MUST NOT silently cascade across layers.
@@ -991,10 +996,10 @@ instead of silently creating a different mapping. Required/subtree process and
chain transitions include their contextual links, owned contributions and
referenced dependencies. New process/chain contribution mappings use Sequence
traces; older Scenario-kind occurrence mappings remain accepted. The current
name/description baseline does not reconcile later topology changes.
prose baseline alone does not reconcile later topology changes.
Name and description are compared independently against each endpoint's
last-reviewed baseline. An unchanged source MUST NOT overwrite an accepted
In the prose-only workflow, name and description are compared independently
against each endpoint's last-reviewed baseline. An unchanged source MUST NOT overwrite an accepted
different target value. A source-only change proposes an update; different
changes on both sides require an explicit decision to take the source, keep
the target, or enter a merged value. An explicit decision records both current
@@ -1002,6 +1007,339 @@ values as reviewed, even when they differ. A target-only edit is preserved
without advancing that field's baseline, so it remains eligible to propagate
in the reverse direction. Equal current values can be recorded as converged.
5.1. Typed semantic review facts
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``baseline.semantic`` is either none or ``{ facets [facet, ...]; }``.
Each facet is a closed, self-tagged typed record, not a free-form path/value
entry. List position does not identify a facet. A scalar fact is exactly
``{ Family { Leaf { oa observation; sa observation; }; }; }``. Each observation
is either ``NotApplicable`` or ``{ Value value; }``, with the value type fixed
by Family/Leaf. Both sides are always canonical OA/SA, even during reverse review.
``{ Value none; }`` means an applicable optional field was absent;
``NotApplicable`` means the field's branch or record was not applicable.
An absent facet means Unreviewed. These meanings MUST NOT be conflated. Each
side is compared with its own last-reviewed value; deliberately unequal values
can be reviewed and unchanged. Reviewing one minimum does not acknowledge a
maximum, unit, default, header or referenced type definition.
Historical facts are independently acknowledged observations. They need not
compose one coherent former model: two bounds can have different review times.
They MAY outlive the endpoint's former record family, constructor and referenced
objects. Historical admission checks their closed payload types, finite values,
text, keys and uniqueness; it MUST NOT require a currently existing referenced
object or current family applicability. In contrast, a new graphical or planned
resolution MUST address the appropriate current record family and have at least
one currently applicable side. Both-side NotApplicable facts are invalid persisted
acknowledgements. A grouped constructor decision retires such obsolete branch facts.
The following table is the exhaustive scalar catalog. ``T?`` denotes an optional
T, ``T[]`` a list, and ``Id`` a historical UUID. Existing enum and typed payload
definitions in sections 3–4 apply. Every listed leaf wraps ``{ oa ...; sa ...; }``.
.. list-table:: Closed scalar semantic facets
:header-rows: 1
:widths: 15 85
* - Family
- Leaves and observation value types
* - Header
- Name, Description: string; Parent: Id?
* - ExchangeItem
- Nature: ExchangeItemKind; Mechanism: ExchangeMechanism; Supertype: Id?
* - Interface
- Supertype: Id?
* - Data
- Constructor: DataConstructor; IntegerMinimum, IntegerMaximum: i64?; RealMinimum, RealMaximum: finite f64?; StringMinimumLength: u32; StringMaximumLength: u32?; RecordSupertype: Id?; Default: DataValue?; Unit: string
* - Field
- Type: TypeRef; Minimum: u32; Maximum: u32?; Unit: string; Direction: ParameterDirection; Storage: FieldStorage; Default: DataValue[]?
* - Port
- Constructor: PortConstructor; ComponentKind: ComponentPortKind; Orientation: ComponentPortOrientation; LegacyDirection: PortDirection; LegacyInterface: Id?
* - Scenario
- Viewpoint: ScenarioViewpoint; Context: Id?; Precondition, Postcondition: string
* - Lifeline
- Represented: Id?
* - Occurrence
- Constructor: OccurrenceConstructor; Precondition, Postcondition: string; Exchange, Sender, Receiver: Id?; MessageKind: MessageConstructor; TimerExpression: string; ReplyTo, Action, ActionLifeline: Id?; ControlKind: ControlKind; Sequence, Scenario, State, StateLifeline: Id?; FragmentOperator: FragmentOperator; BranchGuard: string
* - Event
- Constructor: EventConstructor; TimeKind: TimeEventKind; TimeExpression, ChangeExpression: string
* - State
- Kind: StateKind; Entry, Exit, DoActivity: string
* - Constraint
- Expression: string
* - Relation
- Source, Target: Id; AllocationOrder: u32; Transmission: TransmissionProtocol; Acquisition: AcquisitionProtocol; SourceRole, TargetRole: string; SourceMinimum, TargetMinimum: u32; SourceMaximum, TargetMaximum: u32?; SourceNavigable, TargetNavigable: bool; SourcePort, TargetPort: Id; Oriented: bool; ExtendCondition, SequenceGuard: string; TransitionKind: TransitionKind; Trigger, Guard, Effect: string; StateActionPhase: StateActionKind
5.2. Members, rows, order, and attached records
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The remaining facet forms are closed records. ``keys`` means ``{ oa key-or-none;
sa key-or-none; }``; at least one key MUST be present. ``review`` means the same
required OA/SA observation pair used above.
.. list-table:: Closed keyed and ordered semantic facets
:header-rows: 1
:widths: 17 45 38
* - Outer tag
- Required fields
- Key / reviewed value type
* - Member
- collection; keys; review
- Id / bool
* - Order
- collection; review
- No keys / Id[]
* - Literal
- keys; review
- Exact case-sensitive string / bool
* - Annotation
- keys; review
- ``{ path Id[]; field Id; }`` / MessageValue?
* - Binding
- keys; review
- Referenced lifeline Id / LifelineBinding?
* - Child
- keys; review
- Direct child Id / bool
* - Incident
- keys; end; review
- Relation Id, end Source or Target / bool
* - Artifact
- keys; review
- Artifact Id / nested artifact facet below
Member.collection is exactly Steps, FragmentBranches, FunctionInputItems,
FunctionOutputItems, SignalItems, ExchangeItems, TransitionTriggers,
TransitionEffects or Constrained. Order.collection is exactly Steps or
FragmentBranches. Order is independent of membership and MUST contain each
surviving member exactly once when applied. Membership Keep does not refresh
order history. An automatic insertion requires a unique unchanged mapped anchor;
ambiguous insertion or conflicting inferred orders requires an explicit final
target order. Independent removals can filter the order to surviving members.
Annotation identity is the complete ``(path, field)`` pair. Its MessageValue
payload contains ``path``, ``field`` and ``expression``; the payload key MUST
match its side's selector key. Binding payloads contain ``referenced`` and
``local``; ``referenced`` MUST match that side's key. Each row is one atomic
fact. Changing its key is an explicit old-row removal/new-row addition, not an
expression-only edit. Defaults containing repeated DataValues are atomic optional
vectors because those values have no stable member identity.
Artifact.review is ``{ Leaf { oa observation; sa observation; }; }`` where Leaf
is Presence: bool; Name, Description or Uri: string; Owner: Id;
Relation: ArtifactRelation; Revision or Fragment: string?. Metadata decisions
are authorized on the artifact's attachment in the initial project. An explicit
Owner candidate may move a target-only link and record its resulting Owner and
other selected metadata. Presence remains relative to the trace owner. Subsequent
metadata decisions require its new owner trace; historical facts remain attached
to the trace that recorded them. No review fetches artifact content.
Duplicate scalar selectors and keyed selectors that occupy the same OA slot or
the same SA slot are invalid, even when the other side's key differs. Historical
order vectors MUST NOT repeat an Id. Review history has a limit of 16384 facets,
128 Ids in an annotation path, 1000000 bytes per string, and 1000000 traversal
steps per baseline. Exceeding these implementation work limits produces a
diagnostic; truncation MUST NOT be accepted as a complete review.
5.3. Constructor groups and decisions
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Persisted constructors are discriminator words only:
* DataConstructor: Boolean, Integer, Real, String, Enumeration, Record.
* PortConstructor: FunctionInput, FunctionOutput, Component, Legacy.
* OccurrenceConstructor: Exchange, Action, Control, SequenceReference,
ScenarioUse, State, Fragment, Branch.
* EventConstructor: Signal, Time, Change.
* MessageConstructor: Asynchronous, Call, Reply, Create, Delete, Timer.
When a discriminator has no prior fact after explicit resets, both current sides
are applicable and equal, and the proposed target discriminator is unchanged,
KeepTarget, TakeSource or Candidate acknowledges only that discriminator.
It MUST NOT copy a constructor payload, acknowledge unselected descendants or
retire branch history. Descendant facets may be independently selected in the
same review; their decisions retain their own scopes and final-value audits.
Candidate records still MUST NOT change an unselected facet. Missing applicability
on either side does not qualify as an equal discriminator acknowledgement.
A grouped constructor resolution uses a transient typed target record, then
records the discriminator and each acknowledged descendant as separate facts.
It MUST NOT persist a second whole constructor payload overlapping those leaves.
Data includes its branch fields, literals and coupled default, but excludes Unit.
Port includes its contract, Legacy fields and item members. Event includes its
branch fields and Signal items. Occurrence includes its variant payload, members,
order, annotations and bindings, but excludes outer Precondition/Postcondition.
MessageKind includes its discriminator and dependent TimerExpression. State.Kind
is its own structural facet even though it changes the element's derived kind.
Other dependent changes caused by preparation remain explicit impacts.
A grouped constructor Keep requires an actual discriminator proposal or a
discriminator change since its prior review. Repeating Keep on an already reviewed
unequal pair MUST NOT acknowledge unrelated bound/default drift. A real target switch may
reconsider a previously kept difference. When the target already has the proposed
constructor, reconciliation MUST preserve target-only members rather than replace
its whole list. Removed branch facts participate in agreement as NotApplicable;
facts with neither side remaining applicable are retired.
Semantic review supports these explicit decisions in either direction:
* KeepTarget records the current source and retained target values for only the
selected facet, or the explicitly displayed constructor group.
* TakeSource maps the selected source value through the frozen correspondence and
applies only that facet's delta. Unrelated target edits and target-only collection
members, rows, children and artifacts remain intact. Removing membership unlinks
the target member; it does not authorize deleting the target semantic object.
* Candidate supplies final existing typed Element, Relation or Artifact records.
Only selected facets may differ from the result. Identity, alias and layer are
protected. New artifact creation requires a complete candidate and review of
every introduced metadata facet; removal also reviews the metadata retirement.
Headers have explicit semantic Name/Description facets; the original prose-only
workflow remains supported with its own baseline. Ordinary source editing can
explicitly edit a valid baseline; neither baseline format attests that a human
performed a particular review workflow.
Decision requests, final candidates and prepared impacts are transient editor/API
data. Source persists the resulting ordinary model and typed baseline facts;
it does not persist KeepTarget/TakeSource/Candidate as extra facet fields.
5.4. Correspondence, agreement, and atomic preparation
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Correspondence is an explicit canonical OA-to-SA Id map, including historical
deleted IDs. Names and aliases MUST NOT guess correspondence. Reverse application
uses its inverse and rejects ambiguity. Multiple trace kinds for one endpoint
pair require an explicit trace selection. Changing a keyed pairing requires an
explicit reset naming the prior selector; old pairs are never silently replaced.
If a previously paired member or branch disappears on one side, retain its pair
with the absent/NotApplicable observation, suppressing a redundant one-sided
projection. A newly appearing pair still requires reset of old one-sided history.
All selected source proposals use one immutable initial project. Resets precede
catalog and constructor-scope derivation. Many sources may affect one target only
when their desired values agree on every actual canonical target slot, including
removals and members introduced by another proposal. Child membership and the
actual child's Parent share one slot; incidents share the relation's Source or
Target endpoint; artifact Presence and Owner share the actual artifact owner slot.
Conflicting desired values MUST reject independently of request order.
Materialize the agreed writes once, then audit each desired selected value.
Prepare one ordinary complete Command batch and compare its actual project with
the explicitly reviewed candidate. Additional cleanup, such as inherited message
annotation pruning, is reported as exact object ``before``/``after`` typed records
(or absence for creation/deletion). Every induced impact requires explicit review;
an accepted impact still MUST NOT contradict a selected Keep/Take. Repeat the
selected-value audit after preparation. Baseline updates belong to the same atomic
transaction and source-preserving writeback as model edits.
A plan's base fingerprint detects a stale model; its prepared fingerprint checks
candidate consistency. The plan is client-supplied data, not an authenticated
receipt or proof of its original decisions. Applying it validates the complete
ordinary candidate and preserves generated identities; it does not re-run its
request to generate new identities. Hosted clients additionally use the revision
preconditions defined in ``api.rst``. Invalid/stale plans or failed source writeback
leave the accepted model, source, baseline, revision and history unchanged.
Own drift and linked impacts are separate, with incomplete review explicitly
visible. Absent facts MUST NOT produce a falsely complete Unchanged rollup.
Shared data/interface/event definitions are reviewed on their own traces; their
bounded derived impact walk follows reusable definitions and interface allocations,
not arbitrary architectural neighborhoods, and does not copy them into every
referrer's baseline. Its limits are 4096 objects per side and 32768 edges; exhaustion
is a diagnostic. Successful review remains one undoable transaction. Replanning
unchanged, already acknowledged leaf facts is a no-op only while their requests
remain applicable. A completed constructor switch or first acknowledgement of an
equal discriminator MUST be removed from subsequent requests; repeating it when
no switch remains is rejected. After an artifact moves to another owner,
metadata review through the old owner's trace is no longer
applicable and is rejected. An old plan becomes stale after an actual committed
change.
5.5. Source examples and historical token rules
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
This valid source records deliberately different constructor/unit observations.
Its historical integer minimum is absent; the Real side has no integer branch::
arcadia 5;
project reviewed_dimensions "Reviewed dimensions" {
oa_data_type oa_dimension "Operational distance" {
definition { Integer { minimum 0; maximum 10; }; };
unit "m";
}
sa_data_type sa_dimension "System distance" {
definition { Real { minimum 0; maximum 20; }; };
unit "ft";
}
trace dimension_review "Dimension review" {
oa @oa_dimension;
sa @sa_dimension;
kind Item;
baseline {
oa { name "Approved OA name"; description ""; };
sa { name "Approved SA name"; description ""; };
semantic { facets [
{ Data { Constructor { oa { Value Integer; }; sa { Value Real; }; }; }; },
{ Data { IntegerMinimum { oa { Value none; }; sa NotApplicable; }; }; },
{ Data { Unit { oa { Value "m"; }; sa { Value "ft"; }; }; }; }
]; };
};
}
}
Historical Id slots require ``uuid "..."`` even if the object still exists.
Only typed reference slots inside ``baseline.semantic`` accept that atom.
For example, the following is a valid historical facet value, including a deleted
named type; ``none`` and bare enum Boolean retain their different typed meanings::
{ Field { Type {
oa { Value { Named uuid "40000000-0000-4000-8000-000000000090"; }; };
sa { Value Boolean; };
}; }; }
A qualified annotation uses the same historical UUID atoms in its key and payload.
This complete facet records one old row and an applicable absent target row::
{ Annotation {
keys {
oa {
path [uuid "40000000-0000-4000-8000-000000000091"];
field uuid "40000000-0000-4000-8000-000000000092";
};
sa none;
};
review {
oa { Value {
path [uuid "40000000-0000-4000-8000-000000000091"];
field uuid "40000000-0000-4000-8000-000000000092";
expression "latitude + 1";
}; };
sa { Value none; };
};
}; }
Historical enums require bare words. Ordinary strings, including UUID-looking
units, literal keys and expressions, require quoted strings and remain text
during identity remapping. ``@alias`` is invalid anywhere inside a semantic
baseline. Live parents, trace endpoints and all other live references still
require ``@alias`` and MUST NOT accept the historical UUID atom.
The following substitutions in these examples are invalid:
* ``oa "NotApplicable";`` or ``oa { Value "Integer"; };`` in the Constructor
review: enum observations require bare words of the correct typed enum.
* ``Named "40000000-0000-4000-8000-000000000090";`` or ``Named @oa_dimension;``:
a historical Id requires the explicit UUID atom.
* ``Unit { oa { Value uuid "40000000-0000-4000-8000-000000000090"; }; ... }``:
a text field cannot hold a reference atom.
* Repeating the Unit facet, adding an unknown leaf, or supplying a numeric bound
as a quoted string: closed typed admission rejects the whole candidate.
* Changing only the annotation payload's field UUID suffix from 092 to 093,
leaving the key at 092: the row's key and payload disagree.
* Changing the document header to ``arcadia 4;`` while retaining ``semantic``:
the frozen earlier schema does not admit a future baseline shape.
6. Text grammar and resolution
------------------------------
@@ -1026,7 +1364,7 @@ sets and enum constructors are closed by sections 3–5.
.. code-block:: text
document = "arcadia" "4" ";" project
document = "arcadia" "5" ";" project
project = "project" alias string identity? "{" project-entry* "}"
project-entry = "description" string ";" | declaration
declaration = element
@@ -1038,7 +1376,8 @@ sets and enum constructors are closed by sections 3–5.
identity = "id" uuid-string
fields = "{" (field-name value ";")* "}"
value = string | number | "true" | "false" | "none"
| enum-word | "@" alias | list | fields
| enum-word | "@" alias | historical-id | list | fields
historical-id = "uuid" uuid-string
list = "[" (value ("," value)*)? "]"
The project description defaults to empty and may appear once. There is no
@@ -1060,6 +1399,9 @@ produces UUID references; the alias itself remains stored on its declaration.
Changing an alias in source requires updating its uses. Display names never
resolve references.
``historical-id`` is context-restricted to the typed semantic baseline reference
slots in section 5.5; it is not an alternative spelling of a live reference.
Unknown fields, duplicate fields, incompatible constructor payloads, unsupported
enum spellings, duplicate aliases/identities, and structural validation errors
reject the entire candidate. No declaration silently replaces another.
@@ -1073,13 +1415,13 @@ deeply nested drafts produce a diagnostic instead of exhausting the parser stack
The pure ``source_project`` core API compiles a virtual source bundle, containing
``entry`` and a ``files`` map from logical paths to UTF-8 strings. Its entry is a
root-level ``.arcadia`` file with the source4 project header above. A project entry
root-level ``.arcadia`` file with the source5 project header above. A project entry
MAY also be ``include string ";"``. An included fragment consists only of ordinary
declarations and include statements; it MUST NOT contain another version header,
project wrapper, or project description. Includes MUST occur at document level,
never inside an element body or typed field value. For example::
arcadia 4;
arcadia 5;
project rescue "Rescue" id "00000000-0000-4000-8000-000000000001" {
description "Operational model";
include "oa/participants.arcadia";
@@ -1145,12 +1487,17 @@ spans never refer to concatenated or synthetic source.
Single-string parse/lint APIs remain filesystem-free and reject include
statements with an explicit source-bundle requirement. V1/V2 single-file imports
continue through explicit migration. Modular projects accept source3 and source4;
source3 is upgraded as one project, preserving file paths, includes, nested
continue through explicit migration. Modular projects accept source3, source4 and
source5. Source3 is upgraded as one project, preserving file paths, includes, nested
ownership and comments through targeted token edits. Generated allocation
declarations stay in their interface's existing file. Individual include fragments
are not independently migrated. Missing UUIDs stabilize before migration so new
allocation identities are deterministic on subsequent compilation.
An authored source4 bundle upgrades through the strict prose-only baseline
adapter; after any missing UUID stabilization, the version token alone changes
to 5. Existing prose, comments, includes and nested ownership remain intact.
Source5 semantic facet comments follow their closed selector identity through
row reordering and reidentification; UUID-looking text does not act as an Id.
6.2. Nested element ownership
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -1382,10 +1729,19 @@ explicitly before Apply succeeds.
Loading detects JSON from its first non-whitespace ``{``; other input is parsed
as Arcadia source regardless of filename. Saving an ``.arcadia`` suffix uses
the accepted V4 source bundle (``arcadia 4``); other suffixes use V4 JSON. Both forms preserve the same
the accepted V5 source bundle (``arcadia 5``); other suffixes use V5 JSON. Both forms preserve the same
complete model. JSON rejects unknown fields throughout typed payloads,
duplicate maps, unsupported versions, and structural errors.
The explicit schema-4 reader retains the exact old Project/Trace/TraceBaseline
envelope with closed fields. It reuses unchanged typed elements, relations,
diagrams and artifacts. Version4 prose baselines migrate with semantic none;
their names/descriptions remain exact even when live endpoint values differ.
The adapter MUST NOT infer semantic review from live values, and version3/4
inputs MUST reject semantic baseline fields. The migration report retains the
original from_schema and current to_schema 5. Reopening a current5 save produces
no migration report. The real V4 reviewed-prose fixtures remain compatibility input.
The schema-3 reader freezes the changed data payloads and old constructor catalog;
schema-4 fields/constructors MUST NOT be silently accepted as schema 3. Existing
typed PortContract records remain unchanged. Information/Material/Energy retain
@@ -1407,9 +1763,9 @@ Migration reports additionally contain ``semantic_defaults`` (object, field,
description), ``generated_interface_allocations`` (id, source, target, alias,
order) and ``unresolved_message_values`` (occurrence, field, reason). These new
report arrays default empty when reading older responses. Version 1 normalizes
through V2, then the typed-port V3 adapter and this shared V4 data upgrade. Original
V2 and real V3 fixtures remain unchanged compatibility inputs. Current exports
always use version 4; reading/migrating alone never saves or changes revisions.
through V2, then the typed-port V3 adapter, shared V4 data upgrade and V5 review
adapter. Original V2 and real V3 fixtures remain unchanged compatibility inputs. Current exports
always use version 5; reading/migrating alone never saves or changes revisions.
Native source Save All checks the original contents of observed dependencies
before staging writes. Existing target files are replaced individually and the
@@ -1435,7 +1791,7 @@ defaults without changing any prior identity, name, reference, order, geometry,
artifact or trace field. The original V2 golden source/JSON in
``crates/oa-core/tests/fixtures`` MUST NOT be regenerated from the newer demo;
they verify that every old persistent field survives. Canonical output writes
the added defaults through the explicit schema-3 and schema-4 migrations.
the added defaults through the explicit schema-3, schema-4 and schema-5 migrations.
The V2 adapter preserves every original identity, alias, prose, parent,
placement, trace and artifact. Generic ports become Legacy with their original
@@ -1454,14 +1810,14 @@ IDs, required resolutions, generated endpoint IDs/aliases/owners, and boundary
orientation defaults. REST import, replacement, candidate validation and
project snapshots surface the same report. Opening an old server envelope
preserves its resource ID/revision and bytes; the next explicit edit writes
schema 4. Model/source exports contain only the canonical model. Migration
schema 5. Model/source exports contain only the canonical model. Migration
reports are transient response metadata, not persisted model facts.
Admission checks account for generated objects and their references before
construction. Both the old V2 contract and the resulting V4 graph are
construction. Both the old V2 contract and the resulting V5 graph are
validated. Migration itself does not save or increase a stored revision.
Canonical second saves are stable. V1 normalization produces the frozen V2
model and then uses these same V2-to-3-to-4 stages.
model and then uses these same V2-to-3-to-4-to-5 stages.
Save validates a snapshot, writes a sibling temporary file, flushes it, and
atomically replaces the destination. Failed serialization or validation must
@@ -1480,7 +1836,7 @@ step becomes a distinct occurrence element owned by its process. Generated
diagram, node, and occurrence identities use UUID v5 with the old Project ID
as namespace and deterministic role/index names. If such an identity collides,
a deterministic suffix is tried. Reimporting the same fully identified V1
input therefore yields the same V4 result, including repeated occurrences.
input therefore yields the same V5 result, including repeated occurrences.
No System or SA content is invented during an OA-only migration.
V1 source aliases are retained when valid in V2. JSON imports generate the old
@@ -1488,7 +1844,7 @@ canonical aliases deterministically. Any alias newly reserved in V2 is changed
by a deterministic suffix; aliases were not persistent V1 semantic data.
A project alias and aliases for new records are generated without colliding
with retained aliases. The existing bundled V1 text and JSON migrate to equal
V4 projects. Future versions remain errors until a separate migration exists.
V5 projects. Future versions remain errors until a separate migration exists.
10. Complete authoring example
------------------------------
@@ -1498,7 +1854,7 @@ the assigned UUIDs explicitly.
.. code-block:: text
arcadia 4;
arcadia 5;
project harbor "Harbor assistance" {
oa_entity control "Harbor control" {}
oa_activity assess "Assess assistance" {}
@@ -1537,6 +1893,14 @@ deterministic V1 migration, occurrence identity, coordinate bits, persistence,
and model-history integration. Core validation and session tests cover domain
mutation rules; native UI and API verification are separate evidence.
``cargo test -p oa-core --test r3_semantic --test r3_source --test r3_migration --locked``
checks the closed facet catalog, partial decisions, constructor scopes, canonical
agreement, target-only preservation, historic references, exact prepared impacts,
source/JSON parity, comment ownership and explicit original1–4 migration. The
source suite includes actual included-file save/reopen and prepared review
writeback. These checks establish core/source behavior; they do not establish
graphical or hosted API acceptance by themselves.
The research record in ``docs/research-v2-semantics.md`` distinguishes method
requirements from Studio design choices and links to original UI evidence.
The following sources inform this language without extending its scope:
+5129 -7
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -14,7 +14,7 @@ Arcadia Studio implements a systems-engineering workbench spanning Operational A
| Perspectives and evidence | Bidirectional counterpart creation/linking, reviewed name/description updates, many-to-many typed/manual traces, arbitrary artifact URI/revision/fragment references |
| Views and editing | Independent diagrams, decomposition/allocation nesting, collapse, pan/zoom/Fit/Arrange, editable connection routes and labels, four-side port positioning, direct interaction/functional/boundary exchange tools, contextual menus, SVG export, model explorer and typed properties |
| Text and storage | `arcadia 4` source projects with relative includes and nested declarations, stable aliases/UUIDs, a source-file tree, per-file drafts and diagnostics, comment-preserving graphical updates, exact model/source Undo/Redo, legacy migration, atomic saves and interrupted source-save recovery |
| Automation | Rust REST service for project/source-bundle/query/validation/transaction/transition/artifact workflows, ETag preconditions, bounded requests and atomic current-revision storage |
| Automation and hosted editing | Rust REST service for project/source-bundle/query/validation/transaction/transition/artifact workflows; optional desktop catalog/open/publish, explicit conditional Save, server review and local Save as; ETag preconditions, bounded requests and atomic current-revision storage |
Graphical and textual parity applies to the persistent fields of this profile. Diagram placements are model data; camera, selection, source cursor and undo history are session state. Canvas gestures need not match textual operations one for one: typed inspectors and explicit commands expose precise semantics. Full source replacement must repair dependencies in its candidate, while graphical delete/reidentify commands maintain references automatically.
@@ -41,18 +41,18 @@ These are concrete design responses, not claims that every Capella user experien
| Hard-to-review model files | Modular typed source, stable aliases/UUIDs, preserved comments/formatting and source-aware history | Semantic merges, completion and an LSP are absent |
| Hidden mutation effects | Validated atomic commands, dependency cleanup, identity maintenance, previewed counterpart changes and undo | No distributed editing or automatic structural reconciliation |
| Platform/plugin overhead | Native Rust/Iced desktop and independent headless service | WGPU has substantial dependencies and needs a working graphics adapter |
| UI-coupled automation | Renderer-independent core and documented REST commands/queries | Desktop-hosted model editing is absent; transfer uses import/export |
| UI-coupled automation | Renderer-independent core, documented REST commands/queries and optional hosted desktop editing | Save and server review are explicit; there is no automatic synchronization or merge |
## Required first-release work
Direct scenario/process canvas authoring, semantic-content drift and reviewed counterpart reconciliation, native acceptance of expanded data contracts, persistent coverage/filter views, Home/recent-project and unsaved-work recovery, hosted desktop editing, guidance and final acceptance remain in the [release plan](../harness/PLAN.md). These are unfinished requirements, not exclusions from the release. Direct state editing has scoped Windows/Linux acceptance recorded in [state evidence](../harness/release-state-editing.md). Interrupted source-save recovery restores a save transaction; it does not checkpoint unsaved models or drafts.
Direct scenario/process canvas authoring, semantic-content drift and reviewed counterpart reconciliation, native acceptance of expanded data contracts, persistent coverage/filter views, Home/recent-project and unsaved-work recovery, guidance and final acceptance remain in the [release plan](../harness/PLAN.md). These are unfinished requirements, not exclusions from the release. Hosted desktop editing is implemented; its remaining native acceptance is tracked in the release plan and current state. Direct state editing has scoped Windows/Linux acceptance recorded in [state evidence](../harness/release-state-editing.md). Interrupted source-save recovery restores a save transaction; it does not checkpoint unsaved models or drafts.
## Remaining boundaries
- Logical and Physical Architecture, deployment semantics, executable behavior, complete SysML or Capella metamodel support, and native `.capella`/`.aird` import/export.
- Complete Capella communication/scenario variants beyond the specified Studio profile.
- Semantic merge, completion and language-server tooling. Local external-file checks do not provide a cross-process transaction lock.
- Collaborative editing, server undo/history, retry receipts, multiple service replicas and network-synchronized data directories. The service stores current models under explicit capacity limits; the current desktop transfers hosted data through explicit import/export until the planned client is implemented.
- Collaborative editing, server undo/history, retry receipts, multiple service replicas and network-synchronized data directories. The service stores current models under explicit capacity limits. Desktop edits and Undo remain local until Save; revision conflicts require explicit review, and Reload is an undoable local operation. Credentials stay in memory and remote desktop connections require HTTPS.
- Production-scale performance guarantees, full screen-reader/keyboard diagram support, high-contrast/localized experiences and signed installers.
- Runtime plugin loading or an extension ABI. External tools can use the REST API; additional in-process infrastructure needs a concrete use case.
+34 -2
View File
@@ -85,9 +85,41 @@ For a native Linux installation, create an `arcadia` service account, install th
Ctrl+C/SIGTERM stops admission and drains HTTP requests. Already-started blocking tasks run to their completion boundary. The systemd example permits 45 seconds before a supervisor kill; an interrupted process recovers by loading the last installed envelopes. `/health/ready` means the initial store validation succeeded and the process can respond; it is not an ongoing disk-free-space or integrity probe.
## Desktop and automation integration
## Edit hosted models in the desktop
A desktop or automation client can query current JSON/source, edit with shared typed commands, validate/dry-run, and commit using the original ETag. For hosted editing, keep the user's draft until the service acknowledges the new revision. On `412`, preserve the draft and reload server state for review; a blind replacement retry would discard another client's work.
Start a local service from the repository root, then leave it running while using the desktop:
```sh
cargo run --locked -p oa-server --bin arcadia-server
```
The same command works in PowerShell and a Linux shell and uses the platform-local data directory described above. For a custom store, append `-- --data-dir PATH`; choose a local directory outside a synchronized folder. For an installed release, run `.\arcadia-server.exe --data-dir PATH` on Windows or `./arcadia-server --data-dir PATH` on Linux.
1. In the desktop top bar, choose **Hosted models...**. Enter `http://127.0.0.1:8787` as **Service address**, or your deployed service's HTTPS address. Use the service root, without `/api/v1`; a reverse-proxy path prefix is supported.
2. Enter the configured secret in **Bearer token** if required, then choose **Connect / refresh**. Tokens stay in application memory; they are not saved in model/source files. Leave the token empty for an unauthenticated loopback service.
3. To share the current local model, choose **Publish as new**. This uploads the complete authored source project, including its entry file, included files and comments, and creates a separate service resource. After acknowledgement, the current project's Save destination becomes that hosted resource; an existing local file is not overwritten.
4. To work on an existing resource, search the **Hosted model catalog**, select a model, and choose **Open selected**. **More models** loads another catalog page. Unsaved local work uses the normal save/discard/cancel prompt before opening.
5. Edit through diagrams, properties or **Textual model**. Choose **Save to server**, or press Ctrl+S, to validate/apply drafts and send the complete source project. The header shows the last acknowledged hosted revision and whether local changes remain. Edits and Undo/Redo stay local until an explicit Save.
An invalid source or property draft prevents publication/save and remains available to correct or revert. A rejected server write or failed connection retains the local model and source history. The application does not silently publish the last valid model while dropping an invalid draft. Detached source drafts must be reattached or reverted before hosted Save.
### Review a newer server version
An API client or another desktop can edit the same resource. Each Save includes the exact ETag of the desktop's last acknowledged revision. If the server has changed, it rejects that stale Save with `412 revision_conflict`; the desktop retains local work and checks the latest server version for review. Choose **Check server** to perform the same read explicitly, or retry the check after restoring a failed connection.
The review lists changed, added and removed source files. **View server text** opens a read-only file; **Back to review** returns to the list. Reading the server does not replace local content. **Reload server version** adopts the reviewed bundle as one undoable local change after checking unapplied drafts. Undo can restore the previous accepted local version, but it does not undo the server's revision. There is no automatic merge: inspect and reconcile the intended edits before saving again.
For a connection failure with an unknown write outcome, use **Check previous write** when offered before another Save. If the server holds exactly the submitted bundle, the desktop acknowledges that save. If it differs, review it. An uncertain publication may already have created a resource; refresh and inspect the catalog before choosing **Publish another copy**.
### Save locally and manage the connection
Choose **Save as local...** in Hosted models, or Ctrl+Shift+S, and select an `.arcadia` entry file to retain included files and comments. A successful source-project save switches future Ctrl+S operations to that local destination and leaves the hosted resource unchanged. Choosing JSON exports the model only and keeps the hosted Save destination and unsaved state. Canceling or failing the local save also keeps the hosted destination. **Forget connection** is available after leaving the hosted session and clears the remembered connection/token in memory.
The desktop accepts HTTPS service URLs and HTTP only for `localhost` or a loopback IP address. Credentials, query parameters and fragments in the URL are rejected; put the bearer token in its own field. Redirects are not followed. For remote access, configure a TLS reverse proxy as described above; the server itself does not provide TLS. To replace an expired token, enter the new value and choose **Connect / refresh** for the same service, then retry the check or Save. Connection settings and tokens are not persisted across application restarts.
## Automation integration
Automation clients use the same typed model, validation, authored source and revision preconditions as the desktop. Preserve the submitted draft and original ETag until a write is acknowledged; on `412`, read and reconcile the current version instead of blindly replacing it.
Use `POST /api/v1/projects/import-source-project` to import `{"entry":"model.arcadia","files":{"model.arcadia":"..."}}`. Use `GET` and conditional `PUT /api/v1/projects/{id}/source-project` to export or edit that complete authored workspace. These routes preserve comments, line endings, entry names and module layout. Missing declaration IDs are inserted once; keep the accepted bundle returned by PUT or subsequent GET. Candidate validation accepts `{"format":"source-project","source_project":{...}}` and reports diagnostics without adoption.
+47
View File
@@ -14,6 +14,21 @@
- **Evidence governs completion.** Original Capella was operated in an isolated example and its useful nesting/navigation/transition patterns informed the UI. Independent reviews, real native workflows and automated invariants complement each other. Platform and feature boundaries remain explicit in validation/scope documents.
# First-release decisions (supersede narrower V2 exclusions below)
- R3 production adopts closed typed per-facet reviews (C), selected after the
11-case proof in b2ab2b1 and independent adversarial review. Each self-tagged
fact stores independently reviewed OA/SA typed values; absent facts remain
Unreviewed, and NotApplicable distinguishes an absent constructor branch from
an absent optional value. This supersedes recursive SemanticValue/string paths
and typed-body carrier/mask/order-shadow baselines. Extra finite adapter code
is accepted for fewer persisted-state invariants, not as a generic metamodel.
Atomic keyed rows, target-only preserving member deltas, independent order,
exact constructor groups, explicit mapping resets/canonical target agreement
and actual prepared-impact review remain required. Keep the existing Command
and transition planner boundaries. Artifact reviews attach to semantic traces.
Schema/source5 is explicit, with unchanged data4 compatibility. The authoritative
implementation/ownership/evidence contract is release-semantic-review-implementation.md;
production implementation and release acceptance are still pending.
- OA/SA semantic scope follows `release-semantics-audit.md` R1–R6 and actual Capella workflow evidence. Later Arcadia layers remain future work; missing OA/SA reuse, topology, state/data contracts and semantic transition review do not become release limitations.
- Preserve one typed Rust core and atomic validated model commands. Mission/capability links and process topology are independent of containment and diagram placement. Work/chain reuse uses stable reference occurrences rather than copied semantic objects; sequence guards belong to contextual links rather than global exchanges.
- Desktop lint uses one background parser plus the latest queued source version. Accepted model checks are cached separately from draft diagnostics. Stale results cannot enter a different document or overwrite newer edits. The REST service prepares bounded candidates outside its global store lock and rechecks ETags during commit.
@@ -69,3 +84,35 @@ the leak and native Windows/Linux tests verified the correction in316dc55.
paths were proven ambiguous in native recursive/reused data. Browsing does not
change the selected field or expression; choosing a field remains an explicit
draft edit through the ordinary validated command. No persistent browser schema.
## Semantic review proposals and historical admission (2026-09-06)
R3 stores closed, typed, independently reviewed OA/SA facet pairs. Constructor
facts store discriminators only; explicit group decisions enumerate and retire
their exact branch facts. No persisted recursive value tree, own-record carrier,
review mask or order shadow is introduced. The complete contract is
release-semantic-review-implementation.md.
Derive every source proposal from the same immutable initial project. Reset old
mapping selectors before deriving scopes; compare all desired canonical target
slots, including constructor removals, before one materialization. Audit selected
values against both that result and the ordinarily prepared project. This prevents
sequential last-write wins, even when one source introduces a member another
constructor removes. Preserve target-only members during same-target-constructor
reconciliation. Artifact attachment and explicit owner changes share the actual
artifact's owner slot; child/incident operations similarly share actual parent or
endpoint slots. Exact prepared cleanup impacts require explicit acknowledgement
and may not contradict a selected Keep/Take.
Artifact metadata is observed by identified record after authorization against
its initial attachment. An owner move therefore retains the actual resulting
Owner/Name facts, including target-only links; Presence remains owner-relative.
New metadata decisions require the current attached owner trace. Historical paired
selectors survive disappearance of one side and suppress redundant one-sided
projections. A new pairing cannot suppress prior one-sided history without reset.
Current resolution applicability and historical baseline admission are separate.
New decisions require a compatible current family and an applicable side; historical
typed facts can outlive former constructors/families and deleted references. Their
typed reference visitor supports explicit reidentification but does not add live
deletion dependencies. Derived linked-definition drift remains bounded and exposes
missing review coverage instead of copying a shared definition into every baseline.
+7 -6
View File
@@ -27,12 +27,13 @@ forms, folding, placement and correct camera/tool lifetime. Windows default/mini
and Linux native acceptance plus independent reviews are recorded in
release-state-editing.md. Direct scenario/process gestures remain required.
R6 data contracts are active in the isolated data integration checkout. Freeze the
full payload/dictionary shape as schema/source4, keep existing typed ports, migrate
older formats explicitly while preserving lexical ownership/comments, then verify
GUI/text/REST parity and real engineering workflows. See release-data-contract.md
and the implementation plan in that checkout. R3 semantic drift/reconciliation
follows the R6 freeze; its current prose-only behavior is insufficient.
R6 data contracts and direct scenario/process gestures are integrated; native
acceptance continues on both platforms. Typed R3 core/source5/REST and scalar review
milestones1–2 are integrated and pass full platform checks. Close independent scalar
fixes and native review, then finish constructor/keyed/order/reset/artifact and
multi-source graphical authoring using the existing shared core planner. Source5
migration retains lexical ownership/comments; no flat source rewrite is allowed.
See release-data-contract.md, release-semantic-integration.md and release-semantic-gui.md.
Continue source authoring improvements only through the production parser/updater.
The23-test experiment is evidence, not architecture: independent probes exposed
+125 -156
View File
@@ -2,182 +2,151 @@
## Objective and phase
Direct scenario work is now in isolated `codex/scenario-direct`, checkout
`D:/CodexBuilds/Capella2-scenario-direct`, base ab661f2, dedicated target
`D:/CodexBuilds/Capella2-scenario-target`. Scope and acceptance are recorded in
release-scenario-editing.md. Existing scene/order semantics and typed drafts are
retained; direct drawing, explicit insertion, same-parent reorder and correlated
activation rendering are implemented in d1d8f0b, with independent-review fixes
in 7a66607 for stale scroll slots, actual Iced scroll coordinates, operand-specific
activation segments and repeated picker cost, plus e7e37d4 for passive scroll
observations during async save. DATA b35dede is integrated in dfd2e41 without
conflicts. Combined Windows desktop tests pass 286/286, strict all-target desktop
Clippy and optimized build pass; a copied artifact/manifest is frozen under
`D:/CodexBuilds/Capella2-release1-01a06e2e/ui-scenario-data-windows-dfd2e41`. Exact artifact
hash, before/after failures and timings are in release-scenario-editing.md.
Focused independent recheck reports no remaining medium/high finding. Native
Windows/Linux trials remain pending and root-owned; this branch claims no native
acceptance and does not close process/chain work.
The first-release goal is active and incomplete: usable Rust/Iced OA/SA,
equivalent graphical/source/REST semantics, traceability, direct behavior editing,
offline guidance, lifecycle/hosted workflows and Windows/Linux evidence.
Authoritative objective reread 2026-09-06: attachment10134df9-bea2-4c02-a820-67b53d6b86f7.
PLAN retains full scope; no subsystem checkpoint completes the release.
The first-release goal remains active and incomplete. Deliver usable Rust/Iced
Operational Analysis and System Analysis/System Architecture, equivalent graphical,
textual and REST semantics, artifact traceability, directly editable behavior,
offline guidance, and Windows/Linux release evidence. Later Arcadia layers remain
future releases. The full authoritative objective was reread on 2026-09-06:
attachment10134df9-bea2-4c02-a820-67b53d6b86f7/goal-objective.md.
PLAN.md retains the entire release scope; no subsystem acceptance completes it.
Main remains a documentation pointer on production a8f451e. Current integration is
D:/CodexBuilds/Capella2-data-integration, branch codex/data-contract-integration.
Production merge332e46b + formatc6db9d4 + evidenced114a5d integrate DATA/scenario/
process60d162c and semantic6ad3cb. Scalar fixes5a51a75 integrated as e7fa061 and evidence767634a;
322 desktop tests/strict checks/build pass on both platforms. That worker owns DATA/Linux indexes and targets;
root owns this file, other native ledgers, README and all native input.
Direct state-machine editing is accepted through production316dc55. The main
checkout was fast-forwarded to acceptance a8f451e. R6 data/interface contracts
through b35dede are now integrated into the isolated direct-scenario checkout.
Root retains final main integration and acceptance; the combined direct scenario
has not yet completed native trials.
Meaningful implementation and runtime progress continues; no external blocker exists.
## Accepted foundations and evidence
## Accepted foundations
- Core typed OA/SA semantics, validated atomic commands and ordinary history are
shared by three Rust crates. Typed ports, explicit unresolved migration and
original Capella observations: release-port-{contract,core,migration}.md and
- Typed ports and migration: release-port-{contract,core,migration}.md and
release-capella-workflow-benchmark.md.
- Explorer/port discovery d364d3c: Add port on participants, grouped roots, focused
children, breadcrumbs, contextual paging/search and full-depth outline. Actual
Windows creation,251-root/12-level trials,1100x700 and Linux reopen/font fixes:
release-explorer-usability.md.
- Routing23535b4, viewport366ce44, labels65fa9ab: authored bends/labels, group
movement, fold restoration, Undo/Redo, save/reopen and SVG, including minimum-size
native trials. Dense full solve measured119ms; arbitrary crossings remain possible.
See release-routing-verification.md and release-routing-linux.md.
- Source4e8575b: nested lexical ownership/includes, provenance-aware token writes,
exact accepted source/model history, retained drafts, Save All/recovery and coupled
hosted source/model revisions. Windows/Linux native and actual REST restart,
stale ETag, comment-only edit and cross-file reparent passed. Full422 Windows /
425 Linux tests at that checkpoint. See release-source-integration.md.
- Direct drawing fef2e0d plus tooltip ad7feb5: OA Interaction, SA Functional and
Boundary Exchange by palette click-click or drag; compatible highlights, automatic
function ports, four-side draggable anchors, contextual menus and source folders.
Independent pointer/tooltip findings fixed and native retested. Windows193 desktop
and Linux189+4 focused tests followed earlier full485/488 suites. Scoped native
Windows/Linux acceptance: release-port-drawing.md and release-context-menus.md.
- Explorer grouping/deep hierarchy, contextual port creation, routing, labels,
anchors, direct exchange drawing, menus and source tree: release-explorer-usability.md,
release-routing-{verification,linux}.md, release-port-drawing.md,
release-context-menus.md.
- Modular source/includes, provenance-aware writes, exact source/model history,
retained drafts and coupled hosted revisions: release-source-integration.md.
- Direct state creation/movement/transitions/reconnect, typed forms, fold and
camera/tool lifetime through316dc55, Windows/Linux native: release-state-editing.md.
## Newly accepted state workspace
## Integrated semantics and verification
Production316dc55 includes typed machine views, shared persisted geometry, atomic
region/symbol/state creation, group movement, direct transitions and reconnection,
Internal rows, Local edges, typed detail forms, folding, placement and navigation.
Architecture and Behavior view selections are separate. Drafts survive rejected
source/model commits; passive camera events never apply a form.
R6 data payloads/defaults/inheritance/associations/interface allocations/qualified
message-value paths are integrated. Source3 migration preserves comments/nesting
until Save All. Typed review now uses source/schema5 with explicit migration.
See release-data-{contract,implementation,verification}.md.
Independent/native findings fixed: covered GPU shapes, pseudo-label/edge collision,
selection-relayout drag displacement, missing typed form access, folded-content
access, wrong-view placement Apply, and camera/tool leakage on machine navigation.
An actual owner/view change resets gesture state and fits; returning to the same
view preserves camera/tool. Exact evidence and limitations: release-state-editing.md.
Semantic core supports finite typed facts, historical references, mappings/reset,
constructor groups, canonical agreement and exact prepared impacts. Independent
equal-discriminator review found and fixed first acknowledgment reachability:
initial equal unreviewed discriminators acknowledge only that scalar; true and
historical switches retain grouped rules.353 core tests and independent36 focused/
8 adversarial probes passed. Normative clarification6ad3cb is integrated.
Verification: full551 Windows /554 Linux workspace tests and strict workspace
Clippy at e0b2210. Final316dc55:241 desktop tests, strict all-target desktop Clippy,
and desktop builds on each platform; five new navigation tests included. Two guide
tests passed after help changes. Source/reST/API state contracts and actual HTTP
rejection/revision/source/restart tests passed earlier in the integration.
REST query/preview/apply is implemented and verified after explicit user approval.
Exact prepared candidates preserve generated identities, conditional ETags and
coupled model/source commits. Fingerprints express consistency, not authentication.
48 server tests pass; optimized live loopback checks validate184 schemas and19
actual/typed values, reject9 malformed shapes. Strict reST/catalog checks pass.
No deployment was requested or performed. See release-semantic-integration.md.
Root and independent Windows native trials covered1440x900 and1100x700 creation,
nested movement, first-drag transition, endpoint/menu reconnect, invalid target,
Escape, timed Internal, Local/EntryPoint, placement Apply, Undo/Redo and save/reopen.
Linux native covered1440x900 creation, first transition/move, Undo/Redo, fold and
saved collapsed reopen. Final316dc55 native on both platforms proved same-view
camera/tool preservation and different-machine Fit/Select reset. Logs are empty.
d114a5d integration: Windows718 / Linux721 tests pass, one intentional inheritance
benchmark ignored on each. Strict workspace all-target Clippy, formatting and
optimized desktop/server builds pass. Linux exec80687 exited0; logs are
semantic-workspace-test.log, semantic-workspace-clippy.log and
semantic-release-build.log in Capella2-data-linux-target.
## Active R6 implementation and next actions
Scalar GUI milestones1–2 provide existing-trace scoped Keep/Take/Candidate review,
typed forms, exact ordinary Batch, impact review and source/history. Independent
review found three P2 issues: unrelated invalid draft blocking scoped actions,
combined type-decision form context, and hidden historical keyed identities.
Worker5a51a75 fixes all three;277 desktop tests/Clippy/build pass in SEM. Fresh
independent recheck passed280 tests (277 repository +3 independent probes),
closing all three P2 findings; integrated platform verification also passed. Native scalar
acceptance and structured milestone3 are still required; do not claim full parity.
Checkout D:/CodexBuilds/Capella2-data-integration; branch
codex/data-contract-integration, base5ce3eef+plan2bf4c54+navigation9118218.
Dedicated target D:/CodexBuilds/Capella2-data-target. Contract freeze is schema/source4
with full data payload and dictionary slices together; existing typed ports remain.
See release-data-implementation.md and release-data-contract.md in that checkout.
## Native behavior evidence and current environment
R6 core/source/desktop are frozen at ab661f2. Full Windows607 tests and Linux610
tests pass (one explicit benchmark ignored on each platform); strict workspace
all-target Clippy passes on both. Optimized desktop/server Linux build also passed.
Windows desktop261 tests include the bounded scenario value editor. Exact logs,
binaries and runtime evidence: release-data-verification.md.
Frozen60d162c binaries live under D:/CodexBuilds/Capella2-release1-01a06e2e:
ui-behavior-windows-60d162c and ui-behavior-linux-60d162c. Hashes in the linked ledgers.
Windows prior scenario native passed middle/ALT insertion, guarded correlated
Call/Reply, qualified values, Undo/Redo, save/reopen, minimum layout and viewport
retention. See release-scenario-editing.md. Remaining advanced tasks are explicit.
All4 R6 actual HTTP workflows pass, covering data protocols/artifact identity,
rejected defaults, qualified Call/Reply paths, validation locations, source edits,
reidentification, source3 migration and restart persistence. The published example's
actual server responses passed served/local OpenAPI validation; obsolete shapes
reject. Strict reST rendering and81 OpenAPI schemas passed.
Linux1440x900 Wayland native: numeric Retry budget0..4 rejects default9 without
losing draft; default2 saves, Undo/Redo works. Recursive Destination/Next/Latitude
browser preserves independent Origin expression; save/reopen passed. Created
Validated route Call/Reply in the middle, Undo/Redo and save/reopen passed. Created
SA chain with two existing functions, drew guarded ready link, inserted existing
Navigation request into it, verified guard and original link ID, Undo/Redo and saved
69objects/7links/3expected checks. Final chain reopen and advanced process cases
remain. See release-data-verification.md and release-process-editing.md.
Independent core review found association transition closure pulling unrelated
types and missing empty-enum warning; both corrected and focused tests passed.
Repeated per-ancestor full scans were simplified to one scan per helper call.
Explicit optimized inheritance timings250/500/1000: Windows21.070/89.088/391.590ms,
Linux22.381/111.662/408.129ms. These single-run empty-chain fixtures measure core
validation, not general UI latency. A1000-deep chain remains a pathological cost.
Wayland exec61870 closed normally, exit0 and no serious runtime output. Current Windows semantic trial is exec62786/window2099614, frozen e7fa061,
1100x700. Root owns input. Re-inventory; never reuse closed window4852212.
Original Capella395654 remains available. Do not modify protected user windows.
Wayland compact resizing is unverified: capture separates frame and client into
bounded surfaces. X11 launch fails in dependency xkbcommon-dl because Ubuntu lacks
libxkbcommon-x11-0. sudo installation requires the user's password; user has been
given `sudo apt-get install libxkbcommon-x11-0`. Windows/Wayland testing continues.
Do not bypass authentication or treat this optional environment issue as goal blocked.
Independent desktop review fixes now pass tests: a focused dictionary with24-row
pages replaces unbounded inherited rows; recursive paths support explicit drill-down
and imported finite paths; same-phase styles/same-exchange reselection preserve
valid drafts. Read-only follow-up found no new path/style defect. Paging resets
only on actual layer/document changes; its regression failed before and passes now.
The bounded scenario editor replaces whole-recursion enumeration and per-field
inputs with24-row browsing and a single focused value draft. The reply call picker
no longer filters alternative calls using values belonging to the old exchange.
Automated and independent checks pass; native scenario checks are next.
## Exact next actions
Root exclusively operates native R6. The Windows13-object data model has saved
native bounds/default repair, a typed physical flow/field, allocation protocol
edit, allocation removal/Undo, and inherited-owner navigation. Association endpoint
labels/selectability were exercised. The1100x700 data layout has been inspected.
A separate53-object recursive-operation fixture was prepared with core APIs and
round-tripped for upcoming native scenario trials; fixture construction is not
claimed as native authoring. See the verification note for remaining gates.
1. Finish5a51a75 integration/recheck, update immutable binaries/hashes, operate scalar
review in actual Windows/Linux app including reverse, failure and compact forms.
2. Complete native R6/scenario/process cases (both layers, branching/reuse/reconnect,
cancellation, minimum size and source reopen); fix from observed behavior.
3. Complete structured semantic GUI: constructor groups, keyed/order/reset/artifact
operations and multi-source final-candidate review; retain shared core planner.
4. Finish coverage/filter, Home/recent/recovery, hosted desktop ETag and offline help
requirements. Fresh end-state reviews, full platform/MSRV checks and packaging.
Parallel isolated work: drawing_correctness implements direct scenario gestures in
Capella2-scenario-direct. data_core owns typed semantic review core and
state_architecture_review owns source5/migration in Capella2-semantic-review.
The typed facet C design won the bounded experiment/criticism round; its production
plan and decision are recorded in that checkout. No overlapping writers or targets.
Use dedicated Cargo targets. Windows: cargo test --locked --offline --workspace;
cargo clippy --locked --offline --workspace --all-targets -- -D warnings;
cargo build --locked --offline --release -p oa-desktop -p oa-server.
WSL Ubuntu24.04 uses target/linux-cargo.toml, Rust1.98.1 at
/home/sorokinoleg/.cache/arcadia/rust/bin and Mesa software Vulkan. No remote push,
publication, usage reset or broad recursive target deletion is authorized.
1. State evidence is committed and main fast-forwarded to a8f451e; DATA received
the same evidence in c5276b1. Finish native data-authoring acceptance and bounded
scenario-value authoring, then full integrated Windows/Linux verification.
2. Finish R6 core/source/desktop, integrate actual REST/schema/reST, independent
semantic/correctness/UX review, Windows/Linux checks and real data authoring.
3. Implement R3 typed semantic drift/reconciliation after R6 freezes. Current
baselines/reconciliation only cover prose. Use the experimentally selected typed facet review design C; the semantic
integration checkout records the full scope and source5 migration plan.
4. Complete direct scenario/process gestures, persistent coverage/filters,
Home/recent/recovery, hosted desktop ETag workflows and offline guidance.
5. Fresh final requirements/domain/correctness/native UX/architecture review; full
Windows/Linux/MSRV checks, real runtime, reproducible docs and release packaging.
## Latest native and process checkpoint — 2026-09-06
## Environment and native ownership
Process cf33785 + evidence1b0b072 fixes forced zoom on palette changes and canvas
remount/scroll loss on pending forms, with persistent two-path field labels.
325 desktop tests/strict checks/release pass on each platform. Independent24-test
review closed both original defects but found owner-to-owner Scrollable leakage.
Fix8c60008 resets actual observer child only on (document,owner) changes;25 focused
red/green checks pass. Full platforms and fresh semantic_http_review are active.
semantic_integration owns DATA/Linux indexes and targets for this narrow follow-up.
The user explicitly reauthorized native testing after Escape. Root's f5467e5 R6
window3476204 and exec93768 closed normally after verified source migration and
55-object recursive scenario reopen; no runtime output. The property-browser
replacement build b35dede has passed the root's Windows native retest. The
combined direct-scenario binary is frozen for its separate pending native trial.
Original Capella is present. Older protected user7210312/6228978 were absent;
do not reset/save/close them if they return. Re-inventory before switching apps.
Windows60d162c reopened Linux69-object bundle. At1100x700, inserted named Choice
Route available? on the ready path, then Two paths added Choice/two existing-work
occurrences/Merge with ready/else guards. Undo74→70 and Redo70→74; Save All wrote
74objects/13links/5 incomplete-model checks. Original link identity/ready preserved.
Trial3607004 closed; saved bundle remains in ui-behavior-linux-60d162c/model.
Final camera/owner fixes and advanced OA/process native cases remain unverified.
Evidence: D:/CodexBuilds/Capella2-release1-01a06e2e. State source checkout/target:
D:/CodexBuilds/Capella2-state-integration and -target. Linux source/target:
D:/CodexBuilds/Capella2-state-linux-review and -target. Use distinct Cargo targets
per checkout; shared-cache results were previously caught and excluded.
Scalar Windows e7fa061 actual native: source5 fixture, numeric prior/current
comparison; Keep SA minimum1, Take OA maximum20, candidate default99 rejects and
retains all three decisions; corrected7. Error, editor and Apply fit1100x700.
Additional changes0; Apply atomically changes SA to1..20/default7, OA remains
0..20/default5. Native text verifies comment and values. Undo restores saved state,
Redo restores review; Save All succeeds. Field Type Take OA Boolean + Default
Candidate creates Boolean(false), previews/applies/saves successfully at1100x700.
Current saved working copy: C:/Users/sorok/AppData/Local/Temp/
capella2-semantic-native-fixture-20260906/windows-scalar.arcadia. Runtime stderr empty.
Reverse, mapping, historical identity, reopen and Linux scalar cases remain.
Windows: cargo test --locked --workspace; cargo clippy --locked --workspace
--all-targets -- -D warnings; cargo build --locked -p oa-desktop -p oa-server.
WSL Ubuntu24.04 uses target/linux-cargo.toml and Mesa software Vulkan for native
WSLg. No remote push or usage reset authorized. No broad recursive target deletion.
semantic_gui_design owns SEM message-kind/TimerExpression constructor slice and
compact Traceability toolbar clipping fix (views.rs), plus its semantic module/forms
adapter/GUI ledger and semantic-desktop-target. Scope is one existing trace and a
complete nested constructor family; no API/core change or full parity claim.
semantic_http_review temporarily owns semantic-desktop-target for isolated process
review; GUI worker must wait for release before builds. Root owns all native input.
Latest R6 checkpoint: b35dede Windows264 desktop tests, strict Clippy, optimized
build and root-owned native retest pass for the one-record property browser. Paging and
location changes retain accepted model and typed expressions. f5467e5 Windows
and Linux263 desktop tests, strict Clippy and optimized release passed. Native
Windows source3 migration preserved comments/nesting through explicit Save All
and clean source4 reopen. Native recursive55-object scenario values and paired
Call/Reply history were verified; flat property paths prompted the latest fix.
The independent bounded-browser review reports no remaining medium/high finding.
Linux native R6 workflows remain a root-owned acceptance gate. See
release-data-verification.md. Main remains a8f451e; R6 is not yet accepted.
Latest process closure: DATA/Linux f4cb486, production8c60008.327 desktop tests,
strict Clippy/fmt/build pass on both. Independent26 tests (4 actual Iced observer,
22 process) close owner-scroll findings. Both targets are idle and root-owned.
Frozen ui-process-owner-{windows,linux}-8c60008 await root native verification.
semantic_http_review used a separate Temp target and released it; SEM GUI owns
semantic-desktop-target exclusively. Hosted lifecycle mapping is read-only parallel
work by semantic_integration; no shared index or target reservation for that task.
+28 -3
View File
@@ -37,14 +37,16 @@ requires native workflows, semantic evaluators and fresh independent review.
- Ordinary Document commands atomically synchronize source/model; failed writes
retain drafts. Source comments require occurrence/provenance-aware edits, not
whole-model equality or flat reformatting. Independent counterexamples were fixed.
- R3 existing-target reconciliation remains name/description only. R6 data shape
- Accepted-main R3 existing-target reconciliation remains name/description only;
isolated typed core/source/API are implemented, with desktop integration active. R6 data shape
expansion is active implementation, not accepted functionality.
- R6 ab661f2 full suites passed Windows607/Linux610; f5467e5 desktop263 and
strict Clippy/release passed on both platforms. Native Windows data editing,
typed defaults, allocations, recursive qualified Call/Reply values, history and
saved reopening passed. Source3 migration now preserves comments and nesting.
Native found flat property browsing still ambiguous; one-record browsing with
bounded pages now passes264 desktop tests/Clippy, pending rebuilt native checks.
bounded pages passes264 desktop tests/Clippy/release on both platforms and native
Windows compact/normal browsing, expression retention and saved reopen.
Linux native R6 acceptance remains outstanding.
## Assumptions and open questions
@@ -72,4 +74,27 @@ Finish and independently verify R6, then semantic R3 reconciliation and direct
scenario/process gestures. Complete lifecycle/recovery, hosted editing, guidance
and fresh cross-disciplinary Windows/Linux acceptance. No subsystem checkpoint
completes the first release. Current work is making measured implementation,
review and runtime progress; no external blocker exists.
review and runtime progress. User reauthorized Computer Use. Root resumed combined Windows scenario tests;
middle insertion, history, save, reorder and scroll/Escape cancellation passed.
Three native UI findings are being refined; Linux acceptance remains pending.
Latest verified facts: integrated60d162c passes306 desktop tests/strict Clippy/build
on Windows and Linux. Windows ALT/pinned-actions/viewport retention native passed;
Linux native is active. RESTd130013 passed48 server tests plus served/local schema
validation and independent review after direct user confirmation resolved approval.
New constructor-acknowledgment reachability defect was independently reproduced;
narrow core fix and separate scalar GUI implementation are active in SEM. These
are current unresolved work, not accepted release limitations. See current STATE.
## Current evidence superseding earlier in-progress notes — 2026-09-06
Semantic source5/core/REST and scalar GUI are integrated at d114a5d. Full Windows718/
Linux721 tests, strict Clippy, optimized builds and live semantic REST contracts pass.
The equal initial discriminator acknowledgment defect is fixed and independently
closed. Three scalar GUI P2 fixes5a51a75 are under fresh recheck/integration; structured
authoring remains required. Basic Linux R6, middle scenario and guarded chain insertion
have actual native evidence in their ledgers; advanced and compact cases remain.
X11 lacks libxkbcommon-x11-0, installation requires user's sudo password. User has
the exact fix command; Windows/Wayland progress is independent. This environment
issue is not evidence of a semantic defect or reason to mark the release blocked.
@@ -0,0 +1,59 @@
# First review of equal constructor discriminators
The review planner previously rejected every unchanged-constructor decision even
when that discriminator had never been reviewed. The catalog correctly retained
it as Unreviewed. An isolated executable against core346fb24 reproduced an ordinary
DataType review that acknowledged all seven other facets but could not complete
its own baseline. The same result occurred with no outer baseline, semantic=None,
an empty semantic baseline, and a schema4 trace migrated to schema5. KeepTarget,
TakeSource and an unchanged Candidate all returned "No constructor switch".
Explicitly inserting only the valid discriminator fact made own drift Unchanged.
This was a material completion defect for existing and migrated review workflows,
without evidence of model corruption. Direct source editing of a valid baseline
or an actual constructor switch were possible workarounds, not normal initial
review behavior.
The agreed fix preserves the public request and stored schema. After explicit
resets, a constructor with no historical fact has scalar scope if both current
sides are applicable and equal and the proposed target discriminator is unchanged.
KeepTarget, equal TakeSource and an unchanged-discriminator Candidate then record
only that discriminator. They do not copy a constructor payload, expand descendants,
retire historical branch facts or change model records.
Resolution overlap checks use this effective scope, so separately chosen leaves
can accompany the first discriminator acknowledgement. Its exact canonical target
slot enters the existing agreement map and both final-value audits. A different
source may independently change a descendant, but cannot contradict the retained
discriminator with a constructor switch. Final Candidate record equality still
rejects changes to unselected fields.
Real proposals and historical discriminator changes retain the existing grouped
scope. Repeated requests after the first acknowledgement or a completed switch
still reject when no switch remains. Repeated Keep on a reviewed unequal pair
cannot hide new bound/default drift. One-sided applicability stays on the group
path; a discriminator inapplicable on both sides cannot be acknowledged, even
after resetting its old fact. This avoids automatic history fabrication and does
not suppress missing facts from the drift rollup.
Normative source5 text and its HTML copy now distinguish the first scalar
acknowledgement from grouped constructor decisions. No source syntax, persisted
baseline type, API request shape, migration rule or desktop code changed here.
Verification uses D:/CodexBuilds/Capella2-semantic-target exclusively. The eight
new regressions cover all constructor families (valid Legacy OA/SA ports), both
directions and all decisions; missing/empty/migrated history; simultaneous complete
review; untouched and later changed descendants; reset; Candidate scope; canonical
agreement in either source order; one-sided/disappeared MessageKind; and repeated
unequal Keep/completed-switch rejection. The focused run passed all eight new
tests and all 28 existing r3_semantic tests. Final verification passed all 353 core
tests (33 test summaries, including doc tests) and strict Clippy for every core
target:
```
cargo test -p oa-core --locked --offline --target-dir D:/CodexBuilds/Capella2-semantic-target
cargo clippy -p oa-core --all-targets --locked --offline --target-dir D:/CodexBuilds/Capella2-semantic-target -- -D warnings
```
Logs are in C:/Users/sorok/AppData/Local/Temp/capella2-constructor-ack-review/
as full-core.log and strict-clippy.log. Native/API release acceptance is outside
this bounded fix.
@@ -0,0 +1,47 @@
# Constructor history correction
Scope: `oa-core` only, based on HOST `f6a59de85b815fbea5ec578bb79d0a15819c42b4`. No desktop, source grammar, API shape, or OA port semantics changed by the keyed closure correction.
## Reproduction and decision
The original independent Boolean-to-Enumeration probe passes OA to SA on an unmodified HOST core. Extending the same fixture to SA to OA reproduces the duplicate `Literal(incoming,incoming)` conflict. New production-core regressions reproduce both the literal and `Event.Signal` member failures before the implementation change; `constructor-closure-red.log` records two failures. This is directional catalog projection, not UUID ordering: a live SA-only selector survives alongside the newly paired selector after a constructor copy.
Constructor closure now removes that redundant transient one-sided projection when a pair covers it. Established historical one-sided selectors remain, so changing their correspondence still requires an explicit reset. Both-sided historical pairs and removed branch observations retain their existing rules.
Suppressing the duplicate exposed another reverse-direction defect: target-local literals were lost because their implicit canonical key looked paired even when the source had no member. Preservation now uses observed membership (`true` or an optional row containing a value), with the existing prior source-membership guard preventing resurrection of reviewed removals. This implements the existing language.rst requirement to preserve target-only members when a historical switch reaches the target's current constructor; no normative contract was relaxed.
The intermediate normalization-only run (`constructor-closure-normalized.log`) passes Signal pairing but fails reverse literal preservation. Three final regressions cover both directions, no-history paired Signal insertion, established one-sided history requiring reset, local literal retention, and previously corresponding removed members staying removed. A separate read-only review checked selector normalization and presence semantics; its deletion-guard test recommendation was implemented.
## Verification
Dedicated target: `D:/CodexBuilds/Capella2-scenario-target`.
```powershell
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-scenario-target'
cargo test --locked --offline -p oa-core --test r3_semantic
cargo test --locked --offline -p oa-core
cargo clippy --locked --offline -p oa-core --all-targets -- -D warnings
cargo fmt --all -- --check
cargo run --offline --manifest-path C:/Users/sorok/AppData/Local/Temp/capella2-constructor-literal-host-probe/Cargo.toml
```
All 31 semantic tests and all 356 Windows core tests pass. Strict core all-target Clippy and workspace formatting pass. The independently adapted probe passes both directions against the final implementation (`constructor-independent-green.log`). Full logs: `constructor-core-test.log`, `constructor-core-clippy.log`, `constructor-r3-semantic-green.log` in the dedicated target. Actual GUI/API acceptance and Linux integration remain with the root engineer; this core-only milestone makes no native acceptance claim.
## Separate follow-up: review an existing OA Legacy port
The shared transition planner previously removed every typed SA port before resolving explicit existing-target mappings. Consequently an existing trace could not Keep or supply a Legacy Candidate in SA-to-OA semantic review: it failed with `Review source is outside selected transition mappings`. Two focused regressions reproduced that boundary on the committed keyed correction (`existing-port-red.log`).
The language contract permits same-kind Port traces and reviewed updates from either layer, while retaining OA Legacy-only compatibility. The planner now retains only explicitly mapped SA ports whose selected existing OA target is Legacy. It does not synthesize a target, change a port contract, or un-omit dependent SA implementation records. Structural validation still rejects typed OA Take/Candidate proposals. Automatic/new-counterpart omissions remain unchanged.
Three regressions exercise Keep and Candidate retaining Legacy, unchanged SA source, typed history source/JSON round trips, typed OA proposals failing without mutation, and an explicit new Port counterpart remaining an SA-only omission. A separate read-only review found no scope/invariant defect. Focused red-to-green result: all three pass (`existing-port-green.log`). This change uses the common planner consumed by desktop and REST; it adds no endpoint or transport-specific behavior.
```powershell
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-scenario-target'
cargo test --locked --offline -p oa-core --test r3_semantic reverse_ -- --nocapture
cargo test --locked --offline -p oa-core
cargo clippy --locked --offline -p oa-core --all-targets -- -D warnings
cargo fmt --all -- --check
```
All 359 Windows core tests pass, including all 34 semantic tests and existing portless-OA transition coverage. Strict core all-target Clippy and workspace formatting pass. Logs: `existing-port-core-test.log` and `existing-port-core-clippy.log` in the dedicated target. REST preview uses the corrected core planner at `oa-server/src/routes.rs` and commit uses the shared core prepare path; those handlers were inspected, not changed. GUI constructor tests and actual API/native acceptance remain with root integration; no native input or desktop code was changed here.
+33
View File
@@ -117,3 +117,36 @@ state; draft field and expression are preserved.264 desktop tests and strict
Clippy passed, including wide-record page coverage and draft preservation across
recursive browsing. Optimized build and native retest remain pending. Frozen
f5467e5 window3476204 closed normally; exec93768 had no runtime output.
## b35dede native browser acceptance — Windows
264 desktop tests, strict Clippy and release passed on Windows and Linux. Native
Windows verified32-field pages with unique terminal names, clean paging, explicit
recursive browse, root navigation retaining draft expressions, and normal/1100x700
layout. Saved Origin/Next coordinate/Latitude expression origin.next.latitude + 0.5
while preserving Destination/Latitude. Disk source has both qualified paths, and
reopened native properties confirm the recursive expression and clean55objects/5links.
Frozen Windows SHA256717B8D5AFF501F42473E043BD49EE3E5E017ABBBC84AB8C3AE85AA37121FBD50;
evidence ui-r6-browser-windows/model/project.arcadia. Independent read-only focused
review found no medium/high issue; noted only page1 reset after choosing a field.
Linux ELF SHA256EEDF02913CE69C4D5F2B820A48E375A0D22791A2CEC721D9E29F5532370B9E40
launched in WSLg but capture timed out awaiting app access. No Linux native acceptance.
The user subsequently stopped Computer Use with Escape on the combined Windows
scenario build's initial capture. All native input stopped; code integration/gates
continue. R6 is not accepted until Linux native gates close.
## Root Linux native data trial — 2026-09-06
Frozen60d162c, Wayland1440x900, ui-behavior-linux-60d162c/model/project.arcadia.
ELF SHA2568CDA9215FBD0B653CD6FB81F3884BF891BF931C659E2B926863134BDD4C43B5A.
Created Retry budget (7fa58d09-f9b4-4ab9-9114-1cadc67bf6e9), Integer0..4.
Default9 was rejected with invalid_data_default and retained the draft; correction2
saved to the new included sa/types.arcadia. Undo/Redo restored the entire edit.
Original Navigation request call8888395e retained Origin/Next/Latitude expression
origin.next.latitude +0.5. Destination paging stayed clean and retained its value;
browsed Next coordinate, selected Latitude and saved destination.next.latitude +0.25.
Exact source contains both independent qualified paths. Native reopen loaded clean65
objects after the scenario additions, including Retry budget and recursive values.
No Linux association/allocation trial or compact layout acceptance is claimed.
Wayland process61870 subsequently closed0 without serious runtime output.
+57
View File
@@ -0,0 +1,57 @@
# Hosted desktop editing
Status: implementation, 24 focused tests, all 351 Windows desktop tests, strict desktop all-target Clippy, formatting and optimized build pass. Native and Linux integration acceptance remain with the lead. Base: 1086e54. The lead owns integration, Linux and native acceptance.
## Contract and ownership
The optional desktop adapter uses the existing hosted envelope and source-project REST routes. Open compiles and checks the coupled source/model; Publish creates a resource; Save uses the captured opaque ETag. Document remains the sole accepted local history. Complete authored bundles retain comments/includes and Undo stays local until an explicit save. No server, metamodel, persistence or general synchronization framework was added.
A hosted handle is separate from disk ownership. Successful publication/open attaches it; successful source Save As switches to local ownership. Failed saves and JSON exports preserve the active destination. Requests carry a unique ticket, desktop document identity, local revision and exact compiled snapshot. Stale replies are ignored and late receipts cannot mark newer edits saved or close them.
Conflicts keep local content and fetch a separate server review. Reload is one undoable accepted change. Readback can acknowledge an uncertain write only when the exact submitted bundle matches; publication is never automatically replayed. Known publication IDs survive lost follow-up reads. Review and reconciliation carry their own connection, including when publishing to a different server. Corrected credentials replace only matching-base handles.
Credentials are masked, redacted from Debug/errors and kept in memory. URLs reject credentials/query/fragments; remote connections require HTTPS, with HTTP supported on loopback. Redirects/retries are disabled. Request, response, source and time budgets are bounded. The implementation reuses already locked reqwest/Rustls dependencies.
Owned code: hosted/{mod,client,view}.rs, transport tests, app/hosted.rs and lifecycle tests; narrow App/main/Cargo hooks. The lead explicitly authorized the three views.rs header hooks: hosted status, Hosted models action and active Save label. Other shared UI/property/harness files were untouched.
## Focused evidence
Windows, dedicated target D:/CodexBuilds/Capella2-scenario-target:
```powershell
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-scenario-target'
cargo test --locked --offline -p oa-desktop hosted
```
24 tests pass: 13 transport tests and 11 lifecycle tests. Transport uses actual loopback sockets, production oa-server fixtures and bounded fault fixtures. The actual App test consumes Iced Tasks and routes real HTTP completions through App::update: Publish, graphical edit/Save, competing API edit/412, corrected credentials, direct review Reload, Undo/Save, uncertain-write Check, catalog/Open and Save & continue to New. These are runtime/model tests, not native GUI acceptance.
Fresh independent lifecycle review found stale captured credentials after reconnect. The production fix refreshes matching-base session, uncertain-write and review connections. The actual App regression fails when that refresh is removed and passes when restored; hosted-credentials-red.log records the expected failure. A restored-source timestamp initially reused Cargo's red binary; advancing the source modification time forced the verified rebuild. hosted-focused-test.log records the passing corrected suite. Reviewer re-review found no remaining production defect; its test-coverage suggestion was incorporated with separate direct Reload and uncertain Check paths.
A first build caught adapter visibility and test cursor API issues, both fixed. A network test originally required connection_failed for a closed Windows port; Windows can instead hit the bounded timeout. The assertion now accepts either certain read failure while checking that the result is not an uncertain mutation.
## Remaining acceptance
The final Windows desktop test run passed 351 tests; strict all-target Clippy and formatting passed. Clippy caught an unnecessary test-only PathBuf allocation, which was removed. Hosted inputs now show their disabled state while requests run. The optimized desktop build passed in 2m 46s. Freeze a separate native trial executable with commit/hash. The lead must exercise default/minimum Windows and Linux dialogs, publish/open, graphical and textual save, API artifact changes, conflict/reload/Undo, offline recovery and local Save As. Update guide/server/scope claims that hosted desktop editing is unavailable after integration; this worker does not own those shared documents. Hosted editing remains explicit save/reload, without automatic merges, server history or collaborative synchronization.
## Verified Windows artifact and commands
Production commit: `6fcc238c5240d35be5e2ef828b16fbf1bd01bdbd` (feature `53a509553daa297a8f602262d0498142010829d4`). The isolated worktree is `D:/CodexBuilds/Capella2-hosted-desktop` on `codex/hosted-desktop`.
From that checkout, the completed gates were:
```powershell
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-scenario-target'
cargo test --locked --offline -p oa-desktop
cargo clippy --locked --offline -p oa-desktop --all-targets -- -D warnings
cargo fmt --all -- --check
cargo build --locked --offline -p oa-desktop --release
```
The target contains `hosted-desktop-test.log` (351 passed, zero failures/ignored), `hosted-desktop-clippy.log`, `hosted-fmt.log` and `hosted-desktop-build.log`. The transport tests exercised local sockets only; no external deployment or native input occurred.
Frozen executable: `D:/CodexBuilds/Capella2-release1-01a06e2e/ui-hosted-windows-6fcc238/arcadia-studio.exe`.
SHA-256: `51E4C02F1265D3E9B140C2BCF34A8B11E25E5B0D1D32C83E987FEA4BEB4FC061`.
The release output and frozen copy hashes match. A sibling `manifest.json` records the production commit, gates and pending acceptance. No existing trial artifact was replaced. The assigned scenario target and HOST index are idle and returned to the lead after this evidence checkpoint.
+274
View File
@@ -0,0 +1,274 @@
# Direct process / functional-chain editing
Status: implementation and automated Windows verification passed on 2026-09-06 in `codex/process-direct`, based on
1bf8ebc in `D:/CodexBuilds/Capella2-scenario-direct`; dedicated target remains
`D:/CodexBuilds/Capella2-scenario-target`. The prior scenario branch is retained.
No native acceptance is claimed; root owns Windows/Linux native operation.
## Decisions and observable outcome
An engineer can involve existing work/exchanges more than once, draw guarded
sequence links and controls, insert on a selected path, reconnect its endpoint,
inspect/reuse a subprocess, and undo/save/reopen with contextual identities intact.
Extend the existing derived topology graph and ordinary typed drafts. No core
schema, persisted geometry, source grammar or REST route is added.
Palette tools capture document/owner/revision/epoch and a specific contribution,
link or presentation slot. Canvas click-click and drag connect local occurrence
IDs. A link insertion preserves the captured link UUID, alias, description and
guard on the upstream segment, creates the downstream segment and new occurrence,
and updates `steps` in one validated Batch. Membership/presentation order remains
separate from SequenceLink topology. Incomplete control guards/cardinalities are
warnings, as in the core. Reconnection retains the link's authored properties.
Select always means the local occurrence/relation. Shared source navigation uses
explicit Open; reusable previews expand without copying or reparenting source
contributions. Inner preview endpoints are not caller-owned endpoints. Capella's
richer cross-reference hierarchy linking is an unresolved semantic extension,
not implied by this milestone. Existing explicit legacy-path conversion remains.
Keep canvas and bounded draft independently scrollable, use searchable paged
contextual choices, and default to readable zoom with explicit Fit and scrolling.
Transient drawing cancels on context, tool, bounds, scroll, resize or focus changes;
form inputs survive failed validation/source updates. Reuse passive scroll
observations including async-save delivery. Commit through Document::execute,
acknowledging and selecting the created occurrence only after model/source success.
## Ownership and verification
Own `behavior/topology.rs` and local tests, with narrow `behavior.rs` and `app.rs`
routing/lifecycle changes. Do not change DATA/Linux/semantic checkouts or perform
native input. Verify production pointer events, planners, App and model/source:
1. OA/SA repeated work/exchange involvement preserves shared records.
2. Alternative/parallel/loop creation, guards and incomplete warnings.
3. Specific guarded-link insertion preserves metadata; incompatible adjacency rejects.
4. Reconnect/delete/Undo/Redo retain unrelated paths and exact source.
5. Repeated subprocess reuse, explicit navigation, expansion and recursive rejection.
6. Legacy conversion and presentation reorder independent of explicit topology.
7. Dirty/source-failure/stale/scroll-save/resize/Escape/focus lifetimes retain drafts.
8. Nested source save/reopen and ordinary serialized command/model parity.
Run focused invariants, all desktop tests, strict all-target desktop Clippy and
optimized desktop build with --locked --offline and the dedicated target. Record
actual evidence and remaining native tasks before freezing. Root independently
reviews and native-tests the freeze; full-release completeness remains separate.
## Original implementation freeze and actual evidence — 2026-09-06
Production is `7a1e12bc7c18c5693de688f054293bacd095f4d8` (`7a1e12b`).
The prior `codex/scenario-direct` branch remains at its original head. Production
changes are confined to desktop topology and its local direct/view/test modules,
App process routing, the existing passive-scroll observer and one updated passive
lifecycle regression. `scenario.rs` and scenario message-label construction are
unchanged, allowing the root's separate DATA native fixes to merge independently.
The palette now draws work/exchange/reuse/control contributions and sequence links
with click-click or drag. Selected link endpoint handles reconnect by identity;
selected contribution handles reorder peers within one derived path column.
Source navigation is explicit. Expanded reference previews expose all directly
owned source contributions without copying them or making inner endpoints local.
Every picker has search and 24-item pages. Active tools have distinct styling.
Create/Cancel are outside the form's internal scroller. The canvas stays visible
while forms are pending. New occurrence selection is restored after the final
SetSteps command, and successful edits reveal it in the graph.
The 100% default and manual 50% zoom floor keep authoring legible. Explicit Fit
may produce a small overview. Palette changes preserve the chosen Fit/manual
camera; use the explicit 100% control to enlarge it. This supersedes the original
forced-100% policy after native testing showed that it hid intended endpoints.
No positions are persisted. A source-only exchange sequence retains its
legacy linear meaning: two or more entries have explicit Convert. The original
one-exchange fallback offered only Work/Control/Reuse; independent review found
that this blocked exchange-only continuation. The correction is recorded below.
Disconnected exchange-only topology cannot be represented by the accepted
contract; the editor does not introduce an invisible mode flag.
The new App resize regression initially failed because existing window handling
only invalidated scenario contexts; it now invalidates process contexts too.
Passive viewport and both scroll reports are accepted before busy/modal gates,
validated by document/owner/revision, and cannot apply properties or discard a
behavior draft. The shared observer distinguishes its scenario/process delivery.
A separate captured-link probe failed because two existing parallel guards had
the same hit target. Distinct derived routes now make each relation selectable;
source/target/UUID/guard remain unchanged. The failing probe is saved alongside
the passing final suite as `process-parallel-selection-before.log`.
Actual commands used `--locked --offline` and the dedicated scenario target:
- Full desktop tests: **303 passed**, zero failed/ignored. Seventeen new process
tests exercise production pointer sequences, exact guarded-link insertion,
duplicate-link selection, same-owner reorder, source failure/drafts, busy save
scrolling, dirty source/properties, real resize, reusable-source Open/Back,
bounded widget trees and nested multi-file source/serialized-command parity.
- Strict all-target desktop Clippy: **passed** (`-D warnings`).
- Independent existing core suites `release_topology`, `process_diagnostics` and
`source_nested_updates`: **19 passed**. These cover complete alternative,
parallel and loop topology; two-direction transition closure; deletion and
reidentification; legacy sequences and exact nested source ownership/history.
- Optimized desktop build: **passed**. Owned sources were formatted with Rustfmt
edition 2024; `git diff --check` passed before the production commit.
The source/JSON commands and core invariants are unchanged. These tests do not
claim a new actual HTTP transport trial or native acceptance.
Frozen Windows directory:
`D:/CodexBuilds/Capella2-scenario-target/freeze-process-7a1e12b`.
It contains the binary, manifest and five verification/probe logs. Binary size:
20409856 bytes. SHA-256:
`701ECE6CD329A1CBE50D4DAEBFCE33AD7A93E6906295E74C3290653706F8455A`.
The frozen copy was hash-compared with the release target binary.
## Independent-review correction — exchange-only continuation
Production follow-up is `8bc564b4b2c3b6d12eb61fc09aec7f129f6e45b7` (`8bc564b`).
Independent review found a real dead end: after the first Exchange was created in
an empty process/chain, the accepted model classified it as an ordered exchange
path. There was no path to convert and the fallback omitted another Exchange.
Ordered paths now offer **Append exchange…**, including a single contribution.
The form names the captured final contribution and explains that Create adds an
explicit sequence link. Its contextual picker accepts exchanges whose source
matches the preceding exchange's target. Create emits one existing validated
Batch: new exchange occurrence, preceding-to-new SequenceLink and SetSteps;
existing implied links in longer ordered paths are materialized in that same
transaction. The second contribution therefore restores the direct topology
palette without inventing disconnected topology, global exchanges or a mode flag.
Source reuse, same-owner endpoints and the cross-reference semantic boundary remain.
Two new regressions both exercise OA and SA. The App regression starts with an
empty owner, creates its first exchange, invokes the actual Append event and
creates its second exchange. It verifies exactly one link, two distinct occurrence
IDs, unchanged shared records, one history entry per Create, exact model/source
Undo/Redo and source reopen. The independent proposal regression preserves a
multi-entry ordered path, rejects an incompatible exchange without losing its
form, and validates the serialized ordinary Batch through core execution.
Focused regressions: **2 passed**. Full desktop tests: **305 passed**, zero
failed/ignored. Strict all-target desktop Clippy: **passed** (`-D warnings`).
Commands used `--locked --offline` and the same dedicated target. The final full
suite took 10.75 seconds; Clippy took 4.96 seconds. Production changes are confined
to `topology/direct.rs`, `topology/view.rs`, local tests and `app/process_canvas.rs`.
No native input or new actual HTTP transport trial was performed.
Updated optimized build: **passed** in 38.80 seconds. Current frozen Windows
artifact, manifest and three verification logs are in
`D:/CodexBuilds/Capella2-scenario-target/freeze-process-8bc564b`.
Binary size: 20430336 bytes; SHA-256:
`8BD5C84DF0F4E333C1955E6E9299F431081912E1A504ED13355E0BFA70F8B8BD`.
The frozen copy was hash-compared with the release target binary. The prior
`freeze-process-7a1e12b` remains available as historical evidence.
## Root-owned native acceptance still required
At 1440x900 and 1100x700 on Windows and Linux, operate the actual application:
1. Start an OA process and SA chain, involve matching existing work/exchanges,
repeat a shared source with a distinct contextual name, and draw links with
both click-click and drag. Inspect source identity and contextual properties.
2. Draw Choice/Merge alternatives and Fork/Join parallel paths; enter `ready` and
`else`, create a loop repeat/exit, and inspect incomplete warnings. Insert an
exchange/control on an existing guarded path, reconnect an endpoint and reject
an incompatible target; confirm the original path metadata and other branch.
3. Reuse a subprocess twice, select its body without navigation, expand one local
reference and inspect the count, Open the shared source, then Back to its caller.
Reorder peer contributions and verify links are unchanged; cross-column movement
is rejected with the existing topology explanation.
4. Test Fit/100%, inner and outer wheel/scrollbar changes, resize, Escape and
unfocus during an armed or held gesture. Verify no stale endpoint/slot commits.
Confirm Create/Cancel remains accessible while a long form is open, search/page
changes retain its values, navigation is guarded and save-time scrolling recovers.
5. Start an empty owner, add its first exchange, use Append exchange for its second,
and verify the visible link/direct palette. Also append/convert a legacy multi-exchange path.
Undo/Redo the conversion and direct edits, save/reopen a nested source project,
then inspect the same occurrence/link IDs, guards and shared source records.
No native/UI input was performed in this task. Cross-reference inner-chain linking
remains an explicit unresolved semantic extension; this milestone does not claim
full Capella functional-chain equivalence or complete the first release.
## Root Linux native direct-chain trial — 2026-09-06
Frozen60d162c Wayland1440x900. Created empty SA Functional chain; Work palette and
canvas clicks involved existing Request navigation and Provide navigation. Drew
Sequence link by dragging their cards, entered ready, created it. Exchange palette
and click on that exact guarded path chose existing Navigation request. Create
inserted its occurrence between functions:69objects/7links/3expected model checks.
Fit69% reveals all three cards and guard. Undo68/6 and Redo69/7 passed; Save All
created included sa/behavior/process_new_functional_chain.arcadia.
Chain768ad41c-3bf7-4a89-8163-41963157f1b1; function occurrences a77f6697 and aa6526a7;
exchange occurrence9562fa0f-c9ff-42d1-aff5-da17ec3c827f. Original link
ead846bd-3245-44b7-b6aa-1223865f533b retains ready and now targets inserted exchange;
new link6f32045b-a598-47ae-bfe7-3f9b7f52cdf3 connects exchange to Provide navigation.
Shared function/exchange records are unchanged. Saved file was inspected; native
chain reopen is still pending. Wayland61870 closed0 normally without serious output.
OA, branch/fork/loop, reuse, reconnect/rejection, contextual names, Exchange-first
continuation, compact/Windows trials and gesture cancellation remain required.
## Preserve camera and viewport during process authoring
Root native testing exposed two independent UI defects in the combined build.
After Fit showed a three-card chain at69%, choosing a control reset scale to100%
and hid remote cards. The implementation intentionally enlarged drawing tools for
legibility, but the real scaled input path already supports editing at Fit. Tool
selection now changes only the tool; explicit camera actions and document/owner
navigation retain their existing behavior and gesture epoch invalidation.
Opening Two paths after revealing a remote OR control also reset the canvas to
the beginning without changing source/model. Its two palette rows were conditional
outer Column children. Removing them moved the canvas subtree to a different
sibling slot; Iced discarded its local scroll state. A permanent controls Column
now contains the palette and pending notice, preserving the canvas position in the
widget tree through form open/cancel. This follows the accepted scenario approach.
The compact Two paths form now has persistent First/Second path work and guard
labels, plus Preceding/Following contribution labels. Typed values no longer erase
those meanings. Existing pinned Create/Cancel controls remain in place.
Three regression tests exercise OA and SA: Fit and manual-camera preservation
across all palette tools; production Control insertion and click/drag sequence
links at15%, approximately69% and150% with nonzero canvas/scroll origins; and
actual Iced widget-tree diff through Control, Two paths and Connect form open/cancel
at1440x900 and1100x700. The latter marks the scrollable's opaque local state during
diff only, proving that the state owning offsets survives, while checking the
unchanged document snapshot. Both defect tests failed against original production
code (0.6898551→1.0 and dropped local state), then passed after correction.
The focused process suite passed15 tests. Production correction is `cf33785`.
Full desktop verification passed **325 tests, zero failures/ignored** on both
Windows and Linux. Strict all-target desktop Clippy, whole-workspace formatting
and optimized desktop builds passed on both. Windows build48.01s, Linux37.89s.
No core, source format or REST behavior changed.
Commands from each platform's assigned DATA checkout, using its dedicated target:
```sh
cargo test --locked --offline -p oa-desktop
cargo clippy --locked --offline -p oa-desktop --all-targets -- -D warnings
cargo fmt --all -- --check
cargo build --locked --offline -p oa-desktop --release
```
Windows CARGO_TARGET_DIR is `D:/CodexBuilds/Capella2-data-target`. Linux uses
`/mnt/d/CodexBuilds/Capella2-data-linux-target` and adds
`--config target/linux-cargo.toml` to test, Clippy and build, with Rust1.98.1 from
`/home/sorokinoleg/.cache/arcadia/rust/bin`. Logs in both targets are
`process-camera-desktop-test.log`, `process-camera-desktop-clippy.log`,
`process-camera-fmt.log` and `process-camera-desktop-build.log`. Windows also has
both expected red regression logs and `process-camera-focused.log`.
Frozen native trial executables under
`D:/CodexBuilds/Capella2-release1-01a06e2e`:
- `ui-process-camera-windows-cf33785/arcadia-studio.exe` SHA256
`44D306520AF1FF9BC26AAD96163EEAB41CDCB68ACD6914F802E2B90F02DF7E74`
- `ui-process-camera-linux-cf33785/arcadia-studio` SHA256
`D1B26795579CA02F74BB221DC3E94B2E97946378AA1B4ED6BD6795FD44FBE52E`
Each has a verification manifest; copy hashes match the build output, and Linux
executable access was checked without launching. Both build targets are idle and
returned to the lead. This task used no native input. Root must verify preserved
Fit scale and the remotely scrolled region through tool/form changes and inspect
the persistent compact-form labels on the actual application.
@@ -0,0 +1,80 @@
# Behavior scroll identity correction
Status: implementation, focused checks and full Windows/Linux desktop gates passed.
Independent re-review and native acceptance belong to the lead. Base `1b0b072` includes the independently reviewed process
camera/form corrections in `cf33785`.
The independent actual-Iced probe reproduced a preexisting navigation defect:
`topology::State::enter` cleared its mirrored offset, but the retained Scrollable
still held y180 and immediately reported it under the next process owner. The
reviewer's test is now a repository regression, using Iced's null renderer, real
layout, wheel input and observer messages. It failed against the original code
with y180 instead of zero and passes with this correction.
The existing observer now stores its mounted `(document, owner)` identity at
construction. Its diff recreates local child widget state only when that pair
changes. Keeping the same owner preserves the actual scrollable and its report;
revision, epoch, tool and form transitions are deliberately not identity keys.
No general state framework, model command, schema or persistence change is added.
The shared observer applies the same lifetime to inner-canvas and outer-page
scrolling in process and scenario views.
A second actual-Iced regression seeds a programmatic scroll operation before any
observer report, then varies document, owner, revision/epoch and form-like content
height. This guards against deriving identity from the last notification. Its
matrix covers both message families and both scroll areas: only document/owner
navigation resets to zero, and unchanged-owner edits retain y180. It also verifies
current event context and one-time reporting. Both defect probes failed before the
fix; all three observer tests and 22 process tests now pass. Existing passive clamp,
remount, palette camera, form widget-state and stale-gesture tests remain passing.
The process/native ledger, STATE and other lead-owned documents are untouched.
The lead owns independent re-review and actual native navigation acceptance.
## Platform verification and frozen trial
Production commit: `8c600087dc406149109f19b90250cc3e3b3ac819`.
On both Windows and Ubuntu 24.04, the complete desktop suite passed **327 tests,
zero failed or ignored**. Strict all-target desktop Clippy, workspace formatting
check and optimized desktop build passed. No native application was launched by
this worker; the lead owns runtime acceptance.
Windows commands, from `D:/CodexBuilds/Capella2-data-integration`:
```powershell
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-data-target'
cargo test --locked --offline -p oa-desktop
cargo clippy --locked --offline -p oa-desktop --all-targets -- -D warnings
cargo fmt --all -- --check
cargo build --locked --offline -p oa-desktop --release
```
Linux commands, under WSL Ubuntu-24.04 from
`/mnt/d/CodexBuilds/Capella2-data-linux-review`:
```sh
export PATH=/home/sorokinoleg/.cache/arcadia/rust/bin:$PATH
export CARGO_TARGET_DIR=/mnt/d/CodexBuilds/Capella2-data-linux-target
cargo test --locked --offline --config target/linux-cargo.toml -p oa-desktop
cargo clippy --locked --offline --config target/linux-cargo.toml -p oa-desktop --all-targets -- -D warnings
cargo fmt --all -- --check
cargo build --locked --offline --config target/linux-cargo.toml -p oa-desktop --release
```
Each target contains the corresponding `process-owner-desktop-test.log`,
`process-owner-desktop-clippy.log`, `process-owner-fmt.log` and
`process-owner-desktop-build.log`. Windows also contains the expected red logs
`process-owner-probe-red.log` and `process-mounted-identity-red.log`, plus passing
`process-owner-observer-green.log` and `process-owner-focused-green.log`.
Separate frozen artifacts and manifests are under
`D:/CodexBuilds/Capella2-release1-01a06e2e`:
| Platform | Relative executable | SHA-256 |
| --- | --- | --- |
| Windows | `ui-process-owner-windows-8c60008/arcadia-studio.exe` | `F64C4D3058E53C8F80E7B8F296F9C4AF89A3FC208E8B2ACD9F7A43181EF59293` |
| Linux | `ui-process-owner-linux-8c60008/arcadia-studio` | `00D2E3824C5489FC1DB158ACE81CC10B33B95F6E2EBB8659BABC04B19D5B970D` |
Source and frozen executable hashes were compared and matched. The Linux frozen
binary passed `/usr/bin/test -x`. This is a bounded desktop correction; it does
not replace the previously recorded complete workspace and API contract gates.
+75
View File
@@ -234,3 +234,78 @@ workflow. Its Windows/Linux native tasks remain pending, including scroll/resize
cancellation, middle/operand insertion, execution spans and save recovery. Root
owns final integration into main and native acceptance; process editing remains
outside this task.
## Root combined Windows native trial — 2026-09-06
Frozen dfd2e41 binary above, 1440x900. Native drag inserted Routed request and
its correlated reply before the original third call. The Origin/Latitude value
route.origin.latitude was authored in the bounded browser. Navigation while
pending retained the draft. Create produced57 objects; Undo removed both messages
(55), Redo restored them, Save All persisted the pair and qualified value. Saved
steps are original call/reply, Routed request/reply, original second call/reply.
Valid selected-message reorder retained selection; invalid reply-before-call
reorder reported reply_order and added no history. Undo restored the saved order.
Inner-canvas scrolling and injected Escape cleared armed source gestures, verified
by the next lifeline click starting a new source without completing a draft.
The saved app closed normally (exec63022 exit0, no runtime output).
Native findings: active/inactive tools looked identical, line labels used the
shared exchange name despite distinct occurrence names, and Create/Cancel needed
inner and outer scrolling. Root is refining those three issues before accepting
the milestone. ALT/reuse/resize/save recovery and Linux native remain pending.
Root UI refinement checks:287 desktop tests passed, strict all-target desktop Clippy passed, optimized Windows desktop build passed. Native verification of this refinement remains pending.
## Refined Windows native results and viewport finding
c6c07cc Windows frozen binary SHA256
4A49AD95C466BACBD1D1EA96FFF983BDA2F5DA9B7DCCBF91C387655CEF88B419,
ui-scenario-refined-windows. Native1440x900 and1100x700 show differentiated tool
selection, occurrence-first message names and pinned Create/Cancel. A pending
Navigation outcome ALT fragment retained its name and route.available guard across
width/height resizing. Create produced the fragment and two operands. Native
click-click in its first operand explicitly captured that operand's append slot;
Available route plus correlated Reply were created on Navigation request. Execution
bar ends at that reply. Reply-before-call reorder rejects with reply_order. Save
All writes62 objects/5 links/3 checks; exact nested source shows the guarded operand
contains only that correlated pair. Undo removes both (60); Redo restores saved62.
The saved native trial was closed before the next refinement.
Native also found the canvas jumps to its start whenever the palette is replaced
by a form or restored. Diagnosis: conditional siblings change its Iced widget-tree
position. Group the controls as one stable sibling and verify that actual viewport
is retained across drawing/form/cancel/create. This is distinct from deliberate
new-selection reveal (not implemented yet). Linux287 tests, strict Clippy and
optimized build pass for c6c07cc; native Linux remains pending.
Stable-canvas refinement:287 desktop tests, strict all-target Clippy and optimized Windows build passed. Native retention trial remains pending.
## Stable viewport native closure and process integration
ae1602a Windows native: reopened saved62-object model; scrolled to the guarded
operand, dragged a message before Available route, and verified the pending form
retained that guard/call region. Cancel returned to the same region and clean model.
Runtime stdout/stderr empty; exec86766 exited0. Frozen ui-scenario-stable-windows
SHA256 D0A0D6DAAADA8CEB3700C2A2071ABB15C41386BD9DD49203A7AFCB2869E5C896.
This closes the observed palette/form remount jump. New-selection auto-reveal is
not added. Compact actions/typed resizing and ALT behavior passed in priorc6c07cc.
Process merge60d162c adds reviewed8bc564b without source conflicts. Both platforms
pass306 desktop tests, strict all-target Clippy and optimized build. New frozen
ui-behavior-{windows,linux}-60d162c artifacts have copied62-object models. Linux
exec61870/window4852212 launched and is now under root native test; acceptance
remains pending. Full release scope is unchanged.
## Root Linux native scenario trial — 2026-09-06
Frozen60d162c Wayland1440x900. Native drag in the gap after old reply6 and before
Navigation outcome ALT opened Call+correlated Reply while preserving that region.
Named Validated route and chose existing Navigation request. Create63→65, Undo65→63,
Redo63→65; execution bar ends at its reply. Save All and native reopen passed.
Call ef0ef967-041b-4bf2-86b8-977047a3ca90 and correlated reply
0005a94e-49c1-41e2-82f9-5a6f285dc604 appear before ALT in saved steps.
Model: ui-behavior-linux-60d162c/model/project.arcadia. Linux ALT editing/reuse/
reorder/cancellation/minimum layout remain required. Wayland61870 closed0 normally.
+103
View File
@@ -0,0 +1,103 @@
# Semantic desktop review — milestones 1 and 2
Status: milestones 1–2 implemented and desktop tests/build verified; not full GUI
parity, native acceptance or release acceptance.
Base: `d130013`. Dedicated build target:
`D:/CodexBuilds/Capella2-semantic-desktop-target`. Native verification is root-owned.
## Scope and decisions
- Add an explicit Review semantics action for existing traced counterparts, with
canonical Operational/System comparisons in either application direction.
Preserve the existing counterpart creation and prose workflow with clear labels.
- Review one explicitly selected trace per dialog. Display grouped current and
historical facts, own/linked drift and incomplete review. Names never infer
reference correspondence; provide explicit reference and trace choices.
- Support every applicable scalar leaf through Keep, Take and typed final-value
forms. Candidate records contain the complete combined selected result. Identity,
alias and layer are protected. The closed core remains the validation boundary.
- State.Kind is a single structural scalar. Other constructor switches, keyed
membership/order/annotation/binding/attachment decisions and pairing resets belong
to milestone 3; show them read-only with an honest explanation.
- Core `6bb2583` permits first equal constructor acknowledgment without descendant
expansion. The GUI exposes **Acknowledge equal kind only** when applicable;
previously reviewed constructors remain governed by the core's switch rules.
- Show exact additional prepared impacts and require explicit acceptance. Apply
validates the stored semantic plan and executes its exact command batch through
Document, preserving source, baselines and one-step undo/redo. Do not place or
reveal diagram objects by mutating the model after preview.
- Retain typed drafts and review choices on invalid input, planning conflicts,
stale plans and failed source writeback. Display actionable errors in the dialog.
## Milestones and acceptance
1. Grouped read-only review, named trace/reference choices, exact comparisons and
honest traceability rollups; compact layout usable at 1100 by 700.
2. Scalar decisions and typed candidates; immutable initial observations; combined
final records; exact impact acceptance; atomic document/source commit. Focused
App event/source/history tests, strict Clippy and a dedicated desktop build.
3. Constructor closure, keyed and ordered facts, reset, complete artifact lifecycle,
multi-source review and remaining source/REST parity cases. Still outstanding.
4. Root-owned native review, cross-surface fixtures, package/release acceptance.
## Verification
Verified after the independent scalar review fixes on SEM `6ad3cb`, using only the
dedicated desktop target (core behavior remains based on `6bb2583`):
```powershell
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-semantic-desktop-target'
cargo test --locked -p oa-desktop --quiet
cargo clippy --locked -p oa-desktop --all-targets -- -D warnings
cargo build --locked -p oa-desktop
```
All **277 desktop tests passed**, including **11 original semantic review tests**
and **5 scalar review regression tests**.
Strict Clippy and the desktop build passed. The existing migration test's header
assertion now follows `SCHEMA_VERSION` instead of expecting schema 4.
Coverage includes both-direction Keep/Take/Candidate with exact comments and
source/model/baseline undo/redo; independent selected trace semantic histories;
invalid numeric and protected-property edits; stale-plan and source-writeback
rejection; typed reference mapping mixed with candidates; honest partial rollups;
equal-kind acknowledgment without implicit descendants; State.Kind candidates;
relationship role/bound candidates with retained identity/endpoints; and exact
inherited-annotation cleanup approval, cleared when decisions change. Unselected
invalid scalar drafts remain retained without contaminating another candidate.
The independent scalar review found three gaps, now corrected:
- Structural scalar edits replace only their authorized subtree. Clearing an
invalid selected bound, changing a variant, and adding/removing/reordering a
literal retain untouched raw inputs and editor buffers, including invalid
inputs belonging to another decision.
- Dependent default, reference and port controls read a noncommitting context
composed by the same scalar Keep/Take/Candidate assembler as the request. Raw
candidates remain separate. Incomplete inputs use their stored shape only for
those controls; request creation and Apply still reject invalid selected input.
The Boolean-source/Integer-target default reproduction now offers Boolean
literals in both directions. Tests also cover Candidate type, Keep reversion,
storage-dependent default controls and mechanism-dependent reference choices.
- Keyed fact headings display live names with stable aliases or deleted UUIDs.
Message annotation headings include the complete qualified path and field.
The regression tests exercise App events, the actual default-form helpers and
button availability, exact source retention before Apply, and one-step undo.
Built executable:
`D:/CodexBuilds/Capella2-semantic-desktop-target/debug/arcadia-studio.exe`.
The build target is idle. No native application was launched by this task.
## Remaining work
Milestone 3 must add explicit constructor closure/replacement, keyed and ordered
decisions, historical pairing reset, complete artifact lifecycle and multi-source
review. These facts are currently inspectable, with unsupported writes visibly
identified. The reference table supplies explicit mappings for scalar reference
Take; it does not claim to complete a reset or establish keyed acknowledgments.
Fresh independent scalar GUI review and root-owned native verification are still
required before merging this milestone into release acceptance. In particular,
validate the 1100×700 layout, keyboard/focus continuity, long historical values,
reference selection, source reopening and prepared-impact inspection in the app.
+158
View File
@@ -0,0 +1,158 @@
# Semantic integration verification
Status: verified Windows integration. Merge `332e46b`, formatting correction
`c6db9d4`. This checkpoint does not complete native acceptance, structured semantic
review or the release. The lead retains overall state and native ownership.
## Inputs and preserved work
Merged frozen semantic branch `6ad3cb92df21a5e67c318e31c751fc6a61555ef6` into
DATA `1a07475` (combined direct process/scenario production `60d162c`). Every Rust
change merged automatically. Only two working-memory documents conflicted: both
architectural decisions were retained; DATA's `STATE.md` was preserved unchanged
for the lead's next overall checkpoint.
The integration contains source/schema 5 and explicit historical migration, the
closed typed semantic review core, first equal-constructor acknowledgment, three
REST query/preview/apply operations, current OpenAPI/reST, and scalar desktop
review milestones 1–2. It preserves process continuation, direct scenario editing,
stable scenario viewport, compact controls and recursive value browsing.
Constructor/keyed/ordered/reset/multi-source desktop authoring remains outside the
scalar milestone. No native input or external deployment was performed here.
## Windows verification
Windows, Rust/Cargo 1.98.1. Exclusive target:
`D:/CodexBuilds/Capella2-data-target`. Logs and generated check artifacts:
`D:/CodexBuilds/Capella2-data-target/semantic-integration`.
Run from `D:/CodexBuilds/Capella2-data-integration`:
```powershell
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-data-target'
cargo test --locked --offline --workspace
cargo clippy --locked --offline --workspace --all-targets -- -D warnings
cargo build --locked --offline --workspace --release
cargo fmt --all -- --check
```
All gates passed. Workspace tests: **718 passed, 0 failed, 1 intentionally ignored**
inheritance scaling probe across 39 test summaries: 353 core, 317 desktop, 48 server.
This includes the integrated scenario/process/value-browser regressions, all 11
scalar semantic desktop cases, source5 migration/writeback and real HTTP operations.
Strict all-target workspace Clippy had no warnings. Optimized workspace build passed
in 2m11s. Logs: `workspace-tests.log`, `workspace-clippy.log`, `workspace-release.log`.
The initial formatting check found only the order of two HTTP test module imports.
Commit `c6db9d4` applies rustfmt to that file; the repeated whole-workspace check
passed (`workspace-fmt-after.log`). No production-code correction was necessary.
The ordinary unprivileged Python launcher was denied by the local environment;
the authorized scoped verification process succeeded. No blocker remains here.
## Normative and actual service contracts
With existing Docutils 0.23 and JSON Schema libraries:
```powershell
$env:PYTHONPATH='D:/CodexBuilds/Capella2-docutils;D:/CodexBuilds/Capella2-contract-tools'
python crates/oa-server/tests/semantic_schema.py --check
python crates/oa-server/tests/semantic_http_contract.py D:/CodexBuilds/Capella2-data-target/release/arcadia-server.exe
```
The closed generated catalog check passed: 13 families, 84 scalar leaves and 184
schemas match checked-in OpenAPI. The HTTP script launched the optimized service
on loopback with an isolated temporary store and terminated it after verification.
**184 schemas valid; 19 actual/typed values checked against served and checked-in
contracts; 9 obsolete/malformed shapes rejected.** It verifies import/migration,
query, preview, apply, typed baselines, one-sided selectors and stale rejection.
Log: `actual-semantic-http.log`. This is actual executable transport evidence.
Strict `docs/language.rst` and `docs/api.rst` generation passed with
`docutils.core.publish_file`, `writer_name='html5'`, and settings
`{'halt_level': 2, 'report_level': 2}`. Outputs went into the evidence directory.
Both generated documents exactly match tracked HTML after line-ending
normalization. The full core suite also parsed the complete normative examples
and exercised typed historical source/JSON, included-file writeback and reopen.
## Frozen binaries and handoff
Both optimized executables are frozen in
`D:/CodexBuilds/Capella2-data-target/semantic-integration/freeze-c6db9d4`:
- `arcadia-studio.exe` SHA256
`38AF477646BCC7096E5C71D95D827BF24F9E7ECA1E310DCD7D4AD258ED9E55A0`
- `arcadia-server.exe` SHA256
`C420DFBC30FDA6935E306DF12C310739066389066B878F44D4470E5DD5B1529E`
The directory also contains `SHA256.txt`. No desktop process was launched. The
build target is idle and returned to the lead. Linux integrated checks, native
semantic acceptance, independent scalar-review follow-ups and the structured
semantic desktop milestone remain separate required work.
## Scalar review correction integrated on Windows and Linux
The lead's complete Linux baseline run at `d114a5d` is confirmed from the logs in
`D:/CodexBuilds/Capella2-data-linux-target`: **721 passed, 0 failed, 1 intentionally
ignored** inheritance probe, across 39 summaries. Strict workspace all-target
Clippy and the optimized workspace build also completed successfully. The source
logs are `semantic-workspace-test.log`, `semantic-workspace-clippy.log` and
`semantic-release-build.log`. These were root-run checks, followed by this task's
log inspection; they are not newly rerun workspace checks after the scalar fix.
Cherry-picked the reviewed scalar correction `5a51a7572e8edee6b0bb85fe6aded3a8380e0edd`
as `e7fa061`. The affected forms and semantic files merged automatically, preserving
DATA's recursive browser and direct behavior changes. The Linux checkout fetched
DATA locally and fast-forwarded to the exact same commit. No core or server code
changed. The correction preserves unfinished scoped inputs during structural edits,
uses the combined review candidate for dependent controls, and disambiguates keyed
fact headings with qualified paths and identity. See `release-semantic-gui.md`.
Post-fix verification passed on both platforms: **322 desktop tests passed, zero
failed or ignored**; strict all-target desktop Clippy; whole-workspace formatting;
and optimized desktop builds. Windows build took 1m38s; Linux took 1m33s. The five
additional test functions and expanded cases exercise scoped draft retention,
dependent type/storage/mechanism controls, exact source/history and qualified
fact headings alongside all existing desktop tests.
Commands used on Windows from DATA, with the existing assigned target:
```powershell
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-data-target'
cargo test --locked --offline -p oa-desktop
cargo clippy --locked --offline -p oa-desktop --all-targets -- -D warnings
cargo fmt --all -- --check
cargo build --locked --offline -p oa-desktop --release
```
Matching commands inside Ubuntu 24.04 WSL:
```sh
cd /mnt/d/CodexBuilds/Capella2-data-linux-review
export PATH=/home/sorokinoleg/.cache/arcadia/rust/bin:$PATH
export CARGO_TARGET_DIR=/mnt/d/CodexBuilds/Capella2-data-linux-target
cargo test --locked --offline --config target/linux-cargo.toml -p oa-desktop
cargo clippy --locked --offline --config target/linux-cargo.toml -p oa-desktop --all-targets -- -D warnings
cargo fmt --all -- --check
cargo build --locked --offline --config target/linux-cargo.toml -p oa-desktop --release
```
Logs share the names `scalar-fix-desktop-test.log`,
`scalar-fix-desktop-clippy.log`, `scalar-fix-fmt.log` and
`scalar-fix-desktop-build.log`. Windows logs are in the target's
`semantic-integration` directory; Linux logs are directly in its assigned target.
Both command sessions completed with exit status 0.
Separate native trial directories under
`D:/CodexBuilds/Capella2-release1-01a06e2e` now contain frozen `e7fa061` executables:
- `ui-semantic-windows-e7fa061/arcadia-studio.exe` SHA256
`3E46F001711948FB60E3F65C49810DF5C177228D5DFA609ED9DF3F8C96081F34`
- `ui-semantic-linux-e7fa061/arcadia-studio` SHA256
`94C39037AF47C95FA6EE6515C3EB3B4FFBAC59730286E6EDB5F14B16E7874826`
Each has a `verification.json` manifest. Both frozen hashes equal their build
outputs, and Linux executable access was verified without launching it. Both
checkouts and build targets are returned to the lead. No native input was used,
and no STATE, TASK_LEDGER, README or other native ledger was staged by this task.
Scalar native acceptance and the remaining structured review milestone still
belong to the lead's release plan.
+35
View File
@@ -0,0 +1,35 @@
# Semantic review native evidence
This is partial acceptance, not release completion. Windows frozen e7fa061,
SHA2563E46F001711948FB60E3F65C49810DF5C177228D5DFA609ED9DF3F8C96081F34.
Trial exec62786/window2099614; stderr/stdout captured under ui-semantic-windows-e7fa061.
Source fixture and exact GUI steps: C:/Users/sorok/AppData/Local/Temp/
capella2-semantic-native-fixture-20260906. Original source SHA256
54BA9528F6904428B3DE2E41E3D8CAC6861FA652E5F83F11C1516FB3DB40E937.
Root edits only windows-scalar.arcadia, preserving the generator's starting source.
## Observed Windows workflows
At1440x900, opened source5,9objects/0links/2 expected pending-prose checks.
Traceability range card shows prior bounds0..100/default10 and current OA0..20/5,
SA1..30/15. Selected Keep System minimum, Take Operational maximum and final default.
Invalid99 rejects with invalid_data_default and disables Apply without losing input
or any decision. Resized actual window to1100x700; complete error/editor/actions fit.
Repair7 restores valid preview. Additional changes reports0. Apply changes SA to
1..20/default7 and preserves OA0..20/default5. Native text inspection shows exact
values and leading comment. One Undo returns All changes saved, Redo returns the
review, and Save All writes source. Disk inspection matches the native result.
At1100x700, Enabled flag Type Take Operational proposes Boolean over original
System Integer. Default Set final value then Set literal default produces the
Boolean checkbox/false value directly. Validated preview and Apply succeed; Save
All persists it. This exercises combined type context through actual controls.
## Remaining checks and findings
Reverse decisions, raw invalid bound scoped clearing, named-type mapping, deleted
historical identities, saved reopen and Linux remain required. Error evidence above
covers a normal validation message, not arbitrarily large diagnostics.
Native found compact Traceability toolbar clips Artifact after the semantic button;
semantic_gui_design owns a narrow wrapping correction. No scalar-modal defect found
in the tested paths. Structured review is a separate incomplete milestone.

Some files were not shown because too many files have changed in this diff Show More