feat: preserve nested source projects across desktop and REST

This commit is contained in:
2026-09-06 00:17:20 +03:00
parent d364d3c058
commit 4e8575bd11
32 changed files with 4283 additions and 417 deletions
+16 -3
View File
@@ -67,9 +67,16 @@ Connections start with orthogonal routes that avoid cards and container headers.
arcadia 3;
project harbor "Harbor assistance" {
oa_entity control "Harbor control" {}
oa_activity assess "Assess assistance" {}
oa_activity respond "Respond to requests" {
oa_activity assess "Assess assistance" {}
}
allocation performs "Performs" { source @control; target @assess; }
sa_system service "Harbor assistance system" {}
sa_actor operator "Harbor operator" {
sa_port radio "Radio" {
contract { Component { kind Standard; orientation Unset; }; };
}
}
sa_function evaluate "Evaluate assistance request" {}
trace assessment_trace "Operational realization" {
oa @assess; sa @evaluate; kind Refinement;
@@ -79,8 +86,12 @@ project harbor "Harbor assistance" {
New declarations may omit IDs; Apply inserts assigned UUIDs into their source. Keep existing identity clauses to preserve objects. Stable `@aliases` survive display-name changes and support forward references. The [normative specification](docs/language.rst) covers every supported declaration, typed constructor, relationship and persisted diagram field.
Nested elements inherit their enclosing element as semantic parent. Allocation remains an explicit relationship: nesting a function inside a System would confuse containment with responsibility and is rejected. An explicit `parent` on a nested declaration must agree with its enclosing owner. Flat declarations with `parent @alias` remain supported. Open [the six-file source project](examples/source-project/project.arcadia) for a complete OA/SA example with typed ports, matching functional and boundary exchanges, allocations and saved views.
Applying valid source commits the reachable source bundle as one model transaction. Graphical edits update the owning declarations while preserving unaffected formatting and comments. Invalid source remains in its file buffer with located diagnostics, including while another file is open. **Undo text** and **Undo model** are distinct: model Undo/Redo restores the accepted model and its exact source documents together. JSON export carries model semantics; comments and source-file organization belong to the source project. Version 1 source/JSON is migrated explicitly without inventing SA content.
Reparenting a nested element through Properties moves its declaration, descendants and attached comments to the new owner's body, including across files. Existing flat declarations retain their file organization. New children appear inside their owner's source body. Separate general file or group notes from the next declaration with a blank line to keep those notes in place when it moves.
## 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.
@@ -97,9 +108,9 @@ cargo run --locked -p oa-server --bin arcadia-server
The default is `127.0.0.1:8787`, with a platform-local data directory. `--data-dir PATH`, `--bind IP:PORT` and `--token-file PATH` configure deployment; binding outside loopback requires a bearer token. See [server setup](docs/server.md), the [REST contract](docs/api.rst), [OpenAPI](docs/openapi.json) and [deployment examples](deployment).
The API queries and edits complete models, canonical source, elements, relationships, artifacts and traces. Transactions and transition previews use the same core validation as the desktop. Writes require the returned ETag in `If-Match`. The server retains the current revision only.
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 current import endpoint accepts a complete JSON model or standalone canonical source; it does not resolve a desktop project's included files.
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:
```sh
curl --fail-with-body -X POST http://127.0.0.1:8787/api/v1/projects/import \
@@ -108,6 +119,8 @@ curl --fail-with-body -X POST http://127.0.0.1:8787/api/v1/projects/import \
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.
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.
## Development
```sh
+196 -74
View File
@@ -898,6 +898,10 @@ pub(crate) struct Declaration {
fields: BTreeMap<String, Ast>,
offset: usize,
pub(crate) span: Span,
/// The containing element's identity, independent of an authored parent field.
pub(crate) lexical_owner: Option<Id>,
/// Exact body interior, excluding the opening and closing braces.
pub(crate) body: Span,
pub(crate) id_insertion: Option<usize>,
pub(crate) field_spans: BTreeMap<String, Span>,
}
@@ -952,6 +956,9 @@ pub(crate) enum EditKind {
List(Vec<EditValue>),
}
pub(crate) struct EditDeclaration {
pub(crate) span: Span,
pub(crate) lexical_owner: Option<Id>,
pub(crate) body: Span,
pub(crate) header: BTreeMap<&'static str, Span>,
pub(crate) fields: EditValue,
}
@@ -995,6 +1002,27 @@ pub(crate) fn edit_declaration(source: &str) -> Result<EditDeclaration, Box<Diag
},
)));
}
let mut declarations = edit_positions(source, unit)?;
Ok(declarations.pop().expect("one declaration").1)
}
/// Editing positions for every occurrence in lexical preorder. This is syntax
/// analysis only: intermediate command edits may have duplicate identities or
/// parent fields that disagree with their as-yet unmoved lexical owner. Callers
/// retain occurrence provenance across such edits, then compile the final text.
pub(crate) fn edit_declarations(
source: &str,
entry: bool,
limits: ParseLimits,
) -> Result<Vec<(Id, EditDeclaration)>, Box<Diagnostic>> {
let unit = parse_unit(source, entry, limits)?;
edit_positions(source, unit)
}
fn edit_positions(
source: &str,
unit: SourceUnit,
) -> Result<Vec<(Id, EditDeclaration)>, Box<Diagnostic>> {
let tokens = strict_tokens(source).map_err(|e| Box::new(diagnostic(source, e)))?;
let mut ends: Vec<_> = tokens.iter().map(|token| token.span.end).collect();
let mut stack = Vec::new();
@@ -1055,21 +1083,48 @@ pub(crate) fn edit_declaration(source: &str) -> Result<EditDeclaration, Box<Diag
tokens: &tokens,
ends,
};
let body = tokens
.iter()
.position(|token| token.text(source) == "{")
.expect("declaration body");
let fields = EditValue {
span: Span {
start: tokens[body].span.start,
end: positions.ends[body],
},
kind: EditKind::Record(positions.fields(&unit.declarations[0].fields)),
};
Ok(EditDeclaration {
header: edit_header(source)?,
fields,
})
unit.declarations
.into_iter()
.map(|declaration| {
if declaration.id_insertion.is_some() {
return Err(Box::new(diagnostic(
source,
Error {
code: "invalid_source",
offset: declaration.span.start,
message: "Stabilize declaration identities before editing source.".into(),
},
)));
}
let roles: &[&str] = if matches!(declaration.kind, DeclKind::Node) {
&["kind", "alias", "id_keyword", "id"]
} else {
&["kind", "alias", "name", "id_keyword", "id"]
};
let start = positions.token(declaration.span.start);
let header = roles
.iter()
.zip(&tokens[start..])
.map(|(role, token)| (*role, token.span))
.collect();
Ok((
declaration.id,
EditDeclaration {
span: declaration.span,
lexical_owner: declaration.lexical_owner,
body: declaration.body,
header,
fields: EditValue {
span: Span {
start: declaration.body.start - 1,
end: declaration.body.end + 1,
},
kind: EditKind::Record(positions.fields(&declaration.fields)),
},
},
))
})
.collect()
}
struct Parser<'a> {
source: &'a str,
@@ -1143,17 +1198,8 @@ impl Parser<'_> {
self.take("{")?;
let mut fields = BTreeMap::new();
while self.text() != "}" {
let key = self.text().to_owned();
let offset = self.current().span.start;
if !matches!(
self.current().class,
TokenClass::Keyword | TokenClass::Identifier
) {
return Err(self.error("Expected a field name or '}'."));
}
self.cursor += 1;
let value = self.value()?;
self.take(";")?;
let (key, value, _) = self.field()?;
if fields.insert(key.clone(), value).is_some() {
return Err(Error {
code: "invalid_source",
@@ -1165,6 +1211,36 @@ impl Parser<'_> {
self.take("}")?;
Ok(fields)
}
fn field(&mut self) -> Result<(String, Ast, Span), Error> {
let key = self.text().to_owned();
if !matches!(
self.current().class,
TokenClass::Keyword | TokenClass::Identifier
) {
return Err(self.error("Expected a field name or '}'."));
}
self.cursor += 1;
let start = self.current().span.start;
let value = self.value()?;
let end = self.tokens[self.cursor - 1].span.end;
self.take(";")?;
Ok((key, value, Span { start, end }))
}
fn nested_header_kind(&self) -> Option<DeclKind> {
let kind = declaration_kind(self.text())?;
let alias = self.tokens.get(self.cursor + 1)?;
let following = self.tokens.get(self.cursor + 2)?;
// A node's ordinary `diagram @view;` field is not a declaration.
if !crate::valid_alias(alias.text(self.source)) {
return None;
}
if matches!(kind, DeclKind::Node) {
matches!(following.text(self.source), "id" | "{").then_some(kind)
} else {
(following.class == TokenClass::String).then_some(kind)
}
}
fn value(&mut self) -> Result<Ast, Error> {
if self.value_depth >= 64 {
return Err(self.error("Typed values may not nest more than 64 levels."));
@@ -1301,15 +1377,54 @@ impl Parser<'_> {
} else {
None
};
let mut declarations = Vec::new();
let mut declarations: Vec<Declaration> = Vec::new();
let mut includes = Vec::new();
let mut graph_count = 0usize;
while if entry {
self.text() != "}"
} else {
!self.text().is_empty()
} {
// Declarations are a flat preorder with a stack of open body indices.
// Semantic nesting can be deep without recursing through the Rust stack
// or conflating declaration depth with the typed-value depth budget.
let mut open: Vec<usize> = Vec::new();
loop {
let offset = self.current().span.start;
if let Some(&owner) = open.last() {
if self.text() == "}" {
declarations[owner].body.end = offset;
declarations[owner].span.end = self.current().span.end;
self.cursor += 1;
open.pop();
continue;
}
if self.text().is_empty() {
return Err(self.error("Expected a field, element declaration, or '}'."));
}
if self.text() == "include" {
return Err(
self.error("Includes must occur at document level, outside declarations.")
);
}
if let Some(kind) = self.nested_header_kind() {
if !matches!(declarations[owner].kind, DeclKind::Element(..))
|| !matches!(kind, DeclKind::Element(..))
{
return Err(self
.error("Only element declarations may nest inside an element body."));
}
} else {
let (key, value, span) = self.field()?;
let declaration = &mut declarations[owner];
if declaration.fields.insert(key.clone(), value).is_some() {
return Err(Error {
code: "invalid_source",
offset,
message: format!("Duplicate field '{key}'."),
});
}
declaration.field_spans.insert(key, span);
continue;
}
} else if (entry && self.text() == "}") || (!entry && self.text().is_empty()) {
break;
}
if self.text() == "include" {
if !includes_allowed {
return Err(self.error("An include requires a source bundle; single-string parsing never reads files."));
@@ -1326,7 +1441,8 @@ impl Parser<'_> {
));
continue;
}
if self.text() == "description"
if open.is_empty()
&& self.text() == "description"
&& let Some(mut entry_header) = header.take()
{
if entry_header.description.is_some() {
@@ -1370,41 +1486,29 @@ impl Parser<'_> {
};
let id_insertion = (self.text() != "id").then_some(self.current().span.start);
let id = self.optional_id()?;
let fields = self.fields()?;
let field_spans = fields
.iter()
.map(|(key, value)| {
let start = value.offset();
let mut depth = 0usize;
let mut end = start;
for token in
self.tokens[self.tokens.partition_point(|t| t.span.start < start)..].iter()
{
match token.text(self.source) {
";" if depth == 0 => break,
"{" | "[" => depth += 1,
"}" | "]" => depth = depth.saturating_sub(1),
_ => {}
}
end = token.span.end;
}
(key.clone(), Span { start, end })
})
.collect();
let body_start = self.current().span.end;
self.take("{")?;
let lexical_owner = open.last().map(|&owner| declarations[owner].id);
declarations.push(Declaration {
kind,
alias,
name,
id,
fields,
fields: BTreeMap::new(),
offset,
span: Span {
start: offset,
end: self.tokens[self.cursor - 1].span.end,
end: body_start,
},
lexical_owner,
body: Span {
start: body_start,
end: body_start,
},
id_insertion,
field_spans,
field_spans: BTreeMap::new(),
});
open.push(declarations.len() - 1);
}
if entry {
header.as_mut().expect("entry header").body.end = self.current().span.start;
@@ -1449,8 +1553,19 @@ pub(crate) fn resolve_declarations(
DeclKind::Element(kind, layer) => {
let description =
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
let parent =
let explicit_parent = d.fields.contains_key("parent");
let mut parent =
take_optional_id(&mut fields, "parent").map_err(&conversion_error)?;
if let Some(owner) = d.lexical_owner {
if explicit_parent && parent != Some(owner) {
return Err(Error {
code: "conflicting_parent",
offset: d.fields["parent"].offset(),
message: "Explicit parent must equal the containing element; move the declaration to change its lexical parent.".into(),
});
}
parent = Some(owner);
}
let mut initial = if kind == Kind::Port {
if !fields.contains_key("contract") {
return Err(conversion_error(
@@ -1667,6 +1782,21 @@ pub fn analyze(source: &str) -> Analysis {
.iter()
.filter(|t| !matches!(t.class, TokenClass::Whitespace | TokenClass::Comment))
.collect::<Vec<_>>();
// Recover matched body bounds once. Scanning each aggregate's descendants
// again would make tolerant navigation quadratic on deeply nested drafts.
let mut body_ends = vec![None; significant.len()];
let mut bodies = Vec::new();
for (index, token) in significant.iter().enumerate() {
match &source[token.span.start..token.span.end] {
"{" => bodies.push(index),
"}" => {
if let Some(open) = bodies.pop() {
body_ends[open] = Some(token.span.end);
}
}
_ => {}
}
}
let mut declarations = Vec::new();
for (i, t) in significant.iter().enumerate() {
let word = &source[t.span.start..t.span.end];
@@ -1709,23 +1839,11 @@ pub fn analyze(source: &str) -> Analysis {
let mut end = significant
.get(j)
.map_or(alias_token.span.end, |t| t.span.end);
let mut depth = 0usize;
for next in significant.iter().skip(j) {
match &source[next.span.start..next.span.end] {
"{" => depth += 1,
"}" => {
if depth == 0 {
break;
}
depth -= 1;
if depth == 0 {
end = next.span.end;
break;
}
}
_ => {}
}
end = next.span.end;
if significant
.get(j)
.is_some_and(|t| &source[t.span.start..t.span.end] == "{")
{
end = body_ends[j].unwrap_or(source.len());
}
declarations.push(DeclarationSpan {
alias: alias.into(),
@@ -1744,6 +1862,10 @@ pub fn analyze(source: &str) -> Analysis {
}
}
#[cfg(test)]
#[path = "language_nesting_tests.rs"]
mod nesting_tests;
pub(crate) mod legacy {
//! Arcadia text: a readable, lossless representation of a valid OA project.
//!
@@ -0,0 +1,505 @@
use super::*;
use crate::source_project::{ProjectLimits, SourceBundle, compile_bundle, lint_bundle};
fn project_source(body: &str) -> String {
format!(
"arcadia 3; project model \"Model\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}"
)
}
fn identity(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, value)| *value == alias)
.unwrap()
.0
}
#[test]
fn nested_elements_infer_only_parent_and_keep_typed_fields_and_allocations_explicit() {
let source = project_source(
r#"oa_entity team "Team" {
description "Authored before children";
oa_actor operator_one "Operator" {}
oa_role responder "Responder" {}
}
oa_activity respond "Respond" {
oa_activity report "Report" {}
description "Authored after children";
}
sa_system system_one "System" {
sa_port boundary "Boundary" { contract { Component { kind Standard; orientation Unset; }; }; }
}
sa_function work "Work" {
sa_function assess "Assess" {
sa_port result "Result" { contract { FunctionOutput { outgoing_items [@data]; }; }; }
}
}
sa_exchange_item data "Data" {
sa_data_field latitude "Latitude" { type_ref Real; unit "degrees"; }
}
allocation performs "Performs" { source @system_one; target @assess; }"#,
);
let project = parse(&source).unwrap();
for (child, owner) in [
("operator_one", "team"),
("responder", "team"),
("report", "respond"),
("boundary", "system_one"),
("assess", "work"),
("result", "assess"),
("latitude", "data"),
] {
assert_eq!(
project.elements[&identity(&project, child)].parent,
Some(identity(&project, owner))
);
}
assert_eq!(project.relations.len(), 1);
let relation = &project.relations[&identity(&project, "performs")];
assert_eq!(relation.kind(), RelationKind::Allocation);
assert_eq!(relation.target(), identity(&project, "assess"));
assert_eq!(parse(&format(&project)).unwrap(), project);
assert_eq!(
project.elements[&identity(&project, "respond")].description,
"Authored after children"
);
}
#[test]
fn matching_explicit_parent_is_accepted_but_none_or_another_owner_is_located() {
let nested = project_source(
"oa_activity parent_one \"Parent\" { oa_activity child \"航海\" { parent @parent_one; } }\noa_activity other \"Other\" {}",
);
let project = parse(&nested).unwrap();
assert_eq!(
project.elements[&identity(&project, "child")].parent,
Some(identity(&project, "parent_one"))
);
for reference in ["none", "@other"] {
let invalid = nested
.replace("parent @parent_one;", &format!("parent {reference};"))
.replace('\n', "\r\n");
let errors = parse(&invalid).unwrap_err();
let issue = errors
.iter()
.find(|issue| issue.code == "conflicting_parent")
.unwrap();
let at = invalid.find(&format!("parent {reference};")).unwrap() + "parent ".len();
assert_eq!(issue.span.start, at);
assert_eq!(issue.line, 2);
assert_eq!(
issue.column,
invalid[..at].rsplit('\n').next().unwrap().chars().count() + 1
);
}
}
#[test]
fn containment_validation_rejects_allocation_like_or_cross_layer_nesting() {
for body in [
"sa_system system_one \"System\" { sa_function work \"Work\" {} }",
"oa_activity parent_one \"Parent\" { sa_function child \"Child\" {} }",
] {
let source = project_source(body);
let errors = parse(&source).unwrap_err();
assert!(errors.iter().any(|issue| issue.code == "invalid_parent"));
}
}
#[test]
fn non_elements_includes_and_declarations_inside_typed_values_are_rejected() {
for body in [
"oa_activity outer_one \"Outer\" { allocation a \"A\" { source @outer_one; target @outer_one; } }",
"oa_activity outer_one \"Outer\" { include \"children.arcadia\"; }",
"diagram view_one \"View\" { oa_activity child \"Child\" {} }",
"sa_port port_one \"Port\" { contract { sa_function child \"Child\" {} }; }",
] {
let source = project_source(body);
assert!(parse_unit(&source, true, ParseLimits::UNLIMITED).is_err());
}
// Existing node `diagram @view;` fields must not look like nested headers.
let demo = Project::demo();
assert_eq!(parse(&format(&demo)).unwrap(), demo);
}
#[test]
fn declarations_are_preorder_occurrences_with_real_own_field_and_body_spans() {
let source = "// lead\r\noa_activity outer_one \"航海\" id \"00000000-0000-0000-0000-000000000002\" {\r\n description \"before\";\r\n oa_activity child \"Child\" id \"00000000-0000-0000-0000-000000000003\" { description \"inside\"; } // child proof\r\n}\r\n";
let unit = parse_unit(source, false, ParseLimits::UNLIMITED).unwrap();
assert_eq!(unit.declarations.len(), 2);
let outer = &unit.declarations[0];
let child = &unit.declarations[1];
assert_eq!(outer.alias, "outer_one");
assert_eq!(outer.lexical_owner, None);
assert_eq!(child.lexical_owner, Some(outer.id));
assert_eq!(
&source[child.body.start..child.body.end],
" description \"inside\"; "
);
assert_eq!(&source[outer.body.start - 1..outer.body.start], "{");
assert_eq!(&source[outer.body.end..outer.body.end + 1], "}");
assert!(!child.field_spans.contains_key("parent"));
assert!(outer.body.start < child.span.start && child.span.end < outer.body.end);
let edits = edit_declarations(source, false, ParseLimits::UNLIMITED).unwrap();
let (_, outer_edit) = &edits[0];
let (_, child_edit) = &edits[1];
assert_eq!(child_edit.span, child.span);
assert_eq!(child_edit.body, child.body);
assert_eq!(child_edit.lexical_owner, Some(outer.id));
let EditKind::Record(fields) = &outer_edit.fields.kind else {
panic!("own fields");
};
assert_eq!(fields.len(), 1, "children must not appear as typed fields");
let value = &fields["description"].value;
assert_eq!(&source[value.span.start..value.span.end], "\"before\"");
assert_eq!(
&source[child_edit.header["alias"].start..child_edit.header["alias"].end],
"child"
);
}
#[test]
fn syntax_edit_positions_retain_duplicate_identity_occurrences_during_a_command() {
let source = "oa_activity outer_one \"Outer\" id \"00000000-0000-0000-0000-000000000002\" { oa_activity child \"Child\" id \"00000000-0000-0000-0000-000000000002\" { parent none; } }";
let edits = edit_declarations(source, false, ParseLimits::UNLIMITED).unwrap();
assert_eq!(edits.len(), 2);
assert_eq!(edits[0].0, edits[1].0);
assert!(edits[0].1.span.start < edits[1].1.span.start);
assert!(parse(&project_source(source)).is_err());
}
#[test]
fn nested_fragment_id_stabilization_preserves_comments_and_indexes_real_bytes() {
let original = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
(
"project.arcadia".into(),
"arcadia 3; project model \"Model\" { include \"children.arcadia\"; }".into(),
),
(
"children.arcadia".into(),
"// rationale\r\nsa_function owner_one \"航海\" { sa_function leaf \"Leaf\" { } } // kept\r\n".into(),
),
]),
};
let compiled = compile_bundle(&original, ProjectLimits::default()).unwrap();
let stable = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
assert_eq!(compiled.bundle, stable.bundle);
assert_eq!(compiled.decoded.project, stable.decoded.project);
assert_eq!(compiled.index, stable.index);
let child = identity(&compiled.decoded.project, "leaf");
assert!(
!compiled
.index
.fields
.contains_key(&(child, "parent".into()))
);
let location = &compiled.index.declarations[&child];
assert_eq!(location.path, "children.arcadia");
assert!(
compiled.bundle.files[&location.path][location.span.start..location.span.end]
.starts_with("sa_function leaf \"Leaf\" id ")
);
assert!(compiled.bundle.files["children.arcadia"].starts_with("// rationale\r\n"));
assert!(compiled.bundle.files["children.arcadia"].ends_with("// kept\r\n"));
let raw_warning = lint_bundle(&original, ProjectLimits::default())
.into_iter()
.find(|issue| issue.diagnostic.code == "unallocated_leaf")
.unwrap();
let raw = &original.files["children.arcadia"];
assert_eq!(
raw_warning.diagnostic.span.start,
raw.find("sa_function leaf").unwrap()
);
}
#[test]
fn nested_aliases_remain_global_and_forward_references_remain_legal() {
let source = project_source(
"oa_activity outer_one \"Outer\" { oa_activity child \"Child\" { parent @outer_one; } }\noa_activity sibling \"Sibling\" { parent @outer_one; }\noa_activity sa_function \"Alias using a declaration spelling\" {}",
);
let project = parse(&source).unwrap();
assert_eq!(
project.elements[&identity(&project, "sibling")].parent,
project.elements[&identity(&project, "child")].parent
);
let duplicated = source.replace("oa_activity sibling", "oa_activity child");
assert!(
parse(&duplicated)
.unwrap_err()
.iter()
.any(|d| d.message.contains("Duplicate alias"))
);
}
#[test]
fn nested_record_admission_precedes_resolution_and_typed_depth_stays_bounded() {
let source = project_source(
"oa_activity a \"A\" { oa_activity b \"B\" { oa_activity c \"C\" { oa_activity d \"D\" { parent @missing; } } } }",
);
let errors = parse_limited(
&source,
ParseLimits {
max_graph_objects: 3,
..ParseLimits::UNLIMITED
},
)
.unwrap_err();
assert_eq!(errors[0].code, "model_limit");
assert_eq!(errors[0].span.start, source.find("oa_activity d").unwrap());
let deep_value = project_source(&format!(
"oa_activity owner_one \"Owner\" {{ description {}0{}; }}",
"[".repeat(65),
"]".repeat(65)
));
assert!(
parse(&deep_value).unwrap_err()[0]
.message
.contains("64 levels")
);
}
#[test]
fn deep_declaration_parsing_and_positions_use_a_bounded_rust_stack() {
let mut fragment = String::new();
for i in 2..602u128 {
fragment.push_str(&format!(
"oa_activity level_{i} \"Level\" id \"{}\" {{\n",
Id::from_u128(i)
));
}
fragment.push_str(&"}\n".repeat(600));
let analyzed_source = project_source(&fragment);
std::thread::Builder::new()
.stack_size(128 * 1024)
.spawn(move || {
let unit = parse_unit(&fragment, false, ParseLimits::UNLIMITED).unwrap();
assert_eq!(unit.declarations.len(), 600);
assert_eq!(
unit.declarations[599].lexical_owner,
Some(Id::from_u128(600))
);
let edits = edit_declarations(&fragment, false, ParseLimits::UNLIMITED).unwrap();
assert_eq!(edits.len(), 600);
})
.unwrap()
.join()
.unwrap();
let analysis = analyze(&analyzed_source);
assert_eq!(analysis.declarations.len(), 601);
assert!(
!analysis
.diagnostics
.iter()
.any(|d| d.severity == validation::Severity::Error)
);
}
#[test]
fn tolerant_nested_analysis_retains_closed_children_before_an_unfinished_sibling() {
let source = "arcadia 3; 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
.iter()
.find(|d| d.alias == "ready")
.unwrap();
assert_eq!(
&source[ready.span.start..ready.span.end],
"oa_activity ready \"Ready\" { description \"} is text\"; }"
);
let outer = analysis
.declarations
.iter()
.find(|d| d.alias == "outer_one")
.unwrap();
assert_eq!(outer.span.end, source.len());
assert!(
analysis
.declarations
.iter()
.any(|d| d.alias == "unfinished")
);
assert!(
analysis
.diagnostics
.iter()
.any(|d| d.severity == validation::Severity::Error)
);
}
#[test]
fn normative_nested_ownership_example_compiles_as_a_source_fragment() {
let spec = include_str!("../../../docs/language.rst");
let section = spec.split("6.2. Nested element ownership").nth(1).unwrap();
let example = section
.split("For example::")
.nth(1)
.unwrap()
.lines()
.skip_while(|line| line.trim().is_empty())
.take_while(|line| line.starts_with(" "))
.map(|line| &line[4..])
.collect::<Vec<_>>()
.join("\n");
assert!(!example.is_empty());
let compiled = compile_bundle(
&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
(
"project.arcadia".into(),
project_source("include \"example.arcadia\";"),
),
("example.arcadia".into(), example),
]),
},
ProjectLimits::default(),
)
.unwrap();
let project = &compiled.decoded.project;
assert_eq!(
project.elements[&identity(project, "radio")].parent,
Some(identity(project, "harbor"))
);
}
fn checked_in_source_project() -> SourceBundle {
SourceBundle {
entry: "project.arcadia".into(),
files: [
(
"project.arcadia",
include_str!("../../../examples/source-project/project.arcadia"),
),
(
"oa.arcadia",
include_str!("../../../examples/source-project/oa.arcadia"),
),
(
"sa/participants.arcadia",
include_str!("../../../examples/source-project/sa/participants.arcadia"),
),
(
"sa/reporting.arcadia",
include_str!("../../../examples/source-project/sa/reporting.arcadia"),
),
(
"sa/assurance.arcadia",
include_str!("../../../examples/source-project/sa/assurance.arcadia"),
),
(
"views.arcadia",
include_str!("../../../examples/source-project/views.arcadia"),
),
]
.into_iter()
.map(|(path, text)| (path.into(), text.into()))
.collect(),
}
}
#[test]
fn checked_in_nested_example_has_stable_ids_typed_exchanges_and_three_clear_views() {
let input = checked_in_source_project();
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
assert_eq!(
compiled.bundle, input,
"every example identity is already explicit"
);
assert!(
compiled.diagnostics.is_empty(),
"{:?}",
compiled.diagnostics
);
let project = &compiled.decoded.project;
assert_eq!(project.diagrams.len(), 3);
let assess = identity(project, "assess_report");
let actor_port = identity(project, "reporting_output");
assert_eq!(
project.elements[&assess].parent,
Some(identity(project, "handle_reports"))
);
assert_eq!(
project.elements[&actor_port].parent,
Some(identity(project, "vessel_service"))
);
assert!(matches!(
port_contract(&project.elements[&actor_port]),
Some(PortContract::Component { .. })
));
let exchange = &project.relations[&identity(project, "validated_report")];
assert_eq!(exchange.kind(), RelationKind::FunctionalExchange);
assert_eq!(exchange.target(), assess);
for diagram in project.diagrams.values() {
let view = projection::project_diagram(project, diagram);
assert!(!view.nodes.is_empty());
let routed = routing::route_diagram(project, diagram, &view);
assert!(!routed.edges.is_empty());
for edge in routed.edges.values() {
assert!(
edge.warnings.is_empty(),
"{} / {}: {:?}",
diagram.name,
project.named(edge.id),
edge.warnings
);
}
}
}
#[test]
fn checked_in_example_supports_cross_file_reparent_without_changing_allocations() {
let input = checked_in_source_project();
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
let project = &compiled.decoded.project;
let assess = identity(project, "assess_report");
let prepared = prepare_command(
project,
&Command::Reparent {
element: assess,
parent: Some(identity(project, "assure_response")),
},
)
.unwrap();
assert_eq!(prepared.project.relations, project.relations);
let result = crate::source_project::update_bundle_with_identity_map(
&compiled,
&prepared.project,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
assert_eq!(
result.index.declarations[&assess].path,
"sa/assurance.arcadia"
);
assert_eq!(
result.index.declarations[&identity(project, "report_in")].path,
"sa/assurance.arcadia"
);
assert!(
result.bundle.files["sa/assurance.arcadia"]
.contains("// Assessment rationale travels with this declaration")
);
assert!(
result.bundle.files["sa/assurance.arcadia"]
.contains("// Assessment remains performed by rescue_system")
);
assert!(
result.bundle.files["sa/reporting.arcadia"]
.contains("// This group comment stays in this file")
);
for path in [
"project.arcadia",
"oa.arcadia",
"sa/participants.arcadia",
"views.arcadia",
] {
assert_eq!(result.bundle.files[path], input.files[path]);
}
}
+88 -133
View File
@@ -4,6 +4,8 @@ use crate::{Id, Kind, Project, language, persistence::DecodeResult, validation};
use language::{Diagnostic, ParseLimits, Span};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[path = "source_project/source_structure.rs"]
mod source_structure;
/// A portable slash-separated path, checked at every bundle boundary.
pub type LogicalPath = String;
@@ -88,6 +90,11 @@ pub struct IncludeLocation {
pub struct SourceIndex {
/// Includes the project header itself under the project UUID.
pub declarations: BTreeMap<Id, SourceLocation>,
/// Interior of each declaration body, excluding its opening/closing braces.
pub declaration_bodies: BTreeMap<Id, SourceLocation>,
/// Authored element nesting, distinct from an explicit semantic parent field.
/// Flat declarations have no entry; the project is not a lexical element owner.
pub lexical_owners: BTreeMap<Id, Id>,
/// Complete value spans for top-level declaration fields.
pub fields: BTreeMap<(Id, String), SourceLocation>,
pub includes: Vec<IncludeLocation>,
@@ -363,6 +370,8 @@ fn compile_once(
let mut alias_locations: BTreeMap<String, SourceLocation> = BTreeMap::new();
let mut project_header = None;
let mut declaration_locations = BTreeMap::new();
let mut declaration_bodies = BTreeMap::new();
let mut lexical_owners = BTreeMap::new();
let mut fields = BTreeMap::new();
let mut includes = Vec::new();
let mut insertions: Edits = BTreeMap::new();
@@ -461,6 +470,16 @@ fn compile_once(
aliases.insert(declaration.alias.clone(), declaration.id);
alias_locations.insert(declaration.alias.clone(), location.clone());
declaration_locations.insert(declaration.id, location);
declaration_bodies.insert(
declaration.id,
SourceLocation {
path: path.clone(),
span: declaration.body,
},
);
if let Some(owner) = declaration.lexical_owner {
lexical_owners.insert(declaration.id, owner);
}
for (key, span) in &declaration.field_spans {
fields.insert(
(declaration.id, key.clone()),
@@ -532,6 +551,8 @@ fn compile_once(
let header = project_header.expect("validated entry header");
let index = SourceIndex {
declarations: declaration_locations,
declaration_bodies,
lexical_owners,
fields,
includes,
project_header: SourceLocation {
@@ -847,63 +868,65 @@ pub fn update_bundle_with_identity_map(
.collect();
let mut bundle = current.bundle.clone();
let mut edits: Edits = BTreeMap::new();
for (id, old_text) in &before {
let location = &current.index.declarations[id];
let new_text = identities.get(id).and_then(|id| after.get(id));
if new_text != Some(old_text) {
let source = &bundle.files[&location.path][location.span.start..location.span.end];
let mut local = Vec::new();
if let Some(new_text) = new_text {
let parse = |text| {
language::edit_declaration(text).map_err(|error| {
vec![problem(
&bundle,
&location.path,
location.span,
"source_edit",
error.message,
)]
})
};
let actual = parse(source)?;
let previous = parse(old_text)?;
let next = parse(new_text)?;
let update = SyntaxUpdate {
source,
before: old_text,
after: new_text,
old_aliases: &old_aliases,
new_aliases: &new_aliases,
};
update.header(&actual.header, &previous.header, &next.header, &mut local);
update.value(
&actual.fields,
Some(&previous.fields),
&next.fields,
&mut local,
);
} else {
erase_syntax(
source,
Span {
start: 0,
end: source.len(),
},
&mut local,
);
let mut original_order = BTreeMap::new();
for (path, source) in &bundle.files {
let declarations = language::edit_declarations(source, path == &bundle.entry, limits.model)
.map_err(|error| {
vec![SourceDiagnostic {
path: path.clone(),
diagnostic: *error,
related: Vec::new(),
}]
})?;
original_order.insert(
path.clone(),
declarations.iter().map(|(id, _)| *id).collect(),
);
for (id, actual) in declarations {
let old_text = &before[&id];
// Deletion is structural: removing a parent's complete span here
// would overlap child edits and lose independently moved descendants.
let Some(new_text) = identities.get(&id).and_then(|id| after.get(id)) else {
continue;
};
if new_text == old_text {
continue;
}
edits
.entry(location.path.clone())
.or_default()
.extend(local.into_iter().map(|(span, text)| {
(
Span {
start: span.start + location.span.start,
end: span.end + location.span.start,
},
text,
)
}));
let parse = |text| {
language::edit_declaration(text).map_err(|error| {
vec![problem(
&bundle,
path,
actual.span,
"source_edit",
error.message,
)]
})
};
let mut previous = parse(old_text)?;
let mut next = parse(new_text)?;
if actual.lexical_owner.is_some()
&& let language::EditKind::Record(fields) = &actual.fields.kind
&& !fields.contains_key("parent")
{
// The lexical structure is the authored definition of parent.
// Keep it implicit while the structural phase moves this record.
for fields in [&mut previous.fields, &mut next.fields] {
if let language::EditKind::Record(fields) = &mut fields.kind {
fields.remove("parent");
}
}
}
let update = SyntaxUpdate {
source,
before: old_text,
after: new_text,
old_aliases: &old_aliases,
new_aliases: &new_aliases,
};
let local = edits.entry(path.clone()).or_default();
update.header(&actual.header, &previous.header, &next.header, local);
update.value(&actual.fields, Some(&previous.fields), &next.fields, local);
}
}
if old.id != project.id || old.alias != project.alias || old.name != project.name {
@@ -975,84 +998,16 @@ pub fn update_bundle_with_identity_map(
}
}
let defaults = default_ownership(project);
let mut assigned: BTreeMap<_, _> = current
.index
.declarations
.iter()
.filter_map(|(id, location)| identities.get(id).map(|new| (*new, location.path.clone())))
.collect();
let mut chosen_defaults = BTreeMap::new();
let mut used_paths: BTreeSet<_> = bundle
.files
.keys()
.map(|path| path.to_lowercase())
.collect();
let mut additions: BTreeMap<LogicalPath, String> = BTreeMap::new();
let aliases: BTreeMap<_, _> = project.identities().into_iter().collect();
let mut new_ids: Vec<_> = after
.keys()
.filter(|id| !origins.contains_key(*id))
.copied()
.collect();
new_ids.sort_by_key(|id| declaration_order(project, *id, &aliases));
for id in new_ids {
let mut parent = semantic_owner(project, id);
let mut seen = BTreeSet::new();
let mut inherited = None;
while let Some(owner) = parent {
if !seen.insert(owner) {
break;
}
if let Some(path) = assigned.get(&owner) {
inherited = Some(path.clone());
break;
}
parent = semantic_owner(project, owner);
}
let path = inherited.unwrap_or_else(|| {
chosen_defaults
.entry(defaults[&id].clone())
.or_insert_with(|| {
let preferred = &defaults[&id];
if bundle.files.contains_key(preferred) {
preferred.clone()
} else {
unique_path(preferred, &mut used_paths)
}
})
.clone()
});
assigned.insert(id, path.clone());
additions
.entry(path)
.or_default()
.push_str(&unindent(&after[&id]));
}
let mut new_includes = String::new();
for (path, text) in additions {
if !bundle.files.contains_key(&path) {
bundle.files.insert(path.clone(), String::new());
new_includes.push_str(&format!(" include {};\n", quote(&path)));
}
let at = if path == bundle.entry {
current.index.project_body.span.end
} else {
bundle.files[&path].len()
};
edits
.entry(path)
.or_default()
.push((Span { start: at, end: at }, format!("\n{text}")));
}
if !new_includes.is_empty() {
let at = current.index.project_body.span.end;
edits
.entry(bundle.entry.clone())
.or_default()
.push((Span { start: at, end: at }, format!("\n{new_includes}")));
}
apply_edits(&mut bundle, edits)?;
source_structure::update(
&mut bundle,
current,
project,
identities,
&original_order,
&after,
limits,
)?;
let result = compile_bundle(&bundle, limits)?;
if &result.decoded.project != project {
return Err(vec![problem(
@@ -0,0 +1,562 @@
//! Lexical containment writeback. Scalar fields use the shared SyntaxUpdate;
//! this phase only relocates declaration pieces and their owned comments.
use super::*;
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
enum Scope {
File(LogicalPath),
Element(Id),
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Key {
Live(Id),
Deleted(Id),
}
enum Piece {
Text(String),
/// A relative indentation adjustment, inherited by retained descendants.
Child(Key, isize),
}
struct Record {
path: LogicalPath,
syntax: language::EditDeclaration,
owned: Span,
indent: usize,
}
pub(super) fn update(
bundle: &mut SourceBundle,
current: &CompiledSourceProject,
project: &Project,
identities: &BTreeMap<Id, Id>,
original_order: &BTreeMap<LogicalPath, Vec<Id>>,
after: &BTreeMap<Id, String>,
limits: ProjectLimits,
) -> Result<(), Vec<SourceDiagnostic>> {
let origins: BTreeMap<_, _> = identities.iter().map(|(old, new)| (*new, *old)).collect();
let structural = after.keys().any(|id| !origins.contains_key(id))
|| original_order
.values()
.flatten()
.any(|id| !identities.contains_key(id))
|| current.index.lexical_owners.iter().any(|(id, owner)| {
identities
.get(id)
.is_some_and(|new| project.elements[new].parent != identities.get(owner).copied())
});
if !structural {
return Ok(());
}
// Scalar changes may temporarily duplicate a deleted UUID or contradict an
// explicit parent until relocation. Parse syntax, then preserve original
// preorder provenance; intermediate IDs are never editing authority.
let mut records = BTreeMap::new();
let mut children: BTreeMap<(LogicalPath, Option<Id>), Vec<Id>> = BTreeMap::new();
let mut file_bodies = BTreeMap::new();
for (path, source) in &bundle.files {
let parsed = language::edit_declarations(source, path == &bundle.entry, limits.model)
.map_err(|error| diagnostic(path, *error))?;
if parsed.len() != original_order[path].len() {
return Err(vec![problem(
bundle,
path,
Span::default(),
"stale_source_index",
"Scalar edits changed the declaration structure.",
)]);
}
let body = if path == &bundle.entry {
language::parse_unit(source, true, limits.model)
.map_err(|error| diagnostic(path, *error))?
.header
.expect("entry header")
.body
} else {
Span {
start: 0,
end: source.len(),
}
};
file_bodies.insert(path.clone(), body);
for (old, (_, syntax)) in original_order[path].iter().zip(parsed) {
children
.entry((path.clone(), current.index.lexical_owners.get(old).copied()))
.or_default()
.push(*old);
records.insert(
*old,
Record {
path: path.clone(),
owned: syntax.span,
indent: indent_at(source, syntax.span.start).len(),
syntax,
},
);
}
}
for ((path, owner), siblings) in &children {
let source = &bundle.files[path];
let body = owner.map_or(file_bodies[path], |id| records[&id].syntax.body);
let mut previous = body.start;
for (index, id) in siblings.iter().enumerate() {
let span = records[id].syntax.span;
let next = siblings
.get(index + 1)
.map_or(body.end, |next| records[next].syntax.span.start);
let owned = attached_span(source, span, previous, next);
records.get_mut(id).unwrap().owned = owned;
previous = owned.end;
}
}
let defaults = default_ownership(project);
let aliases: BTreeMap<_, _> = project.identities().into_iter().collect();
let mut scopes = BTreeMap::new();
for (old, record) in &records {
if let Some(new) = identities.get(old) {
let scope = if current.index.lexical_owners.contains_key(old) {
project.elements[new]
.parent
.map(Scope::Element)
.unwrap_or_else(|| Scope::File(record.path.clone()))
} else {
Scope::File(record.path.clone())
};
scopes.insert(*new, scope);
}
}
let mut new_ids: Vec<_> = after
.keys()
.filter(|id| !origins.contains_key(id))
.copied()
.collect();
new_ids.sort_by_key(|id| declaration_order(project, *id, &aliases));
let mut chosen_defaults = BTreeMap::new();
let mut used: BTreeSet<_> = bundle
.files
.keys()
.map(|path| path.to_lowercase())
.collect();
for id in &new_ids {
if let Some(parent) = project.elements.get(id).and_then(|element| element.parent) {
scopes.insert(*id, Scope::Element(parent));
continue;
}
let mut owner = semantic_owner(project, *id);
let mut inherited = None;
while let Some(id) = owner {
if id == project.id {
inherited = Some(bundle.entry.clone());
break;
}
if scopes.contains_key(&id) {
inherited = Some(file_for(id, &scopes));
break;
}
owner = semantic_owner(project, id);
}
let path = inherited.unwrap_or_else(|| {
chosen_defaults
.entry(defaults[id].clone())
.or_insert_with(|| {
let preferred = &defaults[id];
if bundle.files.contains_key(preferred) {
preferred.clone()
} else {
unique_path(preferred, &mut used)
}
})
.clone()
});
scopes.insert(*id, Scope::File(path));
}
let mut incoming: BTreeMap<Scope, Vec<Id>> = BTreeMap::new();
for (id, scope) in &scopes {
let original_scope = origins
.get(id)
.and_then(|old| {
records.get(old).map(|record| {
if let Some(owner) = current.index.lexical_owners.get(old) {
identities.get(owner).copied().map(Scope::Element)
} else {
Some(Scope::File(record.path.clone()))
}
})
})
.flatten();
if original_scope.as_ref() != Some(scope) {
incoming.entry(scope.clone()).or_default().push(*id);
}
}
for ids in incoming.values_mut() {
ids.sort_by_key(|id| declaration_order(project, *id, &aliases));
}
let mut generated = BTreeMap::new();
let mut base_indents: BTreeMap<_, _> = records
.iter()
.filter_map(|(old, record)| identities.get(old).map(|new| (*new, record.indent)))
.collect();
for id in &new_ids {
let mut text = unindent(&after[id]);
let path = file_for(*id, &scopes);
if bundle
.files
.get(&path)
.is_some_and(|source| newline(source) == "\r\n")
{
text = text.replace('\n', "\r\n");
}
let parsed =
language::edit_declaration(&text).map_err(|error| diagnostic(&bundle.entry, *error))?;
if matches!(scopes[id], Scope::Element(_))
&& let language::EditKind::Record(fields) = &parsed.fields.kind
&& let Some(parent) = fields.get("parent")
{
text.replace_range(parent.span.start..parent.span.end, "");
}
let syntax =
language::edit_declaration(&text).map_err(|error| diagnostic(&bundle.entry, *error))?;
generated.insert(*id, (text, syntax));
base_indents.insert(*id, 0);
}
let composition = Composition {
records: &records,
identities,
scopes: &scopes,
incoming: &incoming,
base_indents: &base_indents,
};
let mut templates = BTreeMap::new();
for (old, record) in &records {
let source = &bundle.files[&record.path];
let live = identities.get(old).copied();
let scope = live.map(Scope::Element);
let own = |text: &str| {
if live.is_some() {
text.to_owned()
} else {
trivia(text)
}
};
let mut pieces = vec![Piece::Text(own(
&source[record.owned.start..record.syntax.body.start]
))];
pieces.extend(
composition.body(
source,
record.syntax.body,
children
.get(&(record.path.clone(), Some(*old)))
.map_or(&[], Vec::as_slice),
scope.as_ref(),
record.indent + 4,
live.is_none(),
),
);
pieces.push(Piece::Text(own(
&source[record.syntax.body.end..record.owned.end]
)));
templates.insert(live.map_or(Key::Deleted(*old), Key::Live), pieces);
}
for (id, (text, syntax)) in &generated {
let mut pieces = vec![Piece::Text(text[..syntax.body.start].into())];
pieces.extend(composition.body(
text,
syntax.body,
&[],
Some(&Scope::Element(*id)),
4,
false,
));
pieces.push(Piece::Text(text[syntax.body.end..].into()));
templates.insert(Key::Live(*id), pieces);
}
let paths: BTreeSet<_> = bundle
.files
.keys()
.cloned()
.chain(scopes.values().filter_map(|scope| {
if let Scope::File(path) = scope {
Some(path.clone())
} else {
None
}
}))
.collect();
let mut output = BTreeMap::new();
let mut includes = String::new();
let mut bytes = 0usize;
for path in paths {
let source = bundle.files.get(&path).map_or("", String::as_str);
let body = file_bodies.get(&path).copied().unwrap_or_default();
let mut pieces = vec![Piece::Text(source[..body.start].into())];
pieces.extend(
composition.body(
source,
body,
children
.get(&(path.clone(), None))
.map_or(&[], Vec::as_slice),
Some(&Scope::File(path.clone())),
if path == bundle.entry { 4 } else { 0 },
false,
),
);
pieces.push(Piece::Text(source[body.end..].into()));
let text = render(
&pieces,
&templates,
limits.model.max_source_bytes.saturating_sub(bytes),
)
.ok_or_else(|| {
vec![problem(
bundle,
&path,
Span::default(),
"source_limit",
"Structural source update exceeds the source byte budget.",
)]
})?;
bytes = bytes.saturating_add(text.len());
if !bundle.files.contains_key(&path) {
includes.push_str(&format!(
" include {};{}",
quote(&path),
newline(&bundle.files[&bundle.entry])
));
}
output.insert(path, text);
}
if !includes.is_empty() {
let entry = output.get_mut(&bundle.entry).unwrap();
let body = language::parse_unit(entry, true, limits.model)
.map_err(|error| diagnostic(&bundle.entry, *error))?
.header
.unwrap()
.body;
entry.insert_str(body.end, &format!("{}{includes}", newline(entry)));
}
bundle.files = output;
Ok(())
}
fn diagnostic(path: &str, error: Diagnostic) -> Vec<SourceDiagnostic> {
vec![SourceDiagnostic {
path: path.into(),
diagnostic: error,
related: Vec::new(),
}]
}
fn file_for(mut id: Id, scopes: &BTreeMap<Id, Scope>) -> LogicalPath {
loop {
match &scopes[&id] {
Scope::File(path) => return path.clone(),
Scope::Element(owner) => id = *owner,
}
}
}
struct Composition<'a> {
records: &'a BTreeMap<Id, Record>,
identities: &'a BTreeMap<Id, Id>,
scopes: &'a BTreeMap<Id, Scope>,
incoming: &'a BTreeMap<Scope, Vec<Id>>,
base_indents: &'a BTreeMap<Id, usize>,
}
impl Composition<'_> {
fn body(
&self,
source: &str,
body: Span,
children: &[Id],
scope: Option<&Scope>,
child_indent: usize,
deleted: bool,
) -> Vec<Piece> {
let mut pieces = Vec::new();
let text = |s: &str| Piece::Text(if deleted { trivia(s) } else { s.into() });
let mut cursor = body.start;
for old in children {
let record = &self.records[old];
pieces.push(text(&source[cursor..record.owned.start]));
if let Some(new) = self.identities.get(old) {
if scope == self.scopes.get(new) {
pieces.push(Piece::Child(Key::Live(*new), 0));
} else {
pieces.push(Piece::Text(format!("{0}{0}", newline(source))));
}
} else {
// A deleted declaration's annotations remain in the nearest
// surviving original scope, separated from adjacent declarations.
pieces.push(Piece::Text(format!("{0}{0}", newline(source))));
pieces.push(Piece::Child(Key::Deleted(*old), 0));
pieces.push(Piece::Text(format!("{0}{0}", newline(source))));
}
cursor = record.owned.end;
}
let additions: &[Id] = scope
.and_then(|scope| self.incoming.get(scope))
.map_or(&[], Vec::as_slice);
if additions.is_empty() {
pieces.push(text(&source[cursor..body.end]));
return pieces;
}
let line = source[..body.end].rsplit('\n').next().unwrap_or("");
let at = if line.bytes().all(|b| b == b' ' || b == b'\t' || b == b'\r') {
body.end.saturating_sub(line.len()).max(cursor)
} else {
body.end
};
pieces.push(text(&source[cursor..at]));
pieces.push(Piece::Text(newline(source).into()));
for id in additions {
pieces.push(Piece::Child(
Key::Live(*id),
child_indent as isize - self.base_indents[id] as isize,
));
pieces.push(Piece::Text(newline(source).into()));
}
pieces.push(text(&source[at..body.end]));
pieces
}
}
/// Keep contiguous leading comments and same-line trailing comments with the
/// declaration. Blank-separated notes and field trailing comments stay in gaps.
fn attached_span(source: &str, span: Span, lower: usize, upper: usize) -> Span {
let gap = &source[lower..span.start];
let tokens = language::tokenize(gap);
let mut start = span.start;
for token in tokens.iter().rev() {
let token_start = lower + token.span.start;
let token_end = lower + token.span.end;
match token.class {
language::TokenClass::Whitespace => {
if source[token_start..token_end]
.bytes()
.filter(|b| *b == b'\n')
.count()
> 1
{
break;
}
}
language::TokenClass::Comment => {
let line = source[..token_start].rsplit('\n').next().unwrap_or("");
if !line.chars().all(char::is_whitespace) {
break;
}
start = token_start;
}
_ => break,
}
}
let line = source[..start].rsplit('\n').next().unwrap_or("");
if line.bytes().all(|b| b == b' ' || b == b'\t') {
start = start.saturating_sub(line.len()).max(lower);
}
let mut end = span.end;
let mut trailing = false;
for token in language::tokenize(&source[span.end..upper]) {
let token_start = span.end + token.span.start;
let token_end = span.end + token.span.end;
match token.class {
language::TokenClass::Whitespace => {
if let Some(newline) = source[token_start..token_end].find('\n') {
if trailing {
end = token_start + newline + 1;
}
break;
}
}
language::TokenClass::Comment => {
trailing = true;
end = token_end;
if source[token_start..token_end].contains('\n') {
break;
}
}
_ => break,
}
}
Span { start, end }
}
/// Expand a piece forest iteratively. Descendants are streamed directly to the
/// final file, avoiding recursive calls and repeated copying of whole subtrees.
fn render(pieces: &[Piece], templates: &BTreeMap<Key, Vec<Piece>>, limit: usize) -> Option<String> {
let mut output = String::new();
let mut stack = vec![(pieces.iter(), 0isize)];
let mut line_start = true;
// A lexical piece can end in the indentation immediately before another
// piece's closing brace. Delay it until that line's content is available.
let mut indentation = String::new();
while let Some((items, shift)) = stack.last_mut() {
match items.next() {
Some(Piece::Child(key, relative)) => {
let delta = *shift + relative;
stack.push((templates[key].iter(), delta));
}
Some(Piece::Text(text)) => {
for part in text.split_inclusive('\n') {
if line_start {
let leading = part.len() - part.trim_start_matches([' ', '\t']).len();
indentation.push_str(&part[..leading]);
let content = &part[leading..];
if output.len().saturating_add(indentation.len()) > limit {
return None;
}
if content.is_empty() {
continue;
}
let delta = if content.starts_with(['\r', '\n']) {
0
} else {
*shift
};
if delta > 0 {
if output
.len()
.saturating_add(indentation.len())
.saturating_add(delta as usize)
.saturating_add(content.len())
> limit
{
return None;
}
output.extend(std::iter::repeat_n(' ', delta as usize));
}
let trim = if delta < 0 {
indentation.len().min(delta.unsigned_abs())
} else {
0
};
output.push_str(&indentation[trim..]);
indentation.clear();
output.push_str(content);
} else {
output.push_str(part);
}
if output.len() > limit {
return None;
}
line_start = part.ends_with('\n');
}
}
None => {
stack.pop();
}
}
}
output.push_str(&indentation);
Some(output)
}
+10 -1
View File
@@ -1021,6 +1021,15 @@ pub fn plan_transition(
Ok(plan)
}
pub fn apply_transition(project: &Project, plan: &TransitionPlan) -> Result<Project, ModelError> {
prepare_transition(project, plan).map(|prepared| prepared.project)
}
/// Prepare a reviewed transition once, retaining identity correspondence for
/// adapters that update accepted graphical and authored source state together.
pub fn prepare_transition(
project: &Project,
plan: &TransitionPlan,
) -> Result<PreparedCommand, ModelError> {
if project_fingerprint(project) != plan.base_fingerprint {
return Err(ModelError(
"Transition preview is stale; create a new preview.".into(),
@@ -1031,7 +1040,7 @@ pub fn apply_transition(project: &Project, plan: &TransitionPlan) -> Result<Proj
"Resolve transition conflicts before applying.".into(),
));
}
execute_batch(project, &plan.commands)
prepare_command(project, &Command::Batch(plan.commands.clone()))
}
/// Supported explicit element counterparts, in stable kind-catalog order.
@@ -0,0 +1,388 @@
use oa_core::{source_project::*, *};
use std::collections::BTreeMap;
fn compile(a: &str, b: &str) -> CompiledSourceProject {
compile_bundle(&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), "arcadia 3; 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()
}
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn apply(current: &CompiledSourceProject, project: &Project) -> CompiledSourceProject {
let result = update_bundle(current, project, ProjectLimits::default()).unwrap();
assert_eq!(&result.decoded.project, project);
assert_eq!(
update_bundle(&result, project, ProjectLimits::default())
.unwrap()
.bundle,
result.bundle,
"unchanged writeback is byte-stable"
);
result
}
fn comments(bundle: &SourceBundle) -> Vec<String> {
let mut result: Vec<_> = bundle
.files
.values()
.flat_map(|source| {
language::tokenize(source)
.into_iter()
.filter(|t| t.class == language::TokenClass::Comment)
.map(|t| source[t.span.start..t.span.end].to_owned())
})
.collect();
result.sort();
result
}
#[test]
fn nested_scalar_edits_touch_only_authored_tokens() {
let current = compile(
"// owner rationale\nsa_function owner \"Owner\" {\n description \"owner notes\";\n sa_function child \"Child\" { description \"before\" ; // inline\n } // child tail\n}\n",
"// unrelated file\n",
);
let mut project = current.decoded.project.clone();
let child = id(&project, "child");
project.elements.get_mut(&child).unwrap().description = "after".into();
project
.elements
.get_mut(&id(&current.decoded.project, "owner"))
.unwrap()
.name = "Renamed owner".into();
let result = apply(&current, &project);
assert_eq!(
result.bundle.files["a.arcadia"],
current.bundle.files["a.arcadia"]
.replace("\"Owner\"", "\"Renamed owner\"")
.replace("\"before\"", "\"after\"")
);
assert_eq!(
result.bundle.files["b.arcadia"],
current.bundle.files["b.arcadia"]
);
assert!(!result.index.fields.contains_key(&(child, "parent".into())));
}
#[test]
fn cross_file_move_carries_attached_comments_and_subtree_but_leaves_gap_notes() {
let current = compile(
"sa_function left \"Left\" {\n // stationary gap note\n\n // child rationale\n sa_function child \"Child\" {\n sa_function grandchild \"Grandchild\" {}\n } // child trailing rationale\n}\n",
"sa_function right \"Right\" {}\n",
);
let mut project = current.decoded.project.clone();
let (child, right, grandchild) = (
id(&project, "child"),
id(&project, "right"),
id(&project, "grandchild"),
);
project.elements.get_mut(&child).unwrap().parent = Some(right);
project.elements.get_mut(&grandchild).unwrap().name = "Updated grandchild".into();
let result = apply(&current, &project);
assert_eq!(result.index.declarations[&child].path, "b.arcadia");
assert_eq!(result.index.declarations[&grandchild].path, "b.arcadia");
assert_eq!(result.index.lexical_owners[&child], right);
assert_eq!(result.index.lexical_owners[&grandchild], child);
assert!(result.bundle.files["a.arcadia"].contains("// stationary gap note"));
assert!(!result.bundle.files["b.arcadia"].contains("// stationary gap note"));
assert!(result.bundle.files["b.arcadia"].contains("// child rationale"));
assert!(result.bundle.files["b.arcadia"].contains("// child trailing rationale"));
assert!(
result.bundle.files["b.arcadia"].contains("\n } // child trailing rationale"),
"moved closing brace must align with its header: {}",
result.bundle.files["b.arcadia"]
);
assert_eq!(comments(&result.bundle), comments(&current.bundle));
}
#[test]
fn moving_a_child_keeps_the_owners_opening_line_comment_with_its_owner() {
let current = compile(
"sa_function left \"Left\" { // Owner rationale belongs to Left.\n // Child rationale follows Child.\n sa_function child \"Child\" {}\n}\n",
"sa_function right \"Right\" {}\n",
);
let mut project = current.decoded.project.clone();
let (child, right) = (id(&project, "child"), id(&project, "right"));
project.elements.get_mut(&child).unwrap().parent = Some(right);
let result = apply(&current, &project);
assert!(result.bundle.files["a.arcadia"].contains("// Owner rationale belongs to Left."));
assert!(!result.bundle.files["b.arcadia"].contains("// Owner rationale belongs to Left."));
assert!(result.bundle.files["b.arcadia"].contains("// Child rationale follows Child."));
assert!(!result.bundle.files["a.arcadia"].contains("// Child rationale follows Child."));
assert_eq!(comments(&result.bundle), comments(&current.bundle));
}
#[test]
fn compact_same_file_reparent_and_unnest_preserve_explicit_parent_comments() {
let current = compile(
"sa_function left \"Left\" {sa_function child \"Child\" {parent // parent rationale\n @left;}} sa_function right \"Right\" {}\n",
"// untouched\n",
);
let mut project = current.decoded.project.clone();
let (child, right) = (id(&project, "child"), id(&project, "right"));
project.elements.get_mut(&child).unwrap().parent = Some(right);
let moved = apply(&current, &project);
assert_eq!(moved.index.lexical_owners[&child], right);
assert!(moved.bundle.files["a.arcadia"].contains("// parent rationale"));
assert!(moved.bundle.files["a.arcadia"].contains("@right;"));
project.elements.get_mut(&child).unwrap().parent = None;
let unnested = apply(&moved, &project);
assert!(!unnested.index.lexical_owners.contains_key(&child));
assert_eq!(unnested.index.declarations[&child].path, "a.arcadia");
assert_eq!(comments(&unnested.bundle), comments(&current.bundle));
}
#[test]
fn additions_nest_in_existing_and_new_owners_while_authored_flat_records_stay_flat() {
let current = compile(
"sa_function owner \"Owner\" {}\nsa_function flat \"Flat\" {parent @owner;}\n",
"// unrelated\n",
);
let mut project = current.decoded.project.clone();
let (owner, flat) = (id(&project, "owner"), id(&project, "flat"));
let added = project
.add_element(Kind::Function, Layer::Sa, "Added", Some(owner))
.unwrap();
let grandchild = project
.add_element(Kind::Function, Layer::Sa, "Added descendant", Some(added))
.unwrap();
project.elements.get_mut(&flat).unwrap().parent = Some(added);
let fresh_root = project
.add_element(Kind::Function, Layer::Sa, "Fresh root", None)
.unwrap();
let fresh_child = project
.add_element(Kind::Function, Layer::Sa, "Fresh child", Some(fresh_root))
.unwrap();
let result = apply(&current, &project);
for (child, parent) in [
(added, owner),
(grandchild, added),
(fresh_child, fresh_root),
] {
assert_eq!(result.index.lexical_owners[&child], parent);
assert_eq!(
result.index.declarations[&child].path,
result.index.declarations[&parent].path
);
assert!(!result.index.fields.contains_key(&(child, "parent".into())));
}
assert!(!result.index.lexical_owners.contains_key(&flat));
assert_eq!(result.index.declarations[&flat].path, "a.arcadia");
assert_eq!(
result.bundle.files["b.arcadia"],
current.bundle.files["b.arcadia"]
);
}
#[test]
fn deleting_owner_retains_annotations_and_extracts_independently_moved_descendant() {
let current = compile(
"// deleted owner rationale\nsa_function doomed \"Doomed\" {\n description \"old\"; // deleted field rationale\n // retained child rationale\n sa_function rescued \"Rescued\" {} // retained child tail\n // deleted child rationale\n sa_function removed \"Removed\" {}\n}\nsa_function neighbor \"Neighbor\" {}\n",
"sa_function destination \"Destination\" {}\n",
);
let mut project = current.decoded.project.clone();
let (doomed, removed, rescued, destination) = (
id(&project, "doomed"),
id(&project, "removed"),
id(&project, "rescued"),
id(&project, "destination"),
);
project.elements.remove(&doomed);
project.elements.remove(&removed);
project.elements.get_mut(&rescued).unwrap().parent = Some(destination);
let result = apply(&current, &project);
assert_eq!(result.index.lexical_owners[&rescued], destination);
for comment in [
"deleted owner rationale",
"deleted field rationale",
"deleted child rationale",
] {
assert!(result.bundle.files["a.arcadia"].contains(comment));
assert!(!result.bundle.files["b.arcadia"].contains(comment));
}
assert!(result.bundle.files["b.arcadia"].contains("retained child rationale"));
assert!(result.bundle.files["b.arcadia"].contains("retained child tail"));
assert_eq!(comments(&result.bundle), comments(&current.bundle));
// Old annotations are separated: moving the neighbor in a later command
// cannot accidentally steal comments of deleted declarations.
let neighbor = id(&project, "neighbor");
project.elements.get_mut(&neighbor).unwrap().description = "changed".into();
let next = apply(&result, &project);
assert_eq!(comments(&next.bundle), comments(&current.bundle));
}
#[test]
fn independent_descendant_move_can_reverse_old_containment_without_overlapping_edits() {
let current = compile(
"sa_function root \"Root\" {\n // outer rationale\n sa_function outer \"Outer\" {\n // inner rationale\n sa_function inner \"Inner\" {}\n }\n}\n",
"",
);
let mut project = current.decoded.project.clone();
let (root, outer, inner) = (
id(&project, "root"),
id(&project, "outer"),
id(&project, "inner"),
);
project.elements.get_mut(&outer).unwrap().parent = Some(inner);
project.elements.get_mut(&inner).unwrap().parent = Some(root);
let result = apply(&current, &project);
assert_eq!(result.index.lexical_owners[&inner], root);
assert_eq!(result.index.lexical_owners[&outer], inner);
assert_eq!(comments(&result.bundle), comments(&current.bundle));
}
#[test]
fn nested_uuid_alias_swap_and_cross_file_move_follow_command_provenance() {
let current = compile(
"sa_function left \"Left\" {\n // first rationale\n sa_function first \"First\" {}\n}\n",
"sa_function right \"Right\" {\n // second rationale\n sa_function second \"Second\" {}\n}\n",
);
let old = &current.decoded.project;
let (first, second, right) = (id(old, "first"), id(old, "second"), id(old, "right"));
let temporary = Id::new_v4();
let prepared = prepare_command(
old,
&Command::Batch(vec![
Command::Reidentify(first, temporary),
Command::Reidentify(second, first),
Command::Reidentify(temporary, second),
Command::Reparent {
element: second,
parent: Some(right),
},
]),
)
.unwrap();
let result = update_bundle_with_identity_map(
&current,
&prepared.project,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
assert_eq!(result.decoded.project, prepared.project);
assert_eq!(result.index.declarations[&second].path, "b.arcadia");
assert!(result.bundle.files["b.arcadia"].contains("// first rationale"));
assert!(result.bundle.files["b.arcadia"].contains("// second rationale"));
assert_eq!(comments(&result.bundle), comments(&current.bundle));
}
#[test]
fn deleted_owner_identity_reused_by_new_owner_does_not_keep_old_lexical_ownership() {
let current = compile(
"// retired owner note\nsa_function old_owner \"Old owner\" {\n // rescued child note\n sa_function child \"Child\" {}\n}\n",
"",
);
let old = &current.decoded.project;
let (owner, child) = (id(old, "old_owner"), id(old, "child"));
let mut project = old.clone();
project.elements.remove(&owner);
let mut replacement = project
.make_element(Kind::Function, Layer::Sa, "New owner", None)
.unwrap();
replacement.id = owner;
project.elements.insert(owner, replacement);
let mut identities: BTreeMap<_, _> = old
.identities()
.into_iter()
.map(|(id, _)| (id, id))
.collect();
identities.remove(&owner);
let result =
update_bundle_with_identity_map(&current, &project, &identities, ProjectLimits::default())
.unwrap();
assert_eq!(result.decoded.project, project);
let destination = &result.index.declarations[&owner].path;
assert_ne!(destination, "a.arcadia");
assert_eq!(&result.index.declarations[&child].path, destination);
assert_eq!(result.index.lexical_owners[&child], owner);
assert!(result.bundle.files["a.arcadia"].contains("retired owner note"));
assert!(!result.bundle.files[destination].contains("retired owner note"));
assert!(result.bundle.files[destination].contains("rescued child note"));
assert_eq!(comments(&result.bundle), comments(&current.bundle));
}
#[test]
fn nested_typed_reference_list_updates_keep_item_rationale_while_port_moves() {
let current = compile(
"sa_exchange_item first \"First\" {}\nsa_exchange_item second \"Second\" {}\nsa_function sender \"Sender\" {\n // port rationale\n sa_port output \"Output\" { contract { FunctionOutput { outgoing_items [\n @first, // first item rationale\n @second // second item rationale\n ]; }; }; }\n}\n",
"sa_function other \"Other\" {}\n",
);
let mut project = current.decoded.project.clone();
let (first, output, other) = (
id(&project, "first"),
id(&project, "output"),
id(&project, "other"),
);
project.elements.get_mut(&first).unwrap().alias = "renamed_first".into();
let port = project.elements.get_mut(&output).unwrap();
port.parent = Some(other);
let Some(SharedBody::Port {
contract: PortContract::FunctionOutput { outgoing_items },
..
}) = port.shared_mut()
else {
panic!("output contract")
};
outgoing_items.reverse();
let result = apply(&current, &project);
let text = &result.bundle.files["b.arcadia"];
assert!(text.find("@second").unwrap() < text.find("@renamed_first").unwrap());
assert_eq!(result.index.lexical_owners[&output], other);
assert_eq!(comments(&result.bundle), comments(&current.bundle));
}
#[test]
fn crlf_moved_and_new_children_preserve_authored_and_destination_line_endings() {
let current = compile(
"sa_function left \"Left\" {\r\n // child rationale\r\n sa_function child \"Child\" {} // trailing\r\n}\r\n",
"sa_function right \"Right\" {}\r\n",
);
let mut project = current.decoded.project.clone();
let (child, right) = (id(&project, "child"), id(&project, "right"));
project.elements.get_mut(&child).unwrap().parent = Some(right);
project
.add_element(Kind::Function, Layer::Sa, "Added", Some(right))
.unwrap();
let result = apply(&current, &project);
for path in ["a.arcadia", "b.arcadia"] {
let text = &result.bundle.files[path];
assert!(
!text.replace("\r\n", "").contains('\n'),
"bare LF introduced in {path}: {text:?}"
);
}
assert_eq!(comments(&result.bundle), comments(&current.bundle));
}
#[test]
fn deep_structural_writeback_runs_on_a_small_thread_stack() {
std::thread::Builder::new()
.stack_size(256 * 1024)
.spawn(|| {
let mut source = String::new();
for level in 0..600 {
source.push_str(&format!("sa_function n{level} \"N{level}\" {{"));
}
source.push_str(&"}".repeat(600));
let current = compile(&source, "sa_function destination \"Destination\" {}\n");
let mut project = current.decoded.project.clone();
let (leaf, destination) = (id(&project, "n599"), id(&project, "destination"));
project.elements.get_mut(&leaf).unwrap().parent = Some(destination);
let result = apply(&current, &project);
assert_eq!(result.index.declarations[&leaf].path, "b.arcadia");
assert_eq!(result.index.lexical_owners[&leaf], destination);
})
.unwrap()
.join()
.unwrap();
}
+140 -9
View File
@@ -165,15 +165,16 @@ impl Document {
pub fn execute(&mut self, command: Command) -> Result<Option<Id>, DocumentError> {
let candidate = prepare_command(self.project(), &command)?;
if &candidate.project != self.project() {
let compiled = source_project::update_bundle_with_identity_map(
&self.current,
&candidate.project,
&candidate.identity_map,
self.limits,
)?;
self.commit(compiled);
}
// Equal model values can still carry different record provenance, such
// as deleting and recreating an identical record with a reused UUID.
// Only the resulting model/source pair determines whether this is a no-op.
let compiled = source_project::update_bundle_with_identity_map(
&self.current,
&candidate.project,
&candidate.identity_map,
self.limits,
)?;
self.commit(compiled);
Ok(candidate.selected)
}
@@ -369,6 +370,136 @@ mod tests {
assert_eq!(document.bundle(), &branch);
}
#[test]
fn nested_cross_file_reparent_and_source_edit_share_exact_history() {
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: [
(
"project.arcadia".into(),
"arcadia 3; project rescue \"Rescue\" { include \"oa/source.arcadia\"; include \"oa/target.arcadia\"; }\n".into(),
),
(
"oa/source.arcadia".into(),
"// Source module\r\noa_activity respond \"Respond\" {\r\n // Assessment rationale follows this activity.\r\n oa_activity assess \"Assess\" {}\r\n}\r\n".into(),
),
(
"oa/target.arcadia".into(),
"// Destination module\noa_activity coordinate \"Coordinate\" {}\n".into(),
),
].into(),
};
let mut document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap();
document.mark_saved(document.snapshot());
let original = document.snapshot();
let id = |alias: &str| {
original
.decoded
.project
.elements
.values()
.find(|element| element.alias == alias)
.unwrap()
.id
};
let child = id("assess");
let destination = id("coordinate");
document
.execute(Command::Reparent {
element: child,
parent: Some(destination),
})
.unwrap();
let moved = document.snapshot();
assert_eq!(
document.project().elements[&child].parent,
Some(destination)
);
assert_eq!(
document.compiled().index.declarations[&child].path,
"oa/target.arcadia"
);
assert!(
document.bundle().files["oa/target.arcadia"]
.contains("// Assessment rationale follows this activity.")
);
assert!(
!document.bundle().files["oa/source.arcadia"]
.contains("// Assessment rationale follows this activity.")
);
let edited = comment(document.bundle(), "// Reviewed containment decision.\n");
assert!(document.apply_bundle(edited.clone()).unwrap());
assert_eq!(document.project(), &moved.decoded.project);
assert!(document.undo());
assert_eq!(document.bundle(), &moved.bundle);
assert!(document.undo());
assert_eq!(document.bundle(), &original.bundle);
assert_eq!(document.project(), &original.decoded.project);
assert!(!document.dirty());
assert!(document.redo());
assert_eq!(document.bundle(), &moved.bundle);
assert!(document.redo());
assert_eq!(document.bundle(), &edited);
let accepted = document.snapshot();
let invalid = comment(document.bundle(), "invalid_declaration;");
assert!(document.apply_bundle(invalid).is_err());
assert!(Arc::ptr_eq(&accepted, &document.snapshot()));
let reopened =
Document::from_bundle(document.bundle().clone(), ProjectLimits::default()).unwrap();
assert_eq!(reopened.project(), document.project());
assert_eq!(reopened.bundle(), document.bundle());
assert!(!reopened.dirty());
}
#[test]
fn recreated_equal_record_does_not_inherit_deleted_source_ownership() {
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: [
(
"project.arcadia".into(),
"arcadia 3; project rescue \"Rescue\" { include \"authored.arcadia\"; }\n"
.into(),
),
(
"authored.arcadia".into(),
"// Rationale for the original record.\noa_activity assess \"Assess\" {}\n"
.into(),
),
]
.into(),
};
let mut document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap();
document.mark_saved(document.snapshot());
let before = document.snapshot();
let element = document.project().elements.values().next().unwrap().clone();
let id = element.id;
document
.execute(Command::Batch(vec![
Command::Delete(id),
Command::CreateElement(element),
]))
.unwrap();
assert_eq!(document.project(), &before.decoded.project);
assert_ne!(
document.compiled().index.declarations[&id].path,
"authored.arcadia"
);
assert!(
document.bundle().files["authored.arcadia"]
.contains("// Rationale for the original record.")
);
assert!(!document.bundle().files["authored.arcadia"].contains("oa_activity"));
assert!(document.dirty());
assert_eq!(document.revision(), 1);
let recreated = document.snapshot();
assert!(document.undo());
assert_eq!(document.bundle(), &before.bundle);
assert!(!document.dirty());
assert!(document.redo());
assert_eq!(document.bundle(), &recreated.bundle);
}
#[test]
fn asynchronous_save_marks_its_snapshot_without_cleaning_later_edits() {
let mut document = document();
+48 -6
View File
@@ -7,6 +7,7 @@ use iced::{
Element as View, Fill, Font,
widget::{
self, Space, button, column, container, pick_list, row, scrollable, text, text_input,
tooltip,
},
};
use oa_core::*;
@@ -1080,7 +1081,7 @@ fn source_files(app: &App) -> View<'_, Message> {
);
}
}
let mut panel = column![
let panel = column![
text("SOURCE FILES").size(13),
label(format!(
"{} files · one system model",
@@ -1097,9 +1098,6 @@ fn source_files(app: &App) -> View<'_, Message> {
label("Includes define file membership. Closing a detached buffer never deletes its file."),
]
.spacing(9);
if app.pending_recovery.is_some() {
panel = panel.push(action("Recover interrupted save", Message::SourceRecover));
}
panel.into()
}
@@ -1144,8 +1142,49 @@ fn source(app: &App) -> View<'_, Message> {
} else {
column![].into()
};
let recovery: View<'_, Message> = if let Some(path) = &app.pending_recovery {
let edits_block_recovery =
!app.source_workspace.applied && app.source_workspace.has_edits();
let filename = path.file_name().unwrap_or_default().to_string_lossy();
container(
column![
tooltip(
text(format!("Interrupted save · {filename}")).size(15),
text(path.display().to_string()).size(12),
tooltip::Position::Bottom,
),
text(if edits_block_recovery {
"Revert the edited files before recovery. Recovery restores files to their state before the interrupted save, then reopens them."
} else if app.source_workspace.applied {
"Restore files to their state before the interrupted save. Your current model and editor drafts are kept."
} else {
"Restore files to their state before the interrupted save, then reopen them. This rolls back the incomplete save."
})
.size(13),
button(text("Recover interrupted save").size(12))
.padding(10)
.on_press_maybe(
(!edits_block_recovery && !app.busy).then_some(Message::SourceRecover),
),
]
.spacing(8),
)
.padding(12)
.width(Fill)
.style(style::card)
.into()
} else {
column![].into()
};
let mut diagnostics = column![].spacing(5);
let mut visible_diagnostics = 0;
for (index, issue) in app.source_diagnostics.iter().enumerate() {
// The persistent recovery banner supplies the action and explanation;
// repeating the internal journal path obscures ordinary diagnostics.
if app.pending_recovery.is_some() && issue.diagnostic.code == "pending_save" {
continue;
}
visible_diagnostics += 1;
diagnostics = diagnostics.push(
button(
text(format!(
@@ -1216,8 +1255,11 @@ fn source(app: &App) -> View<'_, Message> {
]
.spacing(7)
.align_y(iced::Center),
recovery,
text(app.source_workspace.active.clone()).font(Font::MONOSPACE).size(12).color(style::INK),
label(if !app.source_workspace.applied {
label(if app.pending_recovery.is_some() && !app.source_workspace.applied {
"The previous graphical model remains available while these files are recovered."
} else if !app.source_workspace.applied {
"This project needs repair. Apply all adopts it; the previous graphical model remains available."
} else if app.source_workspace.detached_dirty() {
"A detached file has an unsaved draft. Reattach it or revert that file before saving."
@@ -1226,7 +1268,7 @@ fn source(app: &App) -> View<'_, Message> {
}),
navigation,
scrollable(diagnostics)
.height((app.source_diagnostics.len() as f32 * 32.).min(132.))
.height((visible_diagnostics as f32 * 32.).min(132.))
]
.spacing(12)
)
+76
View File
@@ -33,6 +33,79 @@ pub struct IssueDto {
pub line: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub column: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub span: Option<SpanDto>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub related: Vec<SourceLocationDto>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SpanDto {
pub start: usize,
pub end: usize,
}
impl From<oa_core::language::Span> for SpanDto {
fn from(span: oa_core::language::Span) -> Self {
Self {
start: span.start,
end: span.end,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct SourceLocationDto {
pub path: String,
pub span: SpanDto,
}
pub(crate) fn source_issue(issue: oa_core::source_project::SourceDiagnostic) -> IssueDto {
let diagnostic = issue.diagnostic;
IssueDto {
severity: match diagnostic.severity {
oa_core::validation::Severity::Error => "error",
oa_core::validation::Severity::Warning => "warning",
},
code: diagnostic.code,
message: diagnostic.message,
field: diagnostic.field,
object_id: diagnostic.object_id,
line: Some(diagnostic.line),
column: Some(diagnostic.column),
path: Some(issue.path),
span: Some(diagnostic.span.into()),
related: issue
.related
.into_iter()
.map(|location| SourceLocationDto {
path: location.path,
span: location.span.into(),
})
.collect(),
}
}
pub(crate) fn source_error(
diagnostics: Vec<oa_core::source_project::SourceDiagnostic>,
) -> ApiError {
let code = if diagnostics
.iter()
.any(|d| d.diagnostic.code == "model_limit")
{
"model_limit"
} else {
"invalid_source_project"
};
let mut error = ApiError::new(
StatusCode::UNPROCESSABLE_ENTITY,
code,
"Source project could not be applied.",
);
error.issues = diagnostics.into_iter().map(source_issue).collect();
error
}
impl ApiError {
@@ -106,6 +179,9 @@ pub fn issues(model: &oa_core::Project) -> Vec<IssueDto> {
object_id: issue.element,
line: None,
column: None,
path: None,
span: None,
related: vec![],
})
.collect()
}
+131 -34
View File
@@ -1,7 +1,10 @@
use crate::{
AppState, blocking,
error::{ApiError, ApiResult, IssueDto, issues},
store::{MODEL_LIMITS, StoredProject, check_model_size, validate_model},
error::{ApiError, ApiResult, IssueDto, issues, source_issue},
store::{
MODEL_LIMITS, PreparedProject, SOURCE_LIMITS, StoredProject, check_model_size,
check_source_size, validate_model,
},
};
use axum::{
Json, Router,
@@ -14,6 +17,7 @@ use axum::{
response::{IntoResponse, Response},
routing::{get, post},
};
use oa_core::source_project::{self, SourceBundle};
use oa_core::{Command, Id, Kind, Layer, Project, RelationKind};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use serde_json::{Value, json};
@@ -31,6 +35,10 @@ pub(crate) fn router() -> Router<AppState> {
.route("/api/v1/openapi.json", get(openapi))
.route("/api/v1/projects", get(list_projects).post(create_project))
.route("/api/v1/projects/import", post(import_project))
.route(
"/api/v1/projects/import-source-project",
post(import_source_project),
)
.route(
"/api/v1/projects/{id}",
get(get_project)
@@ -42,6 +50,10 @@ pub(crate) fn router() -> Router<AppState> {
"/api/v1/projects/{id}/source",
get(get_source).put(put_source),
)
.route(
"/api/v1/projects/{id}/source-project",
get(get_source_project).put(put_source_project),
)
.route("/api/v1/projects/{id}/elements", get(list_elements))
.route("/api/v1/projects/{id}/relations", get(list_relations))
.route("/api/v1/projects/{id}/artifact-links", get(list_artifacts))
@@ -209,6 +221,9 @@ fn parse_source(body: &[u8]) -> ApiResult<oa_core::persistence::DecodeResult> {
object_id: issue.object_id,
line: Some(issue.line),
column: Some(issue.column),
path: None,
span: Some(issue.span.into()),
related: vec![],
})
.collect();
error
@@ -236,6 +251,9 @@ fn import_json(body: &[u8]) -> ApiResult<oa_core::persistence::DecodeResult> {
object_id: issue.element,
line: None,
column: None,
path: None,
span: None,
related: vec![],
})
.collect();
error
@@ -306,6 +324,20 @@ async fn import_project(
.await
}
async fn import_source_project(
State(state): State<AppState>,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
media(&headers, "application/json")?;
let body = bytes(body)?;
blocking(state, move |store| {
let bundle: SourceBundle = json_body(&body)?;
created(store.create_source_project(bundle)?)
})
.await
}
#[derive(Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct Page {
@@ -377,14 +409,14 @@ async fn rename_project(
let request: NameRequest = json_body(&body)?;
let snapshot = store.checked(id, &expected)?;
let current = &snapshot;
let model = oa_core::execute_batch(
let prepared = oa_core::prepare_command(
&current.model,
&[Command::EditProject {
&Command::EditProject {
id: current.model.id,
alias: current.model.alias.clone(),
name: request.name,
description: current.model.description.clone(),
}],
},
)
.map_err(|error| {
ApiError::new(
@@ -393,7 +425,8 @@ async fn rename_project(
error.to_string(),
)
})?;
let (stored, changed) = store.replace(id, &expected, model)?;
let prepared = PreparedProject::from_command(current, prepared)?;
let (stored, changed) = store.replace(id, &expected, prepared)?;
Ok(response(
&stored,
StatusCode::OK,
@@ -457,6 +490,48 @@ async fn get_source(
.await
}
async fn get_source_project(
State(state): State<AppState>,
id: ProjectPath,
headers: HeaderMap,
) -> ApiResult<Response> {
let id = path(id)?;
blocking(state, move |store| {
let stored = store.get(id)?;
Ok(not_modified(&stored, &headers)
.unwrap_or_else(|| response(&stored, StatusCode::OK, &stored.source_project)))
})
.await
}
async fn put_source_project(
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| {
store.checked(id, &expected)?;
let bundle: SourceBundle = json_body(&body)?;
let prepared = PreparedProject::from_bundle(bundle)?;
let diagnostics = prepared.source_issues();
let (stored, changed) = store.replace(id, &expected, prepared)?;
Ok(response(
&stored,
StatusCode::OK,
json!({
"revision": stored.revision, "changed": changed, "issues": diagnostics,
"source_project": stored.source_project
}),
))
})
.await
}
async fn put_model(
State(state): State<AppState>,
id: ProjectPath,
@@ -504,7 +579,7 @@ async fn replace_model(
Ok(response(
&stored,
StatusCode::OK,
json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model),"migration":stored.migration,"migration":stored.migration}),
json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model),"migration":stored.migration}),
))
})
.await
@@ -844,8 +919,11 @@ async fn get_validation(State(state): State<AppState>, id: ProjectPath) -> ApiRe
#[serde(deny_unknown_fields)]
struct ValidationRequest {
format: String,
// Keep RawValue directly in the struct: internally tagged enums buffer
// their content and cannot preserve raw model JSON for duplicate-key checks.
model: Option<Box<serde_json::value::RawValue>>,
source: Option<String>,
source_project: Option<SourceBundle>,
}
async fn validate_candidate(
@@ -861,31 +939,31 @@ async fn validate_candidate(
let snapshot = store.get(id)?;
let stored = &snapshot;
let request: ValidationRequest = json_body(&body)?;
let parsed = match (request.format.as_str(), request.model, request.source) {
("json", Some(model), None) => import_json(model.get().as_bytes()),
("arcadia", None, Some(source)) => parse_source(source.as_bytes()),
_ => return Err(ApiError::bad_request("Validation requires either format json with only model, or format arcadia with only source.")),
let parsed = match (request.format.as_str(), request.model, request.source, request.source_project) {
("json", Some(model), None, None) => import_json(model.get().as_bytes()),
("arcadia", None, Some(source), None) => parse_source(source.as_bytes()),
("source-project", None, None, Some(source_project)) => {
let result = check_source_size(&source_project).and_then(|()| {
// Advisory locations refer to submitted bytes, before Apply
// would insert missing persistent identities.
let diagnostics: Vec<_> = source_project::lint_bundle(&source_project, SOURCE_LIMITS).into_iter().map(source_issue).collect();
if !diagnostics.iter().any(|issue| issue.severity == "error") {
PreparedProject::from_bundle(source_project)?;
}
Ok(diagnostics)
});
let diagnostics = result.unwrap_or_else(candidate_issues);
let valid = !diagnostics.iter().any(|issue| issue.severity == "error");
return Ok(response(stored, StatusCode::OK, json!({"revision":stored.revision,"valid":valid,"issues":diagnostics,"migration":null})));
}
_ => return Err(ApiError::bad_request("Validation requires one matching format/payload: json with model, arcadia with source, or source-project with source_project.")),
};
let (valid, issues, migration) = match parsed.and_then(|decoded| {
validate_model(&decoded.project)?;
Ok(decoded)
}) {
Ok(decoded) => (true, issues(&decoded.project), decoded.migration),
Err(error) => {
let mut diagnostics = error.issues;
if diagnostics.is_empty() {
diagnostics.push(IssueDto {
severity: "error",
code: error.code.into(),
message: error.detail,
field: None,
object_id: None,
line: None,
column: None,
});
}
(false, diagnostics, None)
}
Err(error) => (false, candidate_issues(error), None),
};
Ok(response(
stored,
@@ -896,6 +974,24 @@ async fn validate_candidate(
.await
}
fn candidate_issues(error: ApiError) -> Vec<IssueDto> {
if !error.issues.is_empty() {
return error.issues;
}
vec![IssueDto {
severity: "error",
code: error.code.into(),
message: error.detail,
field: None,
object_id: None,
line: None,
column: None,
path: None,
span: None,
related: vec![],
}]
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct TransactionRequest {
@@ -919,13 +1015,13 @@ async fn transactions(
check_commands(&request.commands)?;
let snapshot = store.checked(id, &expected)?;
let current = &snapshot;
let model = oa_core::execute_batch(&current.model, &request.commands)
let candidate = oa_core::prepare_command(&current.model, &Command::Batch(request.commands))
.map_err(|error| ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "command_rejected", error.to_string()))?;
validate_model(&model)?;
let prepared = PreparedProject::from_command(current, candidate)?;
if request.dry_run {
return Ok(response(current, StatusCode::OK, json!({"revision":current.revision,"dry_run":true,"changed":model != current.model,"issues":issues(&model),"model":model})));
return Ok(response(current, StatusCode::OK, json!({"revision":current.revision,"dry_run":true,"changed":prepared.changed(current),"issues":prepared.source_issues(),"model":prepared.model(),"source_project":prepared.bundle()})));
}
let (stored, changed) = store.replace(id, &expected, model)?;
let (stored, changed) = store.replace(id, &expected, prepared)?;
Ok(response(&stored, StatusCode::OK, json!({"revision":stored.revision,"dry_run":false,"changed":changed,"issues":issues(&stored.model)})))
}).await
}
@@ -976,9 +1072,9 @@ async fn preview_transition(
.map_err(|error| ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "transition_rejected", error.to_string()))?;
if !plan.commands.is_empty() { check_commands(&plan.commands)?; }
if plan.conflicts.is_empty() {
let candidate = oa_core::transitions::apply_transition(&current.model, &plan)
let candidate = oa_core::transitions::prepare_transition(&current.model, &plan)
.map_err(|error| ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "transition_rejected", error.to_string()))?;
validate_model(&candidate)?;
PreparedProject::from_command(current, candidate)?;
}
Ok(response(current, StatusCode::OK, json!({"base_revision":current.revision,"plan":plan})))
}).await
@@ -1017,7 +1113,7 @@ async fn commit_transition(
if !request.plan.commands.is_empty() {
check_commands(&request.plan.commands)?;
}
let candidate = oa_core::transitions::apply_transition(&current.model, &request.plan)
let candidate = oa_core::transitions::prepare_transition(&current.model, &request.plan)
.map_err(|error| {
ApiError::new(
StatusCode::UNPROCESSABLE_ENTITY,
@@ -1025,7 +1121,8 @@ async fn commit_transition(
error.to_string(),
)
})?;
let (stored, changed) = store.replace(id, &expected, candidate)?;
let prepared = PreparedProject::from_command(current, candidate)?;
let (stored, changed) = store.replace(id, &expected, prepared)?;
Ok(response(
&stored,
StatusCode::OK,
+232 -40
View File
@@ -1,7 +1,8 @@
//! Single-owner on-disk envelopes. All methods run on a blocking worker.
use crate::error::{ApiError, ApiResult, issues};
use crate::error::{ApiError, ApiResult, issues, source_error};
use axum::http::StatusCode;
use oa_core::persistence::{DecodeResult, MigrationReport};
use oa_core::source_project::{self, CompiledSourceProject, ProjectLimits, SourceBundle};
use oa_core::{Id, Project};
use serde::{Deserialize, Serialize};
use std::{
@@ -13,6 +14,7 @@ use std::{
};
pub const MAX_MODEL_BYTES: usize = 8 * 1024 * 1024;
pub const MAX_SOURCE_PROJECT_BYTES: usize = 8 * 1024 * 1024;
pub const MAX_OBJECTS: usize = 5_000;
pub const MAX_PROJECTS: usize = 128;
pub const MAX_STORE_BYTES: usize = 256 * 1024 * 1024;
@@ -22,7 +24,13 @@ pub(crate) const MODEL_LIMITS: oa_core::language::ParseLimits = oa_core::languag
max_records: 20_000,
max_step_references: 50_000,
};
const MAX_ENVELOPE_BYTES: usize = MAX_MODEL_BYTES + 512;
pub(crate) const SOURCE_LIMITS: ProjectLimits = ProjectLimits {
max_files: 256,
max_include_depth: 32,
max_include_edges: 1_024,
model: MODEL_LIMITS,
};
const MAX_ENVELOPE_BYTES: usize = MAX_MODEL_BYTES + MAX_SOURCE_PROJECT_BYTES + 512;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -30,6 +38,7 @@ pub struct StoredProject {
pub resource_id: Id,
pub revision: u64,
pub model: Project,
pub source_project: SourceBundle,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub migration: Option<MigrationReport>,
}
@@ -93,11 +102,88 @@ struct StoreInner {
/// Constructed only after complete candidate validation, outside the catalog
/// mutex. Commit methods cannot accidentally accept an unchecked candidate.
struct PreparedModel(Project, Option<MigrationReport>);
impl PreparedModel {
pub(crate) struct PreparedProject {
compiled: CompiledSourceProject,
migration: Option<MigrationReport>,
}
impl PreparedProject {
fn new(model: Project) -> ApiResult<Self> {
validate_model(&model)?;
Ok(Self(model, None))
let prepared = Self::from_bundle(source_project::format_bundle(&model))?;
if prepared.model() != &model {
return Err(ApiError::internal(
"Generated source does not reproduce the model.",
));
}
Ok(prepared)
}
pub(crate) fn from_bundle(bundle: SourceBundle) -> ApiResult<Self> {
check_source_size(&bundle)?;
let compiled =
source_project::compile_bundle(&bundle, SOURCE_LIMITS).map_err(source_error)?;
Self::from_compiled(compiled)
}
fn from_compiled(compiled: CompiledSourceProject) -> ApiResult<Self> {
check_model_size(&compiled.decoded.project)?;
check_source_size(&compiled.bundle)?;
Ok(Self {
compiled,
migration: None,
})
}
pub(crate) fn from_command(
current: &StoredProject,
prepared: oa_core::PreparedCommand,
) -> ApiResult<Self> {
check_model_size(&prepared.project)?;
let compiled = source_project::compile_bundle(&current.source_project, SOURCE_LIMITS)
.map_err(source_error)?;
// Model equality alone is not a source no-op: delete/recreate and
// identity swaps can change declaration ownership without changing the
// final semantic snapshot.
let compiled = source_project::update_bundle_with_identity_map(
&compiled,
&prepared.project,
&prepared.identity_map,
SOURCE_LIMITS,
)
.map_err(source_error)?;
Self::from_compiled(compiled)
}
fn replacement(current: &StoredProject, decoded: DecodeResult) -> ApiResult<Self> {
validate_model(&decoded.project)?;
let compiled = source_project::compile_bundle(&current.source_project, SOURCE_LIMITS)
.map_err(source_error)?;
let compiled = source_project::update_bundle(&compiled, &decoded.project, SOURCE_LIMITS)
.map_err(source_error)?;
let mut prepared = Self::from_compiled(compiled)?;
prepared.migration = decoded.migration;
Ok(prepared)
}
pub(crate) fn model(&self) -> &Project {
&self.compiled.decoded.project
}
pub(crate) fn bundle(&self) -> &SourceBundle {
&self.compiled.bundle
}
pub(crate) fn source_issues(&self) -> Vec<crate::error::IssueDto> {
self.compiled
.diagnostics
.iter()
.cloned()
.map(crate::error::source_issue)
.collect()
}
pub(crate) fn changed(&self, current: &StoredProject) -> bool {
self.model() != &current.model || self.bundle() != &current.source_project
}
}
@@ -130,13 +216,17 @@ impl Store {
}
pub(crate) fn create(&self, model: Project) -> ApiResult<StoredProject> {
let prepared = PreparedModel::new(model)?;
let prepared = PreparedProject::new(model)?;
self.lock()?.create(prepared)
}
pub(crate) fn create_decoded(&self, decoded: DecodeResult) -> ApiResult<StoredProject> {
let mut prepared = PreparedModel::new(decoded.project)?;
prepared.1 = decoded.migration;
let mut prepared = PreparedProject::new(decoded.project)?;
prepared.migration = decoded.migration;
self.lock()?.create(prepared)
}
pub(crate) fn create_source_project(&self, bundle: SourceBundle) -> ApiResult<StoredProject> {
let prepared = PreparedProject::from_bundle(bundle)?;
self.lock()?.create(prepared)
}
pub(crate) fn replace_decoded(
@@ -145,8 +235,8 @@ impl Store {
expected: &str,
decoded: DecodeResult,
) -> ApiResult<(StoredProject, bool)> {
let mut prepared = PreparedModel::new(decoded.project)?;
prepared.1 = decoded.migration;
let current = self.checked(id, expected)?;
let prepared = PreparedProject::replacement(&current, decoded)?;
self.lock()?.replace(id, expected, prepared)
}
@@ -154,9 +244,8 @@ impl Store {
&self,
id: Id,
expected: &str,
model: Project,
prepared: PreparedProject,
) -> ApiResult<(StoredProject, bool)> {
let prepared = PreparedModel::new(model)?;
self.lock()?.replace(id, expected, prepared)
}
@@ -216,8 +305,7 @@ impl Store {
if bytes.len() > MAX_ENVELOPE_BYTES {
return Err(corrupt("Envelope exceeds the size limit.".into()));
}
total_bytes = total_bytes.saturating_add(bytes.len());
if projects.len() >= MAX_PROJECTS || total_bytes > MAX_STORE_BYTES {
if projects.len() >= MAX_PROJECTS {
return Err(StoreError::Capacity(
"At most 128 models and 256 MiB of serialized envelopes are supported.".into(),
));
@@ -228,6 +316,8 @@ impl Store {
resource_id: Id,
revision: u64,
model: Box<serde_json::value::RawValue>,
#[serde(default, deserialize_with = "present_source_project")]
source_project: Option<SourceBundle>,
}
let envelope: Envelope =
serde_json::from_slice(&bytes).map_err(|error| corrupt(error.to_string()))?;
@@ -236,10 +326,27 @@ impl Store {
MODEL_LIMITS,
)
.map_err(|error| corrupt(error.to_string()))?;
let prepared = if let Some(bundle) = envelope.source_project {
let prepared = PreparedProject::from_bundle(bundle.clone())
.map_err(|error| corrupt(error.detail))?;
if prepared.bundle() != &bundle || prepared.model() != &decoded.project {
return Err(corrupt(
"Stored source and model disagree, or source lacks stable identities."
.into(),
));
}
prepared
} else {
// Old model-only envelopes gain deterministic source in memory.
// Opening the store never saves or advances their revision.
PreparedProject::new(decoded.project.clone())
.map_err(|error| corrupt(error.detail))?
};
let stored = StoredProject {
resource_id: envelope.resource_id,
revision: envelope.revision,
model: decoded.project,
source_project: prepared.compiled.bundle,
migration: decoded.migration,
};
if stored.resource_id != filename_id || stored.revision == 0 {
@@ -247,8 +354,18 @@ impl Store {
"Resource ID/filename mismatch or zero revision.".into(),
));
}
validate_model(&stored.model).map_err(|error| corrupt(error.detail))?;
byte_sizes.insert(stored.resource_id, bytes.len());
let size = envelope_bytes(&stored)
.map_err(|error| corrupt(error.detail))?
.len();
// Capacity charges the complete accepted pair, including an old
// envelope's in-memory source upgrade before its next changed save.
total_bytes = total_bytes.saturating_add(size);
if total_bytes > MAX_STORE_BYTES {
return Err(StoreError::Capacity(
"Complete accepted envelopes exceed 256 MiB.".into(),
));
}
byte_sizes.insert(stored.resource_id, size);
if projects.insert(stored.resource_id, stored).is_some() {
return Err(corrupt("Duplicate hosted resource ID.".into()));
}
@@ -284,16 +401,16 @@ impl StoreInner {
Ok(current)
}
fn create(&mut self, prepared: PreparedModel) -> ApiResult<StoredProject> {
fn create(&mut self, prepared: PreparedProject) -> ApiResult<StoredProject> {
if self.projects.len() >= MAX_PROJECTS {
return Err(capacity_error());
}
let model = prepared.0;
let stored = StoredProject {
resource_id: Id::new_v4(),
revision: 1,
model,
migration: prepared.1,
model: prepared.compiled.decoded.project,
source_project: prepared.compiled.bundle,
migration: prepared.migration,
};
let size = self.write(&stored, true)?;
self.adopt(stored.clone(), size);
@@ -305,14 +422,13 @@ impl StoreInner {
&mut self,
id: Id,
expected: &str,
prepared: PreparedModel,
prepared: PreparedProject,
) -> ApiResult<(StoredProject, bool)> {
let current = self.checked(id, expected)?;
let model = prepared.0;
if current.model == model {
if !prepared.changed(current) {
let mut unchanged = current.clone();
if prepared.1.is_some() {
unchanged.migration = prepared.1;
if prepared.migration.is_some() {
unchanged.migration = prepared.migration;
}
return Ok((unchanged, false));
}
@@ -326,8 +442,9 @@ impl StoreInner {
let stored = StoredProject {
resource_id: id,
revision,
model,
migration: prepared.1,
model: prepared.compiled.decoded.project,
source_project: prepared.compiled.bundle,
migration: prepared.migration,
};
let size = self.write(&stored, false)?;
self.adopt(stored.clone(), size);
@@ -357,17 +474,7 @@ impl StoreInner {
}
fn write(&self, stored: &StoredProject, create: bool) -> ApiResult<usize> {
// Migration observations are response metadata, never persisted facts.
let envelope = serde_json::json!({"resource_id": stored.resource_id, "revision": stored.revision, "model": stored.model});
let bytes = serde_json::to_vec(&envelope)
.map_err(|_| ApiError::internal("Could not serialize model."))?;
if bytes.len() > MAX_ENVELOPE_BYTES {
return Err(ApiError::new(
StatusCode::PAYLOAD_TOO_LARGE,
"model_too_large",
"Stored model exceeds 8 MiB.",
));
}
let bytes = envelope_bytes(stored)?;
let previous = self
.byte_sizes
.get(&stored.resource_id)
@@ -410,6 +517,51 @@ fn capacity_error() -> ApiError {
)
}
fn present_source_project<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<SourceBundle>, D::Error> {
SourceBundle::deserialize(deserializer).map(Some)
}
fn envelope_bytes(stored: &StoredProject) -> ApiResult<Vec<u8>> {
// Migration observations are response metadata, never persisted facts.
#[derive(Serialize)]
struct Envelope<'a> {
resource_id: Id,
revision: u64,
model: &'a Project,
source_project: &'a SourceBundle,
}
let bytes = serde_json::to_vec(&Envelope {
resource_id: stored.resource_id,
revision: stored.revision,
model: &stored.model,
source_project: &stored.source_project,
})
.map_err(|_| ApiError::internal("Could not serialize accepted project."))?;
if bytes.len() > MAX_ENVELOPE_BYTES {
return Err(ApiError::new(
StatusCode::PAYLOAD_TOO_LARGE,
"project_too_large",
"Stored project exceeds the envelope limit.",
));
}
Ok(bytes)
}
pub(crate) fn check_source_size(bundle: &SourceBundle) -> ApiResult<()> {
let bytes = serde_json::to_vec(bundle)
.map_err(|_| ApiError::bad_request("Source project cannot be serialized."))?;
if bytes.len() > MAX_SOURCE_PROJECT_BYTES {
return Err(ApiError::new(
StatusCode::PAYLOAD_TOO_LARGE,
"source_project_too_large",
"Serialized source project exceeds 8 MiB after identity stabilization.",
));
}
Ok(())
}
pub(crate) fn check_model_size(model: &Project) -> ApiResult<()> {
MODEL_LIMITS
.check_model(model)
@@ -445,6 +597,46 @@ pub(crate) fn validate_model(model: &Project) -> ApiResult<()> {
mod tests {
use super::*;
#[test]
fn source_bytes_are_charged_on_legacy_open_update_and_delete() {
let directory = tempfile::tempdir().unwrap();
let resource_id = Id::new_v4();
let model = Project::empty();
let path = directory.path().join(format!("{resource_id}.json"));
let legacy_bytes = serde_json::to_vec(
&serde_json::json!({"resource_id":resource_id,"revision":4,"model":model}),
)
.unwrap();
fs::write(&path, &legacy_bytes).unwrap();
let store = Store::open(directory.path()).unwrap();
let current = store.get(resource_id).unwrap();
let accepted_size = envelope_bytes(&current).unwrap().len();
assert!(accepted_size > legacy_bytes.len());
assert_eq!(store.lock().unwrap().total_bytes, accepted_size);
assert_eq!(fs::read(&path).unwrap(), legacy_bytes);
let mut bundle = current.source_project.clone();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.push_str(&format!("// {}\n", "rationale ".repeat(512)));
let candidate = PreparedProject::from_bundle(bundle).unwrap();
let changed = store
.replace(resource_id, &current.etag(), candidate)
.unwrap()
.0;
let changed_size = envelope_bytes(&changed).unwrap().len();
assert!(changed_size > accepted_size + 5_000);
assert_eq!(store.lock().unwrap().total_bytes, changed_size);
assert_eq!(fs::read(&path).unwrap().len(), changed_size);
drop(store);
let store = Store::open(directory.path()).unwrap();
assert_eq!(store.lock().unwrap().total_bytes, changed_size);
store.delete(resource_id, &changed.etag()).unwrap();
assert_eq!(store.lock().unwrap().total_bytes, 0);
assert!(store.lock().unwrap().byte_sizes.is_empty());
}
#[test]
fn prepared_candidates_recheck_revision_at_commit_and_failed_loser_is_not_installed() {
let directory = tempfile::tempdir().unwrap();
@@ -456,8 +648,8 @@ mod tests {
winner.name = "Winning review".into();
loser.name = "Losing stale review".into();
// Both requests completed preparation against the same original head.
let winner = PreparedModel::new(winner).unwrap();
let loser = PreparedModel::new(loser).unwrap();
let winner = PreparedProject::new(winner).unwrap();
let loser = PreparedProject::new(loser).unwrap();
let committed = store
.lock()
.unwrap()
+13 -3
View File
@@ -8,6 +8,9 @@ use serde_json::{Value, json};
use std::{path::Path, sync::Arc};
use tokio::{sync::oneshot, task::JoinHandle};
#[path = "http/source_projects.rs"]
mod source_projects;
fn v2_missing_ports() -> Value {
let mut old: Value = serde_json::from_slice(include_bytes!(
"../../oa-core/tests/fixtures/v2-coastal-rescue.json"
@@ -1590,6 +1593,7 @@ async fn failed_atomic_replacement_preserves_the_accepted_model() {
let after = server.get(id).await;
assert_eq!(after.revision, before.revision);
assert_eq!(after.model, before.model);
assert_eq!(after.source_project, before.source_project);
std::fs::remove_dir(&envelope).unwrap();
std::fs::write(&envelope, bytes).unwrap();
server.stop().await;
@@ -1605,19 +1609,23 @@ fn storage_rejects_second_owners_corruption_and_identity_mismatch() {
let path = directory.path().join(format!("{filename}.json"));
std::fs::write(&path, "{broken").unwrap();
assert!(Store::open(directory.path()).is_err());
let model = Project::empty();
let mismatched = StoredProject {
migration: None,
resource_id: Id::new_v4(),
revision: 1,
model: Project::empty(),
source_project: oa_core::source_project::format_bundle(&model),
model,
};
std::fs::write(&path, serde_json::to_vec(&mismatched).unwrap()).unwrap();
assert!(Store::open(directory.path()).is_err());
let model = Project::empty();
let invalid = StoredProject {
migration: None,
resource_id: filename,
revision: 0,
model: Project::empty(),
source_project: oa_core::source_project::format_bundle(&model),
model,
};
std::fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
assert!(Store::open(directory.path()).is_err());
@@ -1773,11 +1781,13 @@ async fn fixed_store_capacity_is_enforced_on_http_and_restart() {
let directory = tempfile::tempdir().unwrap();
let mut last = None;
for _ in 0..oa_server::store::MAX_PROJECTS {
let model = Project::empty();
let envelope = StoredProject {
migration: None,
resource_id: Id::new_v4(),
revision: 1,
model: Project::empty(),
source_project: oa_core::source_project::format_bundle(&model),
model,
};
std::fs::write(
directory
@@ -0,0 +1,806 @@
use super::*;
use oa_core::source_project::*;
use std::collections::BTreeMap;
fn authored() -> SourceBundle {
let bundle = SourceBundle {
entry: "harbor.arcadia".into(),
files: BTreeMap::from([
("harbor.arcadia".into(), "// Harbor engineering rationale 航海\r\narcadia 3; project harbor \"Harbor rescue\" {\r\n include \"team/first.arcadia\";\r\n include \"team/second.arcadia\";\r\n include \"contracts.arcadia\";\r\n include \"work.arcadia\";\r\n}\r\n".into()),
("team/first.arcadia".into(), "// First payload rationale\r\nsa_exchange_item first \"First payload\" { description \"Reviewed first\"; }\r\n".into()),
("team/second.arcadia".into(), "// Second payload rationale\r\nsa_exchange_item second \"Second payload\" {}\r\n".into()),
("contracts.arcadia".into(), "// Contract ownership stays here\r\nsa_interface contract \"Contract\" { items [\r\n @first // first reference rationale\r\n ,\r\n @second // second reference rationale\r\n ]; }\r\n".into()),
("work.arcadia".into(), "// Operational rationale\r\noa_activity assess \"Assess assistance\" {}\r\n".into()),
]),
};
compile_bundle(&bundle, ProjectLimits::default())
.unwrap()
.bundle
}
fn id_for(model: &Project, alias: &str) -> Id {
model
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn assert_pair(stored: &StoredProject) -> CompiledSourceProject {
let compiled = compile_bundle(&stored.source_project, ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.project, stored.model);
assert_eq!(
compiled.bundle, stored.source_project,
"Stored IDs already stabilized"
);
compiled
}
async fn import(server: &TestServer, bundle: &SourceBundle) -> StoredProject {
let response = server
.client
.post(server.url("/projects/import-source-project"))
.json(bundle)
.send()
.await
.unwrap();
let status = response.status();
let body: Value = response.json().await.unwrap();
assert_eq!(status, StatusCode::CREATED, "{body}");
server
.get(body["resource_id"].as_str().unwrap().parse().unwrap())
.await
}
async fn put(server: &TestServer, current: &StoredProject, bundle: &SourceBundle) -> Value {
let response = server
.client
.put(server.url(&format!("/projects/{}/source-project", current.resource_id)))
.header("if-match", current.etag())
.json(bundle)
.send()
.await
.unwrap();
let status = response.status();
let body: Value = response.json().await.unwrap();
assert_eq!(status, StatusCode::OK, "{body}");
body
}
async fn transact(
server: &TestServer,
current: &StoredProject,
commands: &[Command],
dry_run: bool,
) -> Value {
let response = server
.client
.post(server.url(&format!("/projects/{}/transactions", current.resource_id)))
.header("if-match", current.etag())
.json(&json!({"commands":commands,"dry_run":dry_run}))
.send()
.await
.unwrap();
let status = response.status();
let body: Value = response.json().await.unwrap();
assert_eq!(status, StatusCode::OK, "{body}");
body
}
#[tokio::test]
async fn source_projects_preserve_exact_files_comments_and_comment_only_revisions() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let source = authored();
let original = import(&server, &source).await;
let id = original.resource_id;
assert_eq!(original.source_project, source);
assert_pair(&original);
let url = server.url(&format!("/projects/{id}/source-project"));
let exported = server.client.get(&url).send().await.unwrap();
assert_eq!(
exported.headers()["etag"].to_str().unwrap(),
original.etag()
);
assert_eq!(exported.json::<SourceBundle>().await.unwrap(), source);
assert_eq!(
server
.client
.get(&url)
.header("if-none-match", original.etag())
.send()
.await
.unwrap()
.status(),
StatusCode::NOT_MODIFIED
);
let mut edited = source.clone();
edited
.files
.get_mut("team/first.arcadia")
.unwrap()
.push_str("// Reviewed through automation\r\n");
let response = put(&server, &original, &edited).await;
assert_eq!(response["changed"], true);
assert_eq!(
response["source_project"],
serde_json::to_value(&edited).unwrap()
);
let current = server.get(id).await;
assert_eq!(current.revision, 2);
assert_eq!(current.model, original.model);
assert_eq!(current.source_project, edited);
let stale = server
.client
.post(server.url(&format!("/projects/{id}/transactions")))
.header("if-match", original.etag())
.json(&json!({"commands":[Command::Rename(current.model.id,"Stale change".into())]}))
.send()
.await
.unwrap();
assert_eq!(stale.status(), StatusCode::PRECONDITION_FAILED);
let filename = directory.path().join(format!("{id}.json"));
let saved_bytes = std::fs::read(&filename).unwrap();
let no_op = put(&server, &current, &edited).await;
assert_eq!(no_op["changed"], false);
assert_eq!(no_op["revision"], 2);
assert_eq!(std::fs::read(&filename).unwrap(), saved_bytes);
let mut renamed = edited;
let text = renamed.files.remove(&renamed.entry).unwrap();
renamed.entry = "rescue.arcadia".into();
renamed.files.insert(renamed.entry.clone(), text);
assert_eq!(put(&server, &current, &renamed).await["changed"], true);
let current = server.get(id).await;
assert_eq!(current.revision, 3);
assert_eq!(current.model, original.model);
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
let restored = server.get(id).await;
assert_eq!(restored.source_project, renamed);
assert_eq!(restored.etag(), current.etag());
assert_pair(&restored);
server.stop().await;
}
#[tokio::test]
async fn source_projects_failed_atomic_comment_write_preserves_both_representations() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let current = import(&server, &authored()).await;
let id = current.resource_id;
let envelope = directory.path().join(format!("{id}.json"));
let bytes = std::fs::read(&envelope).unwrap();
let mut edited = current.source_project.clone();
edited
.files
.get_mut("work.arcadia")
.unwrap()
.push_str("// Must not be adopted\r\n");
std::fs::remove_file(&envelope).unwrap();
std::fs::create_dir(&envelope).unwrap();
let failed = server
.client
.put(server.url(&format!("/projects/{id}/source-project")))
.header("if-match", current.etag())
.json(&edited)
.send()
.await
.unwrap();
assert_eq!(failed.status(), StatusCode::SERVICE_UNAVAILABLE);
let unchanged = server.get(id).await;
assert_eq!(unchanged.etag(), current.etag());
assert_eq!(unchanged.model, current.model);
assert_eq!(unchanged.source_project, current.source_project);
std::fs::remove_dir(&envelope).unwrap();
std::fs::write(&envelope, &bytes).unwrap();
assert!(!std::fs::read_dir(directory.path()).unwrap().any(|entry| {
entry
.unwrap()
.file_name()
.to_string_lossy()
.starts_with(".arcadia-write-")
}));
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
assert_eq!(server.get(id).await.source_project, current.source_project);
server.stop().await;
}
#[test]
fn source_projects_openapi_describes_the_coupled_envelope_and_source_diagnostics() {
let spec: Value = serde_json::from_str(include_str!("../../../../docs/openapi.json")).unwrap();
let schemas = &spec["components"]["schemas"];
assert!(
schemas["StoredProject"]["required"]
.as_array()
.unwrap()
.contains(&json!("source_project"))
);
assert_eq!(
schemas["SourceBundle"]["required"],
json!(["entry", "files"])
);
assert_eq!(schemas["SourceBundle"]["additionalProperties"], false);
assert_eq!(
schemas["SourceBundle"]["properties"]["files"]["maxProperties"],
256
);
for property in ["path", "span", "related"] {
assert!(schemas["Issue"]["properties"].get(property).is_some());
}
assert_eq!(
schemas["TransactionResult"]["properties"]["source_project"]["$ref"],
"#/components/schemas/SourceBundle"
);
assert!(
schemas["ValidationRequest"]["oneOf"]
.as_array()
.unwrap()
.iter()
.any(|candidate| candidate["properties"]["format"]["const"] == "source-project")
);
let routes = &spec["paths"];
assert_eq!(
routes["/api/v1/projects/import-source-project"]["post"]["requestBody"]["content"]["application/json"]
["schema"]["$ref"],
"#/components/schemas/SourceBundle"
);
assert_eq!(
routes["/api/v1/projects/{id}/source-project"]["get"]["responses"]["200"]["content"]["application/json"]
["schema"]["$ref"],
"#/components/schemas/SourceBundle"
);
assert_eq!(
routes["/api/v1/projects/{id}/source-project"]["put"]["responses"]["200"]["content"]["application/json"]
["schema"]["$ref"],
"#/components/schemas/SourceProjectMutationResult"
);
}
#[tokio::test]
async fn source_projects_command_dry_runs_and_identity_swaps_preserve_lexical_ownership() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let original = import(&server, &authored()).await;
let first = id_for(&original.model, "first");
let second = id_for(&original.model, "second");
let changed_id = Id::new_v4();
let mut element = original.model.elements[&first].clone();
element.id = changed_id;
element.alias = "revised_first".into();
element.name = "Reviewed payload".into();
let commands = vec![Command::EditElement(first, element)];
let filename = directory
.path()
.join(format!("{}.json", original.resource_id));
let before_bytes = std::fs::read(&filename).unwrap();
let dry_run = transact(&server, &original, &commands, true).await;
let proposed: SourceBundle = serde_json::from_value(dry_run["source_project"].clone()).unwrap();
assert!(proposed.files["team/first.arcadia"].contains(&changed_id.to_string()));
assert!(proposed.files["team/first.arcadia"].starts_with("// First payload rationale\r\n"));
assert_eq!(
proposed.files["team/second.arcadia"],
original.source_project.files["team/second.arcadia"]
);
assert!(
proposed.files["contracts.arcadia"].contains("@revised_first // first reference rationale")
);
assert_eq!(
server.get(original.resource_id).await.source_project,
original.source_project
);
assert_eq!(std::fs::read(&filename).unwrap(), before_bytes);
transact(&server, &original, &commands, false).await;
let current = server.get(original.resource_id).await;
assert_eq!(current.source_project, proposed);
assert_eq!(
assert_pair(&current).index.declarations[&changed_id].path,
"team/first.arcadia"
);
let temporary = Id::new_v4();
let swaps = vec![Command::Batch(vec![
Command::Reidentify(changed_id, temporary),
Command::Reidentify(second, changed_id),
Command::Reidentify(temporary, second),
])];
transact(&server, &current, &swaps, false).await;
let swapped = server.get(current.resource_id).await;
let compiled = assert_pair(&swapped);
assert_eq!(
compiled.index.declarations[&second].path,
"team/first.arcadia"
);
assert_eq!(
compiled.index.declarations[&changed_id].path,
"team/second.arcadia"
);
assert!(
swapped.source_project.files["team/first.arcadia"]
.starts_with("// First payload rationale\r\n")
);
assert!(
swapped.source_project.files["contracts.arcadia"]
.contains("@revised_first // first reference rationale")
);
// Removing and reusing a UUID does not attach the old authored declaration
// and its rationale to an unrelated new record.
let mut replacement = swapped.model.elements[&second].clone();
replacement.alias = "new_payload".into();
replacement.name = "Independent new payload".into();
replacement.description.clear();
transact(
&server,
&swapped,
&[Command::Delete(second), Command::CreateElement(replacement)],
false,
)
.await;
let replaced = server.get(swapped.resource_id).await;
let compiled = assert_pair(&replaced);
assert_ne!(
compiled.index.declarations[&second].path,
"team/first.arcadia"
);
assert!(
replaced.source_project.files["team/first.arcadia"].contains("First payload rationale")
);
assert!(!replaced.source_project.files["team/first.arcadia"].contains("new_payload"));
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
assert_eq!(
server.get(replaced.resource_id).await.source_project,
replaced.source_project
);
server.stop().await;
}
#[tokio::test]
async fn source_projects_equal_models_still_apply_changed_identity_provenance() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let original = import(&server, &authored()).await;
let work = id_for(&original.model, "assess");
let commands = [
Command::Delete(work),
Command::CreateElement(original.model.elements[&work].clone()),
];
let prepared =
oa_core::prepare_command(&original.model, &Command::Batch(commands.to_vec())).unwrap();
assert_eq!(prepared.project, original.model);
assert!(!prepared.identity_map.contains_key(&work));
let dry_run = transact(&server, &original, &commands, true).await;
assert_eq!(
dry_run["changed"], true,
"Recreated record must not inherit the deleted record's declaration ownership"
);
assert_eq!(
dry_run["model"],
serde_json::to_value(&original.model).unwrap()
);
let response = transact(&server, &original, &commands, false).await;
assert_eq!(response["changed"], true);
let changed = server.get(original.resource_id).await;
assert_eq!(changed.revision, original.revision + 1);
assert_eq!(changed.model, original.model);
let compiled = assert_pair(&changed);
assert_ne!(compiled.index.declarations[&work].path, "work.arcadia");
assert!(changed.source_project.files["work.arcadia"].contains("Operational rationale"));
assert!(!changed.source_project.files["work.arcadia"].contains("oa_activity"));
server.stop().await;
}
#[tokio::test]
async fn source_projects_model_interchange_and_transitions_keep_authored_modules() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let original = import(&server, &authored()).await;
let id = original.resource_id;
let work = id_for(&original.model, "assess");
let mut model = original.model.clone();
model.elements.get_mut(&work).unwrap().name = "Review assistance".into();
let response = server
.client
.put(server.url(&format!("/projects/{id}/model")))
.header("if-match", original.etag())
.json(&model)
.send()
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"{}",
response.text().await.unwrap()
);
let current = server.get(id).await;
assert_eq!(current.source_project.entry, "harbor.arcadia");
assert_eq!(
current.source_project.files["team/first.arcadia"],
original.source_project.files["team/first.arcadia"]
);
assert!(
current.source_project.files["work.arcadia"].starts_with("// Operational rationale\r\n")
);
model.elements.get_mut(&work).unwrap().description =
"Changed through canonical interchange".into();
let response = server
.client
.put(server.url(&format!("/projects/{id}/source")))
.header("if-match", current.etag())
.header("content-type", "text/plain")
.body(oa_core::language::format(&model))
.send()
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"{}",
response.text().await.unwrap()
);
let current = server.get(id).await;
assert_pair(&current);
let request = oa_core::transitions::TransitionRequest::new(
oa_core::transitions::TransitionDirection::OaToSa,
vec![work],
);
let response = server
.client
.post(server.url(&format!("/projects/{id}/transitions/preview")))
.header("if-match", current.etag())
.json(&request)
.send()
.await
.unwrap();
let status = response.status();
let preview: Value = response.json().await.unwrap();
assert_eq!(status, StatusCode::OK, "{preview}");
assert_eq!(server.get(id).await.source_project, current.source_project);
let response = server
.client
.post(server.url(&format!("/projects/{id}/transitions/apply")))
.header("if-match", current.etag())
.json(&preview)
.send()
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"{}",
response.text().await.unwrap()
);
let final_state = server.get(id).await;
assert_pair(&final_state);
assert!(
final_state
.model
.elements
.values()
.any(|e| e.kind() == Kind::Function)
);
assert_eq!(final_state.model.traces.len(), 1);
for path in [
"team/first.arcadia",
"team/second.arcadia",
"contracts.arcadia",
] {
assert_eq!(
final_state.source_project.files[path],
original.source_project.files[path]
);
}
server.stop().await;
}
#[tokio::test]
async fn source_projects_invalid_dependencies_and_duplicate_paths_never_change_the_head() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let current = import(&server, &authored()).await;
let id = current.resource_id;
let filename = directory.path().join(format!("{id}.json"));
let original_bytes = std::fs::read(&filename).unwrap();
// A same-named server file cannot satisfy a logical source include.
std::fs::write(
directory.path().join("server_only.arcadia"),
"oa_activity external \"Must not load\" {}\n",
)
.unwrap();
let mut cases = vec![];
let mut missing = current.source_project.clone();
missing
.files
.get_mut("work.arcadia")
.unwrap()
.push_str("include \"server_only.arcadia\";\n");
cases.push((missing, "missing_include"));
let mut duplicate = current.source_project.clone();
let first = duplicate.files["team/first.arcadia"].clone();
duplicate
.files
.get_mut("team/second.arcadia")
.unwrap()
.push_str(&first);
cases.push((duplicate, "duplicate_declaration"));
let mut invalid = current.source_project.clone();
invalid
.files
.get_mut("team/second.arcadia")
.unwrap()
.push('?');
cases.push((invalid, ""));
let mut cycle = current.source_project.clone();
cycle
.files
.get_mut("work.arcadia")
.unwrap()
.push_str("include \"harbor.arcadia\";\n");
cases.push((cycle, "include_cycle"));
let mut orphan = current.source_project.clone();
orphan
.files
.insert("orphan.arcadia".into(), "// not included\n".into());
cases.push((orphan, "unreachable_file"));
let mut escaped = current.source_project.clone();
escaped
.files
.insert("../outside.arcadia".into(), String::new());
cases.push((escaped, "invalid_path"));
let mut excessive = current.source_project.clone();
for n in 0..256 {
excessive
.files
.insert(format!("empty{n}.arcadia"), String::new());
}
cases.push((excessive, "project_limit"));
for (bundle, code) in cases {
let response = server
.client
.put(server.url(&format!("/projects/{id}/source-project")))
.header("if-match", current.etag())
.json(&bundle)
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let error: Value = response.json().await.unwrap();
let issue = &error["issues"][0];
if !code.is_empty() {
assert_eq!(issue["code"], code, "{error}");
}
assert!(issue["path"].is_string(), "{error}");
assert!(issue["span"]["start"].is_number(), "{error}");
assert!(issue["line"].as_u64().unwrap() >= 1);
if code == "duplicate_declaration" {
assert_eq!(issue["path"], "team/second.arcadia");
assert_eq!(issue["related"][0]["path"], "team/first.arcadia");
}
let response = server
.client
.post(server.url(&format!("/projects/{id}/validation")))
.json(&json!({"format":"source-project","source_project":bundle}))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let validation: Value = response.json().await.unwrap();
assert_eq!(validation["valid"], false, "{validation}");
assert_eq!(validation["issues"][0]["path"], issue["path"]);
assert_eq!(server.get(id).await.source_project, current.source_project);
assert_eq!(std::fs::read(&filename).unwrap(), original_bytes);
}
let body = "{\"entry\":\"a.arcadia\",\"files\":{\"a.arcadia\":\"one\",\"a.arcadia\":\"two\"}}";
let duplicate_keys = server
.client
.put(server.url(&format!("/projects/{id}/source-project")))
.header("if-match", current.etag())
.header("content-type", "application/json")
.body(body)
.send()
.await
.unwrap();
assert_eq!(duplicate_keys.status(), StatusCode::BAD_REQUEST);
assert_eq!(std::fs::read(&filename).unwrap(), original_bytes);
let missing_precondition = server
.client
.put(server.url(&format!("/projects/{id}/source-project")))
.json(&current.source_project)
.send()
.await
.unwrap();
assert_eq!(
missing_precondition.status(),
StatusCode::PRECONDITION_REQUIRED
);
server.stop().await;
}
#[tokio::test]
async fn source_projects_stabilize_once_and_enforce_size_after_insertions() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let bundle = SourceBundle {
entry: "entry.arcadia".into(),
files: BTreeMap::from([
(
"entry.arcadia".into(),
"// Header rationale\narcadia 3; project p \"P\" { include \"work.arcadia\"; }\n"
.into(),
),
(
"work.arcadia".into(),
"// Work rationale\noa_activity work \"Work\" {}\n".into(),
),
]),
};
let current = import(&server, &bundle).await;
assert_ne!(current.source_project, bundle);
assert!(current.source_project.files["work.arcadia"].starts_with("// Work rationale\n"));
assert_pair(&current);
assert_eq!(
put(&server, &current, &current.source_project).await["changed"],
false
);
let mut large = SourceBundle {
entry: "entry.arcadia".into(),
files: BTreeMap::from([(
"entry.arcadia".into(),
"arcadia 3; project p \"P\" {}\n// ".into(),
)]),
};
let base = serde_json::to_vec(&large).unwrap().len();
large
.files
.get_mut("entry.arcadia")
.unwrap()
.push_str(&"x".repeat(oa_server::store::MAX_SOURCE_PROJECT_BYTES - base - 8));
assert!(serde_json::to_vec(&large).unwrap().len() < oa_server::store::MAX_MODEL_BYTES);
let response = server
.client
.put(server.url(&format!("/projects/{}/source-project", current.resource_id)))
.header("if-match", current.etag())
.json(&large)
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
let error: Value = response.json().await.unwrap();
assert_eq!(error["code"], "source_project_too_large");
assert_eq!(
server.get(current.resource_id).await.source_project,
current.source_project
);
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
let restored = server.get(current.resource_id).await;
assert_eq!(restored.source_project, current.source_project);
assert_eq!(restored.etag(), current.etag());
server.stop().await;
}
#[tokio::test]
async fn source_projects_concurrent_comment_and_semantic_writes_share_one_revision() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let original = import(&server, &authored()).await;
let id = original.resource_id;
let mut bundle = original.source_project.clone();
bundle
.files
.get_mut("work.arcadia")
.unwrap()
.push_str("// Comment writer\r\n");
let comment = server
.client
.put(server.url(&format!("/projects/{id}/source-project")))
.header("if-match", original.etag())
.json(&bundle)
.send();
let semantic = server
.client
.patch(server.url(&format!("/projects/{id}")))
.header("if-match", original.etag())
.json(&json!({"name":"Semantic writer"}))
.send();
let (comment, semantic) = tokio::join!(comment, semantic);
let statuses = [comment.unwrap().status(), semantic.unwrap().status()];
assert_eq!(statuses.iter().filter(|s| **s == StatusCode::OK).count(), 1);
assert_eq!(
statuses
.iter()
.filter(|s| **s == StatusCode::PRECONDITION_FAILED)
.count(),
1
);
let accepted = server.get(id).await;
assert_eq!(accepted.revision, 2);
assert_pair(&accepted);
if accepted.model.name == "Semantic writer" {
assert_eq!(
accepted.source_project.files["work.arcadia"],
original.source_project.files["work.arcadia"]
);
} else {
assert_eq!(accepted.model, original.model);
assert_eq!(accepted.source_project, bundle);
}
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
assert_eq!(server.get(id).await.source_project, accepted.source_project);
server.stop().await;
}
#[tokio::test]
async fn source_projects_upgrade_old_envelopes_without_writes_and_reject_inconsistent_pairs() {
let directory = tempfile::tempdir().unwrap();
let resource = Id::new_v4();
let model = graph();
let filename = directory.path().join(format!("{resource}.json"));
let original =
serde_json::to_vec(&json!({"resource_id":resource,"revision":9,"model":model})).unwrap();
std::fs::write(&filename, &original).unwrap();
let server = TestServer::start(directory.path(), None).await;
let current = server.get(resource).await;
assert_eq!(current.model, model);
assert_eq!(current.revision, 9);
assert_pair(&current);
assert_eq!(std::fs::read(&filename).unwrap(), original);
let response = server
.client
.put(server.url(&format!("/projects/{resource}/model")))
.header("if-match", current.etag())
.json(&model)
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.json::<Value>().await.unwrap()["changed"], false);
assert_eq!(std::fs::read(&filename).unwrap(), original);
let mut bundle = current.source_project.clone();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.push_str("// Reviewed source upgrade\n");
put(&server, &current, &bundle).await;
let accepted = server.get(resource).await;
assert_eq!(accepted.revision, 10);
server.stop().await;
let saved = std::fs::read(&filename).unwrap();
let mut envelope: Value = serde_json::from_slice(&saved).unwrap();
assert!(envelope.get("source_project").is_some());
envelope["model"]["name"] = json!("Corrupted cache");
std::fs::write(&filename, serde_json::to_vec(&envelope).unwrap()).unwrap();
assert!(Store::open(directory.path()).is_err());
let mut envelope: Value = serde_json::from_slice(&saved).unwrap();
envelope["source_project"] = Value::Null;
std::fs::write(&filename, serde_json::to_vec(&envelope).unwrap()).unwrap();
assert!(
Store::open(directory.path()).is_err(),
"Present null source is not a legacy envelope"
);
let mut envelope: Value = serde_json::from_slice(&saved).unwrap();
let entry = envelope["source_project"]["entry"]
.as_str()
.unwrap()
.to_owned();
let header_id = format!(" id \"{}\"", accepted.model.id);
let source = envelope["source_project"]["files"][&entry]
.as_str()
.unwrap();
assert!(source.contains(&header_id));
envelope["source_project"]["files"][&entry] = json!(source.replacen(&header_id, "", 1));
let unstable_bytes = serde_json::to_vec(&envelope).unwrap();
std::fs::write(&filename, &unstable_bytes).unwrap();
assert!(
Store::open(directory.path()).is_err(),
"Accepted sources must not silently acquire new IDs on restart"
);
assert_eq!(std::fs::read(&filename).unwrap(), unstable_bytes);
std::fs::write(&filename, &saved).unwrap();
let server = TestServer::start(directory.path(), None).await;
assert_eq!(server.get(resource).await.source_project, bundle);
server.stop().await;
}
+84 -14
View File
@@ -631,14 +631,17 @@ desktop.</p>
<h2>Identity, revision and writes</h2>
<p>All model routes begin with <span class="docutils literal">/api/v1</span>. A hosted project's immutable service
<span class="docutils literal">resource_id</span> is different from its editable <span class="docutils literal">model.id</span>. Each stored file
contains <span class="docutils literal">{resource_id, revision, model}</span>. The revision starts at 1, increments
once per changed accepted transaction, and does not change for a no-op.</p>
contains <span class="docutils literal">{resource_id, revision, model, source_project}</span>. <span class="docutils literal">source_project</span>
is the accepted entry filename and map of authored source files; compiling it
must reproduce <span class="docutils literal">model</span>. The revision starts at 1, increments once per changed
accepted model/source pair, and does not change for an exact no-op. Comment-only
edits and file/entry changes count as changes even when the model is unchanged.</p>
<p>Existing-project writes require the exact quoted ETag returned by a project
read, for example:</p>
<pre class="literal-block">If-Match: &quot;p-6f7162cc-094c-4f30-9b70-3f6f355f0c7f-r1&quot;</pre>
<p>Missing preconditions return 428; stale preconditions return 412. Weak ETags,
wildcards and lists are not accepted on writes. GET of the envelope, model and
source supports <span class="docutils literal"><span class="pre">If-None-Match</span></span> and returns an empty 304 when unchanged.
source/source-project supports <span class="docutils literal"><span class="pre">If-None-Match</span></span> and returns an empty 304 when unchanged.
Use ETags as opaque strings; revisions are unsigned integer counters in JSON.</p>
<p>Every accepted changed write is persisted before the response. A worker captures
a revision-qualified snapshot, then parses and validates its candidate outside
@@ -649,7 +652,8 @@ Malformed or rejected inputs preserve the accepted model. Incomplete-model
warnings permit work and are returned in <span class="docutils literal">issues</span>.</p>
<p>There is no retained revision history, idempotency receipt, tombstone or
server undo. DELETE permanently removes the hosted envelope. Source/JSON
exports contain the current model only. They preserve the model's persistent
interchange exports contain the current model only; source-project export also
preserves the authored file layout and comments. They preserve persistent
aliases, diagrams, artifact references and OA/SA trace data. Artifact revision
strings refer to external artifacts, not past server snapshots.</p>
<p>A lost connection or 504 can occur after an admitted write has begun. That
@@ -707,6 +711,10 @@ bearer token, if any. Non-loopback binding requires a configured token.</p>
<td><p><span class="docutils literal">/api/v1/projects/import</span></p></td>
<td><p>Raw model JSON or Arcadia source; new service ID.</p></td>
</tr>
<tr><td><p>POST</p></td>
<td><p><span class="docutils literal"><span class="pre">/api/v1/projects/import-source-project</span></span></p></td>
<td><p>Complete source bundle as JSON; stabilize missing IDs and create a resource.</p></td>
</tr>
<tr><td><p>GET / PUT</p></td>
<td><p><span class="docutils literal"><span class="pre">/api/v1/projects/{id}/model</span></span></p></td>
<td><p>Export or conditionally replace native model JSON.</p></td>
@@ -715,6 +723,10 @@ 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}/source</span></span></p></td>
<td><p>Export or conditionally replace canonical UTF-8 source.</p></td>
</tr>
<tr><td><p>GET / PUT</p></td>
<td><p><span class="docutils literal"><span class="pre">/api/v1/projects/{id}/source-project</span></span></p></td>
<td><p>Export exact authored files or conditionally replace the complete bundle.</p></td>
</tr>
<tr><td><p>GET</p></td>
<td><p><span class="docutils literal"><span class="pre">/api/v1/projects/{id}/elements</span></span></p></td>
<td><p>Filter by <span class="docutils literal">kind</span>, <span class="docutils literal">layer</span>, <span class="docutils literal">alias</span>, name <span class="docutils literal">q</span>.</p></td>
@@ -757,7 +769,10 @@ bearer token, if any. Non-loopback binding requires a configured token.</p>
<span class="docutils literal">text/plain; <span class="pre">charset=utf-8</span></span> is used for Arcadia source. The service does not
guess formats from a filename. Unknown DTO fields, invalid UUIDs/enums, duplicate
model map keys and unsupported schemas are rejected. Maximum body size is
8 MiB. Compressed or encoded request bodies and browser Origin requests are
8 MiB. Accepted serialized model JSON and serialized source-bundle JSON each
have an 8 MiB cap, including IDs inserted during compilation. The stored
envelope allows both bounded representations plus small framing overhead.
Compressed or encoded request bodies and browser Origin requests are
not supported.</p>
<p>Model admission permits at most 5,000 elements plus relations, 20,000 total
persistent records (including the project, views and placements), and 50,000
@@ -770,10 +785,54 @@ 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 native JSON. Source import uses the shared language parser
and its supported migrations. Export and native JSON replacement use schema 2.
and its supported migrations. Export uses schema 3 / arcadia 3. 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>
</section>
<section id="authored-source-projects">
<h2>Authored source projects</h2>
<p>A source bundle contains <span class="docutils literal">entry</span> and a map of <span class="docutils literal">files</span>. For example:</p>
<pre class="literal-block">{
&quot;entry&quot;: &quot;rescue.arcadia&quot;,
&quot;files&quot;: {
&quot;rescue.arcadia&quot;: &quot;arcadia 3; 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>
<p>The entry is a root filename. All keys are canonical, portable, slash-separated
relative paths ending in <span class="docutils literal">.arcadia</span>. Includes resolve only against these
submitted strings. They never read the service filesystem, URLs, artifact
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. Bundle syntax is source3;
single-file legacy migration remains available through interchange import.</p>
<p>Compilation inserts UUIDs into headers that omit them. Import returns 201 with
Location/ETag; GET source-project retrieves the stabilized accepted bundle.
PUT returns <span class="docutils literal">revision</span>, <span class="docutils literal">changed</span>, located <span class="docutils literal">issues</span> and that accepted
<span class="docutils literal">source_project</span>. Preserve the returned IDs before subsequent editing.
With explicit IDs, untouched text, comments, line endings, entry name and module
ownership are preserved exactly. Re-submitting the same accepted bundle is a
no-op. Removing a file means submitting a complete valid bundle without that
file and its includes; no files are created or deleted on the service host.</p>
<p>All typed command, transition, rename and model-interchange writes update this
same accepted source project. Existing declarations and reference comments
follow command identity correspondence, including UUID swaps. Full-model
replacement uses common UUIDs as its correspondence boundary. It preserves
the accepted source layout; canonical interchange formatting/comments are not
adopted as a replacement authored workspace. Use source-project PUT for
authoring file layout and comments. A failed source writeback rejects the whole
write and never falls back to discarding authored files.</p>
<p>Old model-only envelopes are migrated to a deterministic source bundle in
memory when opened. Reading or an exact no-op does not rewrite their files or
advance revisions. Their next changed write persists both representations.
Startup rejects already-coupled envelopes whose source/model disagree or whose
source lacks stable IDs. The 256 MiB store budget charges complete accepted
envelopes, including the generated source of old envelopes before their next
save; an over-capacity old store is reported rather than silently pruned.</p>
</section>
<section id="queries-and-pagination">
<h2>Queries and pagination</h2>
<p>List filters combine using AND. Enum spelling is case-sensitive and comes
@@ -841,11 +900,14 @@ creation identities allow later commands in the batch to reference earlier
creations. The shared <span class="docutils literal">execute_batch</span> applies all operations to one candidate
and validates the final state. A rejected operation or final structural error
rejects the whole request. Reference rewrites/deletion cleanup follow the same
core logic as desktop editing.</p>
core logic as desktop editing. Command preparation retains original-to-candidate
identity correspondence through UUID edits, swaps, deletion and reuse. Source
writeback uses that correspondence to preserve declaration ownership and comments.</p>
<p>Dry run still requires <span class="docutils literal"><span class="pre">If-Match</span></span>. Its response includes <span class="docutils literal">revision</span>,
<span class="docutils literal">dry_run</span>, <span class="docutils literal">changed</span>, <span class="docutils literal">issues</span> and the proposed <span class="docutils literal">model</span>. It neither writes
nor reserves IDs. A committed response has the same fields except the model is
omitted. Commit must check the revision again. A no-op is successful with
<span class="docutils literal">dry_run</span>, <span class="docutils literal">changed</span>, <span class="docutils literal">issues</span> and the proposed <span class="docutils literal">model</span> and
<span class="docutils literal">source_project</span>. It validates source writeback as well as semantics and neither writes
nor reserves IDs. A committed response omits the proposed model and source
project. Commit must check the revision again. A no-op is successful with
<span class="docutils literal">changed: false</span> and the original ETag.</p>
</section>
<section id="contextual-process-and-chain-topology">
@@ -898,10 +960,15 @@ reinterpret old mixed lifelines as a strict new viewpoint.</p>
<pre class="literal-block">{&quot;format&quot;:&quot;json&quot;, &quot;model&quot;: {&quot;...&quot;:&quot;complete native Project record&quot;}}</pre>
<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 3; 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. A malformed request envelope or JSON model record
is a 400. Source diagnostics include one-based line and column according to the
shared language parser. Candidate validation does not replace current state.</p>
shared language parser. Candidate validation does not replace current state.
Source-project validation locations refer to the submitted bytes before UUID
insertion. Successful source-project PUT diagnostics refer to the returned
accepted bundle. Missing IDs are not saved or adopted by validation.</p>
</section>
<section id="typed-state-machines-and-events">
<h2>Typed state machines and events</h2>
@@ -934,7 +1001,8 @@ commit and disk persistence contract as other semantic records.</p>
conflict resolutions and optional destination diagram. All fields are explicit
in OpenAPI. At most 100 sources, 200 mappings and 400 field resolutions are
accepted. A conflict-free plan must fit the same 200-command transaction limit
and model-capacity limits.</p>
and model/source-capacity limits. A conflict-free preview also checks source
writeback, so its commands can update the same accepted authored workspace.</p>
<p>The preview response is <span class="docutils literal"><span class="pre">{&quot;base_revision&quot;:1,&quot;plan&quot;:{...}}</span></span>. It contains
planned changes, conflicts, warnings and exact commands/IDs. It does not write
the model or persist a server-side plan. Apply accepts that entire response and
@@ -959,7 +1027,9 @@ not a security digest. There is no plan token, expiration store or receipt.</p>
&quot;current_revision&quot;:3
}</pre>
<p><span class="docutils literal">issues</span> contain severity, code and message, and may include field,
object_id, line and column. Use
object_id, line, column, source <span class="docutils literal">path</span>, half-open UTF-8 byte <span class="docutils literal">span</span>
(<span class="docutils literal">start</span>, <span class="docutils literal">end</span>), and <span class="docutils literal">related</span> source locations (path/span). Lines and
columns are one-based; columns count Unicode scalars. Use
<span class="docutils literal">code</span> for client decisions, not the English message. <span class="docutils literal"><span class="pre">X-Request-ID</span></span> is a
response correlation UUID. This is not an idempotency key or receipt address.</p>
<ul class="simple">
@@ -970,7 +1040,7 @@ response correlation UUID. This is not an idempotency key or receipt address.</p
<li><p>405: unsupported HTTP method.</p></li>
<li><p>409: requested query revision changed or revision counter exhausted.</p></li>
<li><p>412: stale write precondition.</p></li>
<li><p>413: body or serialized model exceeds size limit.</p></li>
<li><p>413: body, serialized model, stabilized source bundle or envelope exceeds its size limit.</p></li>
<li><p>415: unsupported Content-Type or Content-Encoding.</p></li>
<li><p>422: rejected source/model/command, model count limit, or missing graph root.</p></li>
<li><p>428: missing If-Match.</p></li>
+89 -13
View File
@@ -12,8 +12,11 @@ Identity, revision and writes
All model routes begin with ``/api/v1``. A hosted project's immutable service
``resource_id`` is different from its editable ``model.id``. Each stored file
contains ``{resource_id, revision, model}``. The revision starts at 1, increments
once per changed accepted transaction, and does not change for a no-op.
contains ``{resource_id, revision, model, source_project}``. ``source_project``
is the accepted entry filename and map of authored source files; compiling it
must reproduce ``model``. The revision starts at 1, increments once per changed
accepted model/source pair, and does not change for an exact no-op. Comment-only
edits and file/entry changes count as changes even when the model is unchanged.
Existing-project writes require the exact quoted ETag returned by a project
read, for example::
@@ -22,7 +25,7 @@ read, for example::
Missing preconditions return 428; stale preconditions return 412. Weak ETags,
wildcards and lists are not accepted on writes. GET of the envelope, model and
source supports ``If-None-Match`` and returns an empty 304 when unchanged.
source/source-project supports ``If-None-Match`` and returns an empty 304 when unchanged.
Use ETags as opaque strings; revisions are unsigned integer counters in JSON.
Every accepted changed write is persisted before the response. A worker captures
@@ -35,7 +38,8 @@ warnings permit work and are returned in ``issues``.
There is no retained revision history, idempotency receipt, tombstone or
server undo. DELETE permanently removes the hosted envelope. Source/JSON
exports contain the current model only. They preserve the model's persistent
interchange exports contain the current model only; source-project export also
preserves the authored file layout and comments. They preserve persistent
aliases, diagrams, artifact references and OA/SA trace data. Artifact revision
strings refer to external artifacts, not past server snapshots.
@@ -81,12 +85,18 @@ bearer token, if any. Non-loopback binding requires a configured token.
* - POST
- ``/api/v1/projects/import``
- Raw model JSON or Arcadia source; new service ID.
* - POST
- ``/api/v1/projects/import-source-project``
- Complete source bundle as JSON; stabilize missing IDs and create a resource.
* - GET / PUT
- ``/api/v1/projects/{id}/model``
- Export or conditionally replace native model JSON.
* - GET / PUT
- ``/api/v1/projects/{id}/source``
- Export or conditionally replace canonical UTF-8 source.
* - GET / PUT
- ``/api/v1/projects/{id}/source-project``
- Export exact authored files or conditionally replace the complete bundle.
* - GET
- ``/api/v1/projects/{id}/elements``
- Filter by ``kind``, ``layer``, ``alias``, name ``q``.
@@ -119,7 +129,10 @@ bearer token, if any. Non-loopback binding requires a configured token.
``text/plain; charset=utf-8`` is used for Arcadia source. The service does not
guess formats from a filename. Unknown DTO fields, invalid UUIDs/enums, duplicate
model map keys and unsupported schemas are rejected. Maximum body size is
8 MiB. Compressed or encoded request bodies and browser Origin requests are
8 MiB. Accepted serialized model JSON and serialized source-bundle JSON each
have an 8 MiB cap, including IDs inserted during compilation. The stored
envelope allows both bounded representations plus small framing overhead.
Compressed or encoded request bodies and browser Origin requests are
not supported.
Model admission permits at most 5,000 elements plus relations, 20,000 total
@@ -134,10 +147,60 @@ 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 native JSON. Source import uses the shared language parser
and its supported migrations. Export and native JSON replacement use schema 2.
and its supported migrations. Export uses schema 3 / arcadia 3. 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.
Authored source projects
------------------------
A source bundle contains ``entry`` and a map of ``files``. For example::
{
"entry": "rescue.arcadia",
"files": {
"rescue.arcadia": "arcadia 3; project rescue \"Rescue\" { include \"oa/work.arcadia\"; }\n",
"oa/work.arcadia": "// Engineering rationale\noa_activity assess \"Assess assistance\" {}\n"
}
}
The entry is a root filename. All keys are canonical, portable, slash-separated
relative paths ending in ``.arcadia``. Includes resolve only against these
submitted strings. They never read the service filesystem, URLs, artifact
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. Bundle syntax is source3;
single-file legacy migration remains available through interchange import.
Compilation inserts UUIDs into headers that omit them. Import returns 201 with
Location/ETag; GET source-project retrieves the stabilized accepted bundle.
PUT returns ``revision``, ``changed``, located ``issues`` and that accepted
``source_project``. Preserve the returned IDs before subsequent editing.
With explicit IDs, untouched text, comments, line endings, entry name and module
ownership are preserved exactly. Re-submitting the same accepted bundle is a
no-op. Removing a file means submitting a complete valid bundle without that
file and its includes; no files are created or deleted on the service host.
All typed command, transition, rename and model-interchange writes update this
same accepted source project. Existing declarations and reference comments
follow command identity correspondence, including UUID swaps. Full-model
replacement uses common UUIDs as its correspondence boundary. It preserves
the accepted source layout; canonical interchange formatting/comments are not
adopted as a replacement authored workspace. Use source-project PUT for
authoring file layout and comments. A failed source writeback rejects the whole
write and never falls back to discarding authored files.
Old model-only envelopes are migrated to a deterministic source bundle in
memory when opened. Reading or an exact no-op does not rewrite their files or
advance revisions. Their next changed write persists both representations.
Startup rejects already-coupled envelopes whose source/model disagree or whose
source lacks stable IDs. The 256 MiB store budget charges complete accepted
envelopes, including the generated source of old envelopes before their next
save; an over-capacity old store is reported rather than silently pruned.
Queries and pagination
----------------------
@@ -217,12 +280,15 @@ creation identities allow later commands in the batch to reference earlier
creations. The shared ``execute_batch`` applies all operations to one candidate
and validates the final state. A rejected operation or final structural error
rejects the whole request. Reference rewrites/deletion cleanup follow the same
core logic as desktop editing.
core logic as desktop editing. Command preparation retains original-to-candidate
identity correspondence through UUID edits, swaps, deletion and reuse. Source
writeback uses that correspondence to preserve declaration ownership and comments.
Dry run still requires ``If-Match``. Its response includes ``revision``,
``dry_run``, ``changed``, ``issues`` and the proposed ``model``. It neither writes
nor reserves IDs. A committed response has the same fields except the model is
omitted. Commit must check the revision again. A no-op is successful with
``dry_run``, ``changed``, ``issues`` and the proposed ``model`` and
``source_project``. It validates source writeback as well as semantics and neither writes
nor reserves IDs. A committed response omits the proposed model and source
project. Commit must check the revision again. A no-op is successful with
``changed: false`` and the original ETag.
Contextual process and chain topology
@@ -287,10 +353,17 @@ or::
{"format":"arcadia", "source":"complete source document"}
or::
{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 3; project rescue \"Rescue\" {}"}}}
A well-formed validation request returns 200 with ``valid`` and ``issues`` even
if its candidate is invalid. A malformed request envelope or JSON model record
is a 400. Source diagnostics include one-based line and column according to the
shared language parser. Candidate validation does not replace current state.
Source-project validation locations refer to the submitted bytes before UUID
insertion. Successful source-project PUT diagnostics refer to the returned
accepted bundle. Missing IDs are not saved or adopted by validation.
Typed state machines and events
-------------------------------
@@ -327,7 +400,8 @@ Preview accepts the shared ``TransitionRequest`` record, including direction
conflict resolutions and optional destination diagram. All fields are explicit
in OpenAPI. At most 100 sources, 200 mappings and 400 field resolutions are
accepted. A conflict-free plan must fit the same 200-command transaction limit
and model-capacity limits.
and model/source-capacity limits. A conflict-free preview also checks source
writeback, so its commands can update the same accepted authored workspace.
The preview response is ``{"base_revision":1,"plan":{...}}``. It contains
planned changes, conflicts, warnings and exact commands/IDs. It does not write
@@ -357,7 +431,9 @@ All errors have ``Content-Type: application/problem+json``. Example::
}
``issues`` contain severity, code and message, and may include field,
object_id, line and column. Use
object_id, line, column, source ``path``, half-open UTF-8 byte ``span``
(``start``, ``end``), and ``related`` source locations (path/span). Lines and
columns are one-based; columns count Unicode scalars. Use
``code`` for client decisions, not the English message. ``X-Request-ID`` is a
response correlation UUID. This is not an idempotency key or receipt address.
@@ -368,7 +444,7 @@ response correlation UUID. This is not an idempotency key or receipt address.
* 405: unsupported HTTP method.
* 409: requested query revision changed or revision counter exhausted.
* 412: stale write precondition.
* 413: body or serialized model exceeds size limit.
* 413: body, serialized model, stabilized source bundle or envelope exceeds its size limit.
* 415: unsupported Content-Type or Content-Encoding.
* 422: rejected source/model/command, model count limit, or missing graph root.
* 428: missing If-Match.
+9 -7
View File
@@ -8,9 +8,9 @@ The normative contract is [language.rst](language.rst); [scope.md](scope.md) exp
|---|---|---|
| `oa-core` | Model, structural/completeness rules, commands/history, reference maintenance, diagram projection, OA/SA transitions, text and file persistence | Iced, renderer, HTTP or native UI |
| `oa-desktop` | Iced events/state, property and source drafts, graphical views, file interactions | Server availability or an account |
| `oa-server` | HTTP contract, authentication/admission, resource revisions and atomic current-model storage | Iced or a desktop session |
| `oa-server` | HTTP contract, authentication/admission, resource revisions and atomic model/source storage | Iced or a desktop session |
The server reuses the same core library as the desktop. A new automation endpoint must produce a core command/candidate rather than implement a second version of domain rules. Desktop and server are separate storage clients: there is currently no desktop HTTP adapter. Canonical source/JSON import/export is the explicit integration path.
The server reuses the same core library as the desktop. A new automation endpoint must produce a core command/candidate rather than implement a second version of domain rules. Desktop and server are separate storage clients: there is currently no desktop HTTP adapter. Explicit transfer supports canonical model interchange and authored source bundles.
## Model, identity and references
@@ -26,7 +26,7 @@ Element kind and layer derive from closed OA/SA bodies. Shared typed bodies repr
`validation.rs` separates structural errors from incomplete-model warnings. Dangling references, wrong kinds/layers, ownership cycles, incompatible ports and invalid uniqueness reject a candidate. Missing allocations, incomplete references and coverage gaps allow continued authoring. Small predicates support parent/relationship choices; they must agree with the committed-model policy.
The desktop retains up to 100 full-model history snapshots and compares against a saved snapshot to compute dirty state. This favors straightforward correctness for modest models. The cost is proportional to model size; indexed validation, persistent collections or delta history should follow profiling, not speculative infrastructure.
`prepare_command` returns a validated candidate, selection and original-record identity map without committing. Desktop `document.rs` then updates the accepted source through the shared writer before adopting one compiled model/source snapshot. It retains up to 100 accepted changes within an estimated 32 MiB history budget and compares the complete source bundle with its saved baseline. Comment-only changes have history; invalid updates retain the current snapshot. Exact snapshots preserve authored omissions and comments through Undo/Redo. Persistent collections or delta history should follow profiling, not speculative infrastructure.
## Nesting and graphical views
@@ -44,17 +44,19 @@ Keep domain mapping choices in the core or derive UI choices from its queries. I
## Text and draft state
`language.rs` provides a lexer/parser, deterministic formatter, tolerant source analysis and explicit V1 adapters. It collects declaration aliases before resolving UUID references, accepts forward references, rejects unknown/duplicate fields and typed-constructor errors, and validates the complete candidate. Canonical `arcadia 2` text preserves every persistent field, alias, UUID and ordered occurrence. Comments/whitespace are lexical trivia and normalize on Apply. V1 migration preserves existing identities and derives new placement/occurrence IDs deterministically.
`language.rs` provides the shared lexer/parser, deterministic formatter, tolerant source analysis and explicit V1/V2 migration readers. It collects global declaration aliases before resolving UUID references, rejects unknown/duplicate fields and typed-constructor errors, and validates the complete candidate. Source3 elements may nest to express semantic parenthood; iterative parsing retains real lexical-owner/body/own-field spans. Canonical flat output remains available and preserves every model field, alias, UUID and ordered occurrence.
`source_project.rs` compiles an explicit virtual bundle with relative includes, global identity and per-file diagnostics. It never reads a filesystem or network. The accepted bundle inserts missing identities while preserving authored bytes. The shared updater changes individual tokens and keyed list entries, then `source_project/source_structure.rs` composes structural moves from original declaration provenance. Nested reparenting moves attached comments and children across files; flat source organization stays flat. Recompilation and full model equality are mandatory before adoption. Canonical export alone normalizes comments/formatting.
`forms.rs` is a closed adapter over typed records. JSON values are temporary form structure; incomplete numbers, references and escaped strings stay in drafts until decoding a typed Command. This is not an extensible runtime schema or an alternate persisted metamodel. Every added field needs a usable control and meaningful authoring coverage; a no-op serialization test alone cannot establish graphical reachability.
`app.rs` coordinates the Session, selection, draft, source buffer/history, active view/camera, workflow dialogs and file state. `views.rs` renders task navigation and inspectors; `workflow.rs` handles explicit relationship/manual-trace/counterpart choices. `highlight.rs` translates tolerant core tokens to source coloring. Source lint runs after a short debounce and ignores obsolete results. Valid Apply commits once; rejected text stays editable with located diagnostics. Text-buffer history is distinct from committed-model history.
`app.rs` coordinates the accepted Document, selection, property draft, per-file source workspace, active view/camera, workflow dialogs and file state. `views.rs` renders task navigation and inspectors; `workflow.rs` handles explicit relationship/manual-trace/counterpart choices. `highlight.rs` translates tolerant core tokens to source coloring. Lint runs on a worker after a short debounce and ignores obsolete results. Valid Apply commits all reachable files once; invalid or inactive-file drafts retain located diagnostics. Per-buffer history is distinct from accepted model/source history.
## File and service persistence
Core persistence detects JSON by content and otherwise parses Arcadia text. `.arcadia` saves canonical text; other suffixes save strict JSON. A validated snapshot is written to a sibling temporary file, flushed and atomically installed. Normal desktop saves compare the previously read bytes to detect external changes before replacement. This is an optimistic local-file check, not a guarantee against every concurrent writer or power-loss scenario. File tasks run on blocking workers; the desktop preserves drafts and pending New/Open/Close continuations on failure or cancellation.
Core model persistence detects JSON by content and otherwise parses standalone Arcadia text; its model export uses canonical source or strict JSON. Native source-project loading/save uses explicit file receipts, validates physical path ownership and preserves comments across all included files. Save All compares previously read bytes, stages replacements and records an interrupted-save journal. Each replacement is atomic; multi-file visibility is not. Recovery is explicit and survives invalid drafts or missing entry files. File tasks run on blocking workers and failures preserve accepted models, drafts and pending continuations.
The service stores one `{resource_id, revision, model}` envelope per current hosted project. Service identity is immutable and independent of editable model identity. A directory lock enforces one local owner; a store mutex serializes revision checks and writes. Disk replacement precedes in-memory adoption. Authentication, body/admission limits, object/query limits and total store capacity are explicit. No distributed database, retained history, idempotency ledger or automatic artifact fetching is hidden behind this interface. See [server.md](server.md) and [api.rst](api.rst) for deployment, error and concurrency behavior.
The service stores one `{resource_id, revision, model, source_project}` envelope per current hosted project. One ETag protects both representations, including comment-only edits. Every command/transition uses shared source writeback and identity provenance before commit. Old model-only envelopes gain deterministic source in memory without changing disk or revision; inconsistent paired envelopes reject startup. Service identity stays independent of model identity. A directory lock enforces one local owner; a mutex rechecks the ETag and writes the complete envelope before in-memory adoption. Admission charges both model and source. See [server.md](server.md) and [api.rst](api.rst) for limits, errors and deployment.
## Extending a supported concept
+78 -23
View File
@@ -690,7 +690,9 @@ subnormal coordinates are retained.</p>
<h2>3. Element declarations and typed fields</h2>
<p>Each element declaration starts with its layer and kind, for example
<span class="docutils literal">oa_activity</span> or <span class="docutils literal">sa_function</span>. Every element body accepts
<span class="docutils literal">description</span> (default <span class="docutils literal">&quot;&quot;</span>) and <span class="docutils literal">parent</span> (default <span class="docutils literal">none</span>). The table
<span class="docutils literal">description</span> (default <span class="docutils literal">&quot;&quot;</span>) and <span class="docutils literal">parent</span> (default <span class="docutils literal">none</span> for a
document-level declaration, or its lexical element owner when nested; section
6.2). The table
lists all additional fields. <span class="docutils literal">ref</span> means <span class="docutils literal">&#64;alias</span>; <span class="docutils literal">ref?</span> also accepts
<span class="docutils literal">none</span>. Lists preserve order. Unknown or duplicate fields are errors.</p>
<table>
@@ -1450,14 +1452,18 @@ activity, capability, process, allocation, interaction, contribution,
realization, position, steps, true, false, null. Other field/constructor
spellings can be used in alias positions because references begin with <span class="docutils literal">&#64;</span>.</p>
<p>The grammar below uses <span class="docutils literal">*</span> for repetition and <span class="docutils literal">?</span> for optional syntax.
<span class="docutils literal">kind</span> is exactly an element/relation keyword listed above. Typed field
<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;3&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 = (kind | &quot;diagram&quot; | &quot;artifact&quot; | &quot;trace&quot;)
declaration = element
| (relation-kind | &quot;diagram&quot; | &quot;artifact&quot; | &quot;trace&quot;)
alias string identity? fields
| &quot;node&quot; alias identity? fields
element = element-kind alias string identity? element-body
element-body = &quot;{&quot; (field-name value &quot;;&quot; | element)* &quot;}&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;
@@ -1469,7 +1475,7 @@ record ends with a semicolon, including fields whose values are nested records.
Trailing list commas are invalid. Fields may appear in any order.</p>
<p>UUID strings use 8-4-4-4-12 hexadecimal spelling, with uppercase or lowercase
hexadecimal accepted. Canonical output uses lowercase. Omitting an identity
creates a fresh UUID for that parse. After Apply, canonical text includes the
creates a fresh UUID for that parse. After Apply, accepted source includes the
generated identity. Authors must keep identity clauses to preserve existing
objects across subsequent edits.</p>
<p>All declaration aliases are collected before references resolve, so forward
@@ -1493,8 +1499,8 @@ deeply nested drafts produce a diagnostic instead of exhausting the parser stack
root-level <span class="docutils literal">.arcadia</span> file with the source3 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 between declarations,
never inside a typed field value. For example:</p>
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 3;
project rescue &quot;Rescue&quot; id &quot;00000000-0000-4000-8000-000000000001&quot; {
description &quot;Operational model&quot;;
@@ -1514,7 +1520,7 @@ spelling; filesystems do not participate in resolution.</p>
includes. Include cycles are errors with their chain and including location.
Missing files, duplicate JSON file keys and supplied unreachable files are
errors. The compiler MUST NOT read local files, network resources, environment
variables or the process working directory. A future native loader is responsible
variables or the process working directory. The native loader is responsible
for translating a directory into this explicit bundle and enforcing physical
path ownership; the pure compiler grants no filesystem access.</p>
<p><span class="docutils literal">inspect_includes(path, source, is_entry, limits)</span> supports native discovery
@@ -1547,8 +1553,8 @@ input. The returned source index and diagnostics address the returned stabilized
bytes. Clients MUST retain that bundle after successful Apply; recompiling the
original buffers with missing identities would generate fresh identities.</p>
<p>Bundle diagnostics wrap the existing Diagnostic with a logical path and optional
related locations. Declaration spans, top-level field value spans, project
header/body/description spans and resolved include locations are available in
related locations. Declaration spans, own-field value spans, lexical owners,
declaration bodies, project header/body/description spans and resolved includes are available in
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
@@ -1557,6 +1563,36 @@ continue through their existing migration readers and reports. Modular projects
require source3; migration is performed before modular export, not independently
on included fragments. Modular source does not change the semantic JSON schema.</p>
</section>
<section id="nested-element-ownership">
<h3>6.2. Nested element ownership</h3>
<p>An element body MAY contain element declarations interleaved with its ordinary
fields. Its immediate enclosing element is its lexical owner. For example:</p>
<pre class="literal-block">sa_actor harbor &quot;Harbor operator&quot; {
sa_port radio &quot;Radio&quot; {
contract { Component { kind Standard; orientation Unset; }; };
}
}</pre>
<p>Nesting expresses semantic <span class="docutils literal">Element.parent</span> only. It does not allocate a
function to an actor or System, deploy equipment, or add or reorder occurrence
references in an owner's explicit <span class="docutils literal">steps</span> list. Those relationships retain
their explicit records and fields. The
same parent-kind, layer and acyclicity constraints apply as for flat source.
Only element declarations can nest: relations, diagrams, placements, artifacts,
traces and include directives remain at document level. A typed record value
contains fields, never element declarations.</p>
<p>If a nested declaration omits <span class="docutils literal">parent</span>, it inherits its lexical owner's UUID.
An explicit <span class="docutils literal">parent</span> MUST reference that owner; another reference or <span class="docutils literal">none</span>
is an error located at the parent value. At document level, omitted <span class="docutils literal">parent</span>
means <span class="docutils literal">none</span> and explicit parent references retain their existing meaning.
Nesting creates no alias namespace. Aliases and UUIDs remain globally unique,
and references can target nested declarations before or after their definition
or from another included file. Identity stabilization inserts missing UUIDs in
nested headers just as it does in document-level headers.</p>
<p>Source positions distinguish each declaration's own fields from its descendants'
fields. A declaration's body span encloses its lexical children as well as its
own fields. An inherited parent has no synthetic source token or field span.
Located diagnostics and editor navigation MUST address real source bytes.</p>
</section>
</section>
<section id="canonical-output-and-source-tooling">
<h2>7. Canonical output and source tooling</h2>
@@ -1574,11 +1610,10 @@ relations; diagrams and their nodes follow, then artifacts, then traces.
Fields use deterministic name order. Output ends with LF. Alias values come
from the model, never from regenerating display names.</p>
<p>Comments and input whitespace are lexical trivia to the canonical writer.
Canonical export and the current desktop single-document Apply path do not
preserve them; the editor MUST explain that behavior. This existing path does not
define an acceptable lossless authoring policy for a future project editor.
The pure project compiler in section 6.1 preserves them while inserting missing
identities. Project update/export distinctions are described below.
Canonical export does not preserve them. The source-project editor and compiler
preserve authored comments and whitespace on Apply while inserting missing
identities. Explicit canonical export remains a separate operation. Project
update/export distinctions are described below.
Camera position, selection, source cursor, and undo history are not stored
model fields. Layout and collapse state inside NodePlacement are model data.</p>
<p>The pure <span class="docutils literal">format_bundle</span> exporter uses the same typed declaration writer as
@@ -1594,12 +1629,24 @@ For every valid P, <span class="docutils literal"><span class="pre">compile_bund
when the caller supplies sufficient admission budgets.</p>
<p><span class="docutils literal">update_bundle(compiled, P, limits)</span> is a core source update facility. It
checks that the compiled document/index/model snapshot still agrees, preserves
existing declaration file ownership and all untouched document bytes, and
recompiles the result before returning it. New children inherit their owner's
file; new roots use canonical module rules and add an explicit entry include.
untouched document bytes, and recompiles the result before returning it. Flat
declarations retain their document-level position and file when reparented.
Nested declarations move into their new semantic owner's body, across files if
necessary; becoming a root moves them to document level. New children prefer
their owner's body. New roots use canonical module rules and add an explicit
entry include.
Removed declarations do not delete source files. Alias changes also update
declarations containing references to that alias. Invalid or stale updates
return errors without mutating the input workspace.</p>
<p>A moved declaration carries its contiguous leading comment block and comments
following its closing brace on the same line. A blank line separates a comment
block from the following declaration; such gap comments stay in the enclosing
scope. Comments trailing a preceding field likewise stay with that field.
Deletion retains annotations in the nearest surviving original scope and
separates them from adjacent declarations with blank lines. Surviving descendants
move with an enclosing declaration unless their own semantic parent changes;
independent parent and child moves are resolved together. Leading whitespace
adjusts to the destination scope; other token spelling remains authored.</p>
<p>Updates MUST NOT canonicalize surviving declarations in full. Header, field and
nested value positions are derived from the existing syntax parser. Unchanged
fields retain their exact bytes, including field order, omitted defaults, number
@@ -1623,23 +1670,31 @@ that the result remains valid syntax. Unkeyed or duplicate-key lists use source
positions. Removed-entry comments remain as annotations in the list; newly added
entries receive formatted syntax. These rules preserve comments without silently
attaching new semantic identity to an ambiguous list entry.</p>
<p>Retained declaration UUIDs and source ownership remain unchanged. Records match
by UUID, not display name or alias. The model-only <span class="docutils literal">update_bundle</span> treats an
<p>Retained declaration UUIDs remain unchanged unless explicitly edited. Source
ownership remains unchanged except for the nested relocation rules above.
Records match by UUID, not display name or alias. The model-only <span class="docutils literal">update_bundle</span> treats an
explicit UUID replacement as removal and creation. The desktop instead uses
<span class="docutils literal">update_bundle_with_identity_map</span> with the surviving-record provenance from
<span class="docutils literal">prepare_command</span>: explicit identity edits and sequential identity swaps MUST
preserve each original declaration's owning file and attached source trivia.
preserve each original declaration's owning file and attached source trivia,
unless the same edit changes its nested semantic parent and requires relocation.
New records have no original identity mapping. The mapping MUST be injective,
refer to existing records and preserve record classes. The
result is recompiled, reindexed and checked for equality with the requested
model before return. Source changes needed for undo are workspace history data;
model before return. Equal model values do not imply a no-op if command
provenance removes or replaces a declaration; the resulting model and source
bundle together determine whether the accepted state changed.
Source changes needed for undo are workspace history data;
callers should retain complete compiled workspace snapshots rather than infer
past omission/layout choices from a model-only undo.</p>
<p>Canonical modular export is a distinct, explicit operation and does not promise
original comment/layout preservation. The native editor integrates per-file
drafts and source-project saves as specified below. The pure compiler performs
no filesystem access. REST source endpoints currently exchange a canonical
model; they do not yet exchange the multi-file source bundle.</p>
no filesystem access. REST <span class="docutils literal"><span class="pre">source-project</span></span> endpoints exchange the accepted
bundle with all its files. One revision and ETag protect the model and source
together, including source-only comment edits; stored envelopes contain both.
Canonical <span class="docutils literal">source</span> endpoints remain explicit model interchange. The precise
transport, admission and persistence contract is in <span class="docutils literal">api.rst</span>.</p>
<p><span class="docutils literal">parse</span> returns a complete structurally validated candidate or error
diagnostics and no model. Engineering warnings alone MUST NOT reject a valid
candidate. Parsing may stop at the first syntax/conversion error. Diagnostics
+84 -23
View File
@@ -73,7 +73,9 @@ subnormal coordinates are retained.
Each element declaration starts with its layer and kind, for example
``oa_activity`` or ``sa_function``. Every element body accepts
``description`` (default ``""``) and ``parent`` (default ``none``). The table
``description`` (default ``""``) and ``parent`` (default ``none`` for a
document-level declaration, or its lexical element owner when nested; section
6.2). The table
lists all additional fields. ``ref`` means ``@alias``; ``ref?`` also accepts
``none``. Lists preserve order. Unknown or duplicate fields are errors.
@@ -792,7 +794,8 @@ realization, position, steps, true, false, null. Other field/constructor
spellings can be used in alias positions because references begin with ``@``.
The grammar below uses ``*`` for repetition and ``?`` for optional syntax.
``kind`` is exactly an element/relation keyword listed above. Typed field
``element-kind`` and ``relation-kind`` are exactly the corresponding declaration
keywords listed above. Typed field
sets and enum constructors are closed by sections 3–5.
.. code-block:: text
@@ -800,9 +803,12 @@ sets and enum constructors are closed by sections 3–5.
document = "arcadia" "3" ";" project
project = "project" alias string identity? "{" project-entry* "}"
project-entry = "description" string ";" | declaration
declaration = (kind | "diagram" | "artifact" | "trace")
declaration = element
| (relation-kind | "diagram" | "artifact" | "trace")
alias string identity? fields
| "node" alias identity? fields
element = element-kind alias string identity? element-body
element-body = "{" (field-name value ";" | element)* "}"
identity = "id" uuid-string
fields = "{" (field-name value ";")* "}"
value = string | number | "true" | "false" | "none"
@@ -816,7 +822,7 @@ Trailing list commas are invalid. Fields may appear in any order.
UUID strings use 8-4-4-4-12 hexadecimal spelling, with uppercase or lowercase
hexadecimal accepted. Canonical output uses lowercase. Omitting an identity
creates a fresh UUID for that parse. After Apply, canonical text includes the
creates a fresh UUID for that parse. After Apply, accepted source includes the
generated identity. Authors must keep identity clauses to preserve existing
objects across subsequent edits.
@@ -844,8 +850,8 @@ The pure ``source_project`` core API compiles a virtual source bundle, containin
root-level ``.arcadia`` file with the source3 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 between declarations,
never inside a typed field value. For example::
project wrapper, or project description. Includes MUST occur at document level,
never inside an element body or typed field value. For example::
arcadia 3;
project rescue "Rescue" id "00000000-0000-4000-8000-000000000001" {
@@ -868,7 +874,7 @@ Every reachable file is compiled once, including through repeated or diamond
includes. Include cycles are errors with their chain and including location.
Missing files, duplicate JSON file keys and supplied unreachable files are
errors. The compiler MUST NOT read local files, network resources, environment
variables or the process working directory. A future native loader is responsible
variables or the process working directory. The native loader is responsible
for translating a directory into this explicit bundle and enforcing physical
path ownership; the pure compiler grants no filesystem access.
@@ -906,8 +912,8 @@ bytes. Clients MUST retain that bundle after successful Apply; recompiling the
original buffers with missing identities would generate fresh identities.
Bundle diagnostics wrap the existing Diagnostic with a logical path and optional
related locations. Declaration spans, top-level field value spans, project
header/body/description spans and resolved include locations are available in
related locations. Declaration spans, own-field value spans, lexical owners,
declaration bodies, project header/body/description spans and resolved includes are available in
the source index. Columns remain one-based Unicode scalars within the real file;
spans never refer to concatenated or synthetic source.
@@ -917,6 +923,41 @@ continue through their existing migration readers and reports. Modular projects
require source3; migration is performed before modular export, not independently
on included fragments. Modular source does not change the semantic JSON schema.
6.2. Nested element ownership
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
An element body MAY contain element declarations interleaved with its ordinary
fields. Its immediate enclosing element is its lexical owner. For example::
sa_actor harbor "Harbor operator" {
sa_port radio "Radio" {
contract { Component { kind Standard; orientation Unset; }; };
}
}
Nesting expresses semantic ``Element.parent`` only. It does not allocate a
function to an actor or System, deploy equipment, or add or reorder occurrence
references in an owner's explicit ``steps`` list. Those relationships retain
their explicit records and fields. The
same parent-kind, layer and acyclicity constraints apply as for flat source.
Only element declarations can nest: relations, diagrams, placements, artifacts,
traces and include directives remain at document level. A typed record value
contains fields, never element declarations.
If a nested declaration omits ``parent``, it inherits its lexical owner's UUID.
An explicit ``parent`` MUST reference that owner; another reference or ``none``
is an error located at the parent value. At document level, omitted ``parent``
means ``none`` and explicit parent references retain their existing meaning.
Nesting creates no alias namespace. Aliases and UUIDs remain globally unique,
and references can target nested declarations before or after their definition
or from another included file. Identity stabilization inserts missing UUIDs in
nested headers just as it does in document-level headers.
Source positions distinguish each declaration's own fields from its descendants'
fields. A declaration's body span encloses its lexical children as well as its
own fields. An inherited parent has no synthetic source token or field span.
Located diagnostics and editor navigation MUST address real source bytes.
7. Canonical output and source tooling
--------------------------------------
@@ -940,11 +981,10 @@ Fields use deterministic name order. Output ends with LF. Alias values come
from the model, never from regenerating display names.
Comments and input whitespace are lexical trivia to the canonical writer.
Canonical export and the current desktop single-document Apply path do not
preserve them; the editor MUST explain that behavior. This existing path does not
define an acceptable lossless authoring policy for a future project editor.
The pure project compiler in section 6.1 preserves them while inserting missing
identities. Project update/export distinctions are described below.
Canonical export does not preserve them. The source-project editor and compiler
preserve authored comments and whitespace on Apply while inserting missing
identities. Explicit canonical export remains a separate operation. Project
update/export distinctions are described below.
Camera position, selection, source cursor, and undo history are not stored
model fields. Layout and collapse state inside NodePlacement are model data.
@@ -962,13 +1002,26 @@ when the caller supplies sufficient admission budgets.
``update_bundle(compiled, P, limits)`` is a core source update facility. It
checks that the compiled document/index/model snapshot still agrees, preserves
existing declaration file ownership and all untouched document bytes, and
recompiles the result before returning it. New children inherit their owner's
file; new roots use canonical module rules and add an explicit entry include.
untouched document bytes, and recompiles the result before returning it. Flat
declarations retain their document-level position and file when reparented.
Nested declarations move into their new semantic owner's body, across files if
necessary; becoming a root moves them to document level. New children prefer
their owner's body. New roots use canonical module rules and add an explicit
entry include.
Removed declarations do not delete source files. Alias changes also update
declarations containing references to that alias. Invalid or stale updates
return errors without mutating the input workspace.
A moved declaration carries its contiguous leading comment block and comments
following its closing brace on the same line. A blank line separates a comment
block from the following declaration; such gap comments stay in the enclosing
scope. Comments trailing a preceding field likewise stay with that field.
Deletion retains annotations in the nearest surviving original scope and
separates them from adjacent declarations with blank lines. Surviving descendants
move with an enclosing declaration unless their own semantic parent changes;
independent parent and child moves are resolved together. Leading whitespace
adjusts to the destination scope; other token spelling remains authored.
Updates MUST NOT canonicalize surviving declarations in full. Header, field and
nested value positions are derived from the existing syntax parser. Unchanged
fields retain their exact bytes, including field order, omitted defaults, number
@@ -995,24 +1048,32 @@ positions. Removed-entry comments remain as annotations in the list; newly added
entries receive formatted syntax. These rules preserve comments without silently
attaching new semantic identity to an ambiguous list entry.
Retained declaration UUIDs and source ownership remain unchanged. Records match
by UUID, not display name or alias. The model-only ``update_bundle`` treats an
Retained declaration UUIDs remain unchanged unless explicitly edited. Source
ownership remains unchanged except for the nested relocation rules above.
Records match by UUID, not display name or alias. The model-only ``update_bundle`` treats an
explicit UUID replacement as removal and creation. The desktop instead uses
``update_bundle_with_identity_map`` with the surviving-record provenance from
``prepare_command``: explicit identity edits and sequential identity swaps MUST
preserve each original declaration's owning file and attached source trivia.
preserve each original declaration's owning file and attached source trivia,
unless the same edit changes its nested semantic parent and requires relocation.
New records have no original identity mapping. The mapping MUST be injective,
refer to existing records and preserve record classes. The
result is recompiled, reindexed and checked for equality with the requested
model before return. Source changes needed for undo are workspace history data;
model before return. Equal model values do not imply a no-op if command
provenance removes or replaces a declaration; the resulting model and source
bundle together determine whether the accepted state changed.
Source changes needed for undo are workspace history data;
callers should retain complete compiled workspace snapshots rather than infer
past omission/layout choices from a model-only undo.
Canonical modular export is a distinct, explicit operation and does not promise
original comment/layout preservation. The native editor integrates per-file
drafts and source-project saves as specified below. The pure compiler performs
no filesystem access. REST source endpoints currently exchange a canonical
model; they do not yet exchange the multi-file source bundle.
no filesystem access. REST ``source-project`` endpoints exchange the accepted
bundle with all its files. One revision and ETag protect the model and source
together, including source-only comment edits; stored envelopes contain both.
Canonical ``source`` endpoints remain explicit model interchange. The precise
transport, admission and persistence contract is in ``api.rst``.
``parse`` returns a complete structurally validated candidate or error
diagnostics and no model. Engineering warnings alone MUST NOT reject a valid
+374 -25
View File
@@ -3,7 +3,7 @@
"info": {
"title": "Arcadia Studio model API",
"version": "1.0.0",
"description": "Current schema-3 snapshots and conditional atomic writes. Request body limit: 8 MiB; at most 200 nested commands; no retained revisions, request receipts, automatic retries or persistent transition plans. See docs/api.rst. Fixed store capacity: 128 models / 256 MiB serialized envelopes. Stateless transition preview/apply. Artifact URI/revision strings remain opaque core fields. Model admission before structural validation: 5,000 graph objects, 20,000 total records and 50,000 ordered references. Preparation uses snapshots outside the catalog lock; final commits recheck the exact ETag."
"description": "Current schema-3 snapshots and conditional atomic writes. Request body limit: 8 MiB; at most 200 nested commands; no retained revisions, request receipts, automatic retries or persistent transition plans. See docs/api.rst. Fixed store capacity: 128 models / 256 MiB serialized envelopes. Stateless transition preview/apply. Artifact URI/revision strings remain opaque core fields. Model admission before structural validation: 5,000 graph objects, 20,000 total records and 50,000 ordered references. Preparation uses snapshots outside the catalog lock; final commits recheck the exact ETag. Each accepted envelope couples model and authored source under one revision. Source-only edits advance the shared ETag. Source bundles resolve logical paths only, preserve authored files/comments, and have a stabilized JSON cap of 8 MiB, 256 files, 32 include edges along a path and 1,024 include edges overall."
},
"servers": [
{
@@ -700,7 +700,8 @@
}
}
}
}
},
"description": "Replace semantics while preserving the accepted authored source layout and comments, using common UUIDs as identity correspondence. Model and source commit together. Use source-project PUT to replace authored files."
}
},
"/api/v1/projects/{id}/source": {
@@ -759,7 +760,8 @@
"default": {
"$ref": "#/components/responses/Problem"
}
}
},
"description": "Canonical single-file interchange view of the current model. Use source-project GET to export exact authored files and comments."
},
"put": {
"operationId": "replaceSource",
@@ -829,7 +831,8 @@
}
}
}
}
},
"description": "Replace semantics from canonical single-file interchange while preserving the existing authored source bundle using common UUIDs. Submitted formatting/comments do not replace the authored workspace. Use source-project PUT for that purpose."
}
},
"/api/v1/projects/{id}/elements": {
@@ -1403,7 +1406,8 @@
}
}
}
}
},
"description": "Validate native model JSON, canonical Arcadia source, or a complete source-project bundle without adoption. Source-project diagnostics refer to submitted file paths and positions. Stabilized source/model byte limits are also checked."
}
},
"/api/v1/projects/{id}/transactions": {
@@ -1475,7 +1479,8 @@
}
}
}
}
},
"description": "Command identity correspondence drives source writeback, including UUID swaps and delete/recreate operations even when final models compare equal. Dry runs return both proposed representations and validate writeback. Failure preserves the accepted pair."
}
},
"/api/v1/projects/{id}/transitions/preview": {
@@ -1540,7 +1545,8 @@
}
}
}
}
},
"description": "Conflict-free previews also validate authored-source writeback and stabilized model/source capacities."
}
},
"/api/v1/projects/{id}/transitions/apply": {
@@ -1607,6 +1613,190 @@
}
}
}
},
"/api/v1/projects/import-source-project": {
"post": {
"operationId": "importSourceProject",
"summary": "Import an authored source bundle as a new hosted resource",
"parameters": [],
"responses": {
"201": {
"description": "Success",
"headers": {
"ETag": {
"schema": {
"type": "string"
},
"description": "Project revision validator when scoped to a project."
},
"X-Request-ID": {
"schema": {
"type": "string",
"format": "uuid"
}
},
"Location": {
"schema": {
"type": "string"
},
"description": "Path to the created hosted resource."
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProjectSummary"
}
}
}
},
"default": {
"$ref": "#/components/responses/Problem"
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SourceBundle"
}
}
}
},
"description": "Compile all logical source files, stabilize missing IDs, and persist the accepted model and bundle together. Importing source3 bundles never reads the service filesystem. Export the accepted bundle after import to retain inserted IDs."
}
},
"/api/v1/projects/{id}/source-project": {
"get": {
"operationId": "getSourceProject",
"summary": "Export the exact accepted authored source bundle",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "Hosted resource UUID; distinct from editable model ID.",
"schema": {
"type": "string",
"format": "uuid"
}
},
{
"name": "If-None-Match",
"in": "header",
"required": false,
"description": "Return 304 when the current representation's revision matches.",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success",
"headers": {
"ETag": {
"schema": {
"type": "string"
},
"description": "Project revision validator when scoped to a project."
},
"X-Request-ID": {
"schema": {
"type": "string",
"format": "uuid"
}
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SourceBundle"
}
}
}
},
"304": {
"description": "Unchanged; empty body."
},
"default": {
"$ref": "#/components/responses/Problem"
}
},
"description": "Returns entry and all files with stable IDs, original comments, line endings and module layout. Shares the model ETag; comment-only changes advance that revision."
},
"put": {
"operationId": "replaceSourceProject",
"summary": "Conditionally replace the complete authored source bundle",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "Hosted resource UUID; distinct from editable model ID.",
"schema": {
"type": "string",
"format": "uuid"
}
},
{
"name": "If-Match",
"in": "header",
"required": true,
"description": "One exact strong project ETag. Missing: 428; stale: 412. Weak/list/wildcard unsupported.",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success",
"headers": {
"ETag": {
"schema": {
"type": "string"
},
"description": "Project revision validator when scoped to a project."
},
"X-Request-ID": {
"schema": {
"type": "string",
"format": "uuid"
}
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SourceProjectMutationResult"
}
}
}
},
"412": {
"$ref": "#/components/responses/Problem"
},
"428": {
"$ref": "#/components/responses/Problem"
},
"default": {
"$ref": "#/components/responses/Problem"
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SourceBundle"
}
}
}
},
"description": "Compile and validate the full submitted bundle; return accepted text with missing UUIDs inserted. Model and source persist together under the exact If-Match precondition. Comment-only or file-layout edits advance revision; an exact pair no-op retains it. Rejected candidates preserve both representations."
}
}
},
"components": {
@@ -1750,7 +1940,10 @@
"description": "Relative diagram-space displacement."
}
},
"required": ["fraction", "offset"],
"required": [
"fraction",
"offset"
],
"additionalProperties": false
},
"EdgeRoute": {
@@ -1764,20 +1957,28 @@
},
"waypoints": {
"type": "array",
"items": { "$ref": "#/components/schemas/Position" },
"items": {
"$ref": "#/components/schemas/Position"
},
"maxItems": 128,
"default": [],
"description": "Absolute diagram positions in traversal order."
},
"label": {
"oneOf": [
{ "$ref": "#/components/schemas/EdgeLabel" },
{ "type": "null" }
{
"$ref": "#/components/schemas/EdgeLabel"
},
{
"type": "null"
}
],
"default": null
}
},
"required": ["edge"],
"required": [
"edge"
],
"additionalProperties": false
},
"Size": {
@@ -3493,7 +3694,9 @@
},
"routes": {
"type": "array",
"items": { "$ref": "#/components/schemas/EdgeRoute" },
"items": {
"$ref": "#/components/schemas/EdgeRoute"
},
"default": [],
"description": "Unique by edge identity within this diagram. Stored order is retained. Each entry counts against the total model record admission limit. Missing routes in earlier schema-3 models default to empty."
}
@@ -3782,14 +3985,19 @@
}
],
"description": "Present after legacy import/reopen; excluded from persisted envelopes. Resource identity and revision are unchanged by reopening."
},
"source_project": {
"$ref": "#/components/schemas/SourceBundle"
}
},
"required": [
"resource_id",
"revision",
"model"
"model",
"source_project"
],
"additionalProperties": false
"additionalProperties": false,
"description": "Accepted coupled model/source snapshot. Compiling source_project reproduces model; revision changes once if either representation changes. Old model-only envelopes acquire source in memory without a write or revision change."
},
"ProjectSummary": {
"type": "object",
@@ -3866,13 +4074,27 @@
},
"column": {
"type": "integer",
"minimum": 1
"minimum": 1,
"description": "One-based Unicode scalar column."
},
"code": {
"type": "string"
},
"field": {
"type": "string"
},
"path": {
"type": "string",
"description": "Logical source file path, when a source project supplied the diagnostic."
},
"span": {
"$ref": "#/components/schemas/SourceSpan"
},
"related": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SourceLocation"
}
}
},
"required": [
@@ -4464,14 +4686,24 @@
"type": "object",
"description": "Upsert by edge identity; preserves unrelated route entries and order. Uses existing transaction validation and ETag checks.",
"properties": {
"diagram": { "type": "string", "format": "uuid" },
"route": { "$ref": "#/components/schemas/EdgeRoute" }
"diagram": {
"type": "string",
"format": "uuid"
},
"route": {
"$ref": "#/components/schemas/EdgeRoute"
}
},
"required": ["diagram", "route"],
"required": [
"diagram",
"route"
],
"additionalProperties": false
}
},
"required": ["SetEdgeRoute"],
"required": [
"SetEdgeRoute"
],
"additionalProperties": false
},
{
@@ -4481,14 +4713,25 @@
"type": "object",
"description": "Reset to automatic routing. No-op if this existing diagram has no route for the existing edge.",
"properties": {
"diagram": { "type": "string", "format": "uuid" },
"edge": { "type": "string", "format": "uuid" }
"diagram": {
"type": "string",
"format": "uuid"
},
"edge": {
"type": "string",
"format": "uuid"
}
},
"required": ["diagram", "edge"],
"required": [
"diagram",
"edge"
],
"additionalProperties": false
}
},
"required": ["RemoveEdgeRoute"],
"required": [
"RemoveEdgeRoute"
],
"additionalProperties": false
},
{
@@ -4710,7 +4953,12 @@
}
},
"model": {
"$ref": "#/components/schemas/Project"
"$ref": "#/components/schemas/Project",
"description": "Proposed model, present only for a dry run."
},
"source_project": {
"$ref": "#/components/schemas/SourceBundle",
"description": "Proposed accepted bundle, present only for a dry run. Source writeback is validated before returning a successful candidate."
}
},
"required": [
@@ -4788,6 +5036,22 @@
"source"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"format": {
"const": "source-project"
},
"source_project": {
"$ref": "#/components/schemas/SourceBundle"
}
},
"required": [
"format",
"source_project"
],
"additionalProperties": false
}
]
},
@@ -5818,6 +6082,91 @@
],
"additionalProperties": false,
"description": "Transient observations from explicit V1→V2→V3 or V2→V3 migration. No save or revision increase occurs merely by reading an old envelope."
},
"SourceBundle": {
"type": "object",
"description": "Complete source3 workspace. The entry is a root filename. Files use canonical portable slash-separated relative .arcadia paths and all must be reachable. Includes resolve only against this map, never the service filesystem. Up to 256 files, 32 include edges along a path and 1,024 include edges overall. Serialized bundle JSON is at most 8 MiB, checked again after missing UUIDs are inserted. GET returns exact accepted stable text.",
"properties": {
"entry": {
"type": "string"
},
"files": {
"type": "object",
"maxProperties": 256,
"additionalProperties": {
"type": "string"
}
}
},
"required": [
"entry",
"files"
],
"additionalProperties": false
},
"SourceSpan": {
"type": "object",
"description": "Half-open UTF-8 byte offsets [start,end) in the named source file.",
"properties": {
"start": {
"type": "integer",
"minimum": 0
},
"end": {
"type": "integer",
"minimum": 0
}
},
"required": [
"start",
"end"
],
"additionalProperties": false
},
"SourceLocation": {
"type": "object",
"properties": {
"path": {
"type": "string"
},
"span": {
"$ref": "#/components/schemas/SourceSpan"
}
},
"required": [
"path",
"span"
],
"additionalProperties": false
},
"SourceProjectMutationResult": {
"type": "object",
"properties": {
"revision": {
"type": "integer",
"minimum": 1,
"description": "Current revision counter; use ETag as the opaque write validator."
},
"changed": {
"type": "boolean"
},
"issues": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Issue"
}
},
"source_project": {
"$ref": "#/components/schemas/SourceBundle"
}
},
"required": [
"revision",
"changed",
"issues",
"source_project"
],
"additionalProperties": false
}
}
}
+15 -7
View File
@@ -1,6 +1,6 @@
# Arcadia Studio server
`arcadia-server` is a headless Rust REST service for the same schema-2 model, commands, source language, validation, artifacts and OA/SA traces used by the desktop. It stores each current project in an atomic JSON envelope. The service provides a deployment artifact and configuration examples; this repository does not assert that a remote instance has been deployed.
`arcadia-server` is a headless Rust REST service for the same schema-3 model, commands, source language, validation, artifacts and OA/SA traces used by the desktop. It stores each model and its authored source project together in an atomic JSON envelope. The service provides a deployment artifact and configuration examples; this repository does not assert that a remote instance has been deployed.
The complete HTTP contract is [api.rst](api.rst); machine-readable schemas are in [openapi.json](openapi.json), also served at `/api/v1/openapi.json`. The original [service research](research-v2-service.md) discusses broader alternatives; retained history, SQLite, idempotency receipts and stored transition plans from that research are **not** the implemented storage contract.
@@ -34,17 +34,19 @@ curl --fail-with-body -i -X POST http://127.0.0.1:8787/api/v1/projects \
-H 'Content-Type: application/json' -d '{"name":"Harbor assistance"}'
```
The create response returns a service `resource_id`, revision and `ETag`. Fetch `/api/v1/projects/{resource_id}` to obtain the full `{resource_id, revision, model}` envelope. Use the exact ETag as `If-Match` on updates. With authentication enabled, add `Authorization: Bearer <your-token>`; avoid putting the token in URLs, shell history or shared example files.
The create response returns a service `resource_id`, revision and `ETag`. Fetch `/api/v1/projects/{resource_id}` to obtain the full `{resource_id, revision, model, source_project}` envelope. Use the exact ETag as `If-Match` on updates. With authentication enabled, add `Authorization: Bearer <your-token>`; avoid putting the token in URLs, shell history or shared example files.
The service resource ID is distinct from the editable model ID. Model IDs, aliases, diagrams, artifact URIs/revisions and trace records round-trip through source/JSON. Imported copies receive new resource IDs. An artifact's `revision` is an opaque external-artifact revision string; it is not a retained server model revision.
## Storage and concurrency
The data directory contains `.arcadia.lock` and one `<resource-id>.json` file per hosted project. Each envelope has exactly `resource_id`, positive `revision` and `model`. The process keeps an exclusive OS file lock on the directory lock file for the entire store lifetime. A second process using the same directory fails at startup. Do not directly edit, synchronize or replace files while the service is running.
The data directory contains `.arcadia.lock` and one `<resource-id>.json` file per hosted project. Each envelope has exactly `resource_id`, positive `revision`, `model` and `source_project`. The source bundle contains its entry filename and a map of file text; compiling it must reproduce the stored model exactly. The process keeps an exclusive OS file lock on the directory lock file for the entire store lifetime. A second process using the same directory fails at startup. Do not directly edit, synchronize or replace files while the service is running.
Startup validates every committed envelope, its filename/UUID, revision, size and complete model. Any corrupt or unsupported envelope stops startup; the service does not silently skip bad data or invent an empty replacement. Sibling `.arcadia-write-*` files from an interrupted pre-commit write are not committed models and are ignored on startup.
Parsing, validation and blocking disk operations run on blocking workers. A shared store mutex captures snapshots and serializes final revision checks and writes. Parsing, semantic validation and query processing use the captured snapshot outside that lock; an expensive candidate does not block the catalog or another model's preparation. Commit rechecks the exact ETag, so a candidate prepared against a superseded snapshot returns `412 revision_conflict`. A successful changed update increments revision once; an unchanged model returns `changed: false` and retains its revision. Each write serializes the whole envelope to a sibling temporary file, syncs it, and atomically replaces the destination. Unix also syncs the directory. The in-memory snapshot changes only after the replacement succeeds. A directory-sync error after replacement is reported as an uncertain write outcome while the in-memory head still matches the installed file.
Earlier model-only envelopes are accepted through the existing model migrations and receive deterministic source files in memory. Opening, reading or an exact no-op neither rewrites those envelopes nor advances their revision. The next changed edit persists the coupled representation. Already-coupled envelopes must contain stable source IDs and matching model/source; inconsistent pairs are rejected at startup. Capacity accounting includes source generated in memory for old envelopes, so an over-capacity old store fails explicitly without rewriting or pruning it.
Parsing, validation and blocking disk operations run on blocking workers. A shared store mutex captures snapshots and serializes final revision checks and writes. Parsing, semantic validation, source writeback and query processing use the captured snapshot outside that lock; an expensive candidate does not block the catalog or another model's preparation. Commit rechecks the exact ETag, so a candidate prepared against a superseded snapshot returns `412 revision_conflict`. A successful changed update increments revision once. Both model and source bundle must be unchanged for `changed: false`; comment-only edits advance the same revision. Each write serializes the whole envelope to a sibling temporary file, syncs it, and atomically replaces the destination. Unix also syncs the directory. The in-memory snapshot changes only after the replacement succeeds. A directory-sync error after replacement is reported as an uncertain write outcome while the in-memory head still matches the installed file.
Atomic replacement protects against partially written envelopes. It does not promise survival of every device/filesystem power-loss failure, protect against out-of-band edits, or coordinate OneDrive/network filesystems. The storage directory must be local to this one process. DELETE permanently removes the envelope; there is no recycle bin, tombstone or server undo history.
@@ -54,14 +56,15 @@ Back up by stopping the server cleanly and copying committed JSON envelopes to a
## Limits and errors
- Raw request bodies: 8 MiB, including requests without `Content-Length`. Encoded/compressed request bodies are rejected. The same limit applies to serialized model JSON; the storage envelope permits small framing overhead.
- Raw request bodies: 8 MiB, including requests without `Content-Length`. Encoded/compressed request bodies are rejected. Serialized model JSON and serialized source-bundle JSON each have an 8 MiB cap. The latter is checked again after omitted IDs are inserted. The storage envelope allows both representations plus small framing overhead.
- Source bundles contain at most 256 files, with at most 32 include edges along a path and 1,024 include edges overall. Includes resolve only against submitted logical `.arcadia` paths; no server filesystem or network access is involved. All files must be reachable, and cycles, duplicate keys, case collisions and escaping paths are rejected.
- At most eight admitted model requests and eight active/waiting blocking workers. Overload returns `503` with `Retry-After: 1`. A 30-second request deadline bounds body receipt and response waiting. A started blocking write can finish after that deadline; `504 outcome_unknown` means reload before retrying.
- At most 5,000 elements plus relations, 20,000 total persistent model records, and 50,000 ordered step references per model. Graph requests allow 1–100 roots, depth 0–4, and at most 1,000 returned objects. List pages contain 1–1,000 records, default 100.
- Source declarations and decoded JSON records are counted before structural validation. V1 import includes the occurrences and placements migration will generate in its early budget check. These limits apply to import, source/model replacement, candidate validation and full-model transaction commands. Over-budget candidates cannot monopolize the catalog while being rejected.
- Transactions accept at most 200 commands, including nested batch wrappers. All commands apply to a candidate, then shared validation accepts or rejects the batch atomically. Dry run returns the candidate and diagnostics without writing.
- Transactions accept at most 200 commands, including nested batch wrappers. All commands apply to a candidate, then shared validation and authored-source writeback accept or reject the batch atomically. Dry run returns the proposed model, source project and diagnostics without writing.
- The fixed store cap is 128 hosted models and 256 MiB of serialized envelopes. New/imported/replaced models that exceed it return `507 store_capacity`. Startup enforces the same limits. Delete/export unneeded projects before adding more data; there are no hidden eviction or pruning rules. The server stores all current models in memory; memory includes Rust object overhead and in-flight candidates in addition to serialized bytes.
Errors use `application/problem+json` with stable `code`, HTTP `status`, human `detail`, and contextual `issues` when available. Responses carry `X-Request-ID`; configured bearer credentials and source/model bodies are not logged. The core currently supplies human command errors; the API maps them to `422 command_rejected` without parsing English messages into invented machine codes. Source errors preserve line/column diagnostics.
Errors use `application/problem+json` with stable `code`, HTTP `status`, human `detail`, and contextual `issues` when available. Responses carry `X-Request-ID`; configured bearer credentials and source/model bodies are not logged. The core currently supplies human command errors; the API maps them to `422 command_rejected` without parsing English messages into invented machine codes. Source-project errors preserve logical file paths, UTF-8 byte spans, line/column and related-file locations.
## Linux deployment
@@ -86,6 +89,10 @@ Ctrl+C/SIGTERM stops admission and drains HTTP requests. Already-started blockin
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.
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.
Typed commands, renames and transitions update the same authored files using command identity correspondence, including UUID swaps and delete/recreate operations. Model JSON and canonical single-file source replacements preserve the existing authored layout using common UUIDs. Canonical `/source` remains an interchange view; use `/source-project` to retain or edit file layout and comments. Source writeback failure rejects the whole candidate instead of discarding the authored files. Comment-only edits share the same ETag as semantic edits, preventing one client from overwriting another client's source work.
External tools can use the artifact-link and trace query routes, and create/edit those records in the same atomic transaction as model changes. URI and external revision fields preserve the author's values; the service supplies no fetching, credentials, artifact-content verification or AI execution runtime. OpenAPI is the integration contract. An optional future adapter can call this API without introducing a second mutation path.
`POST /projects/{id}/transitions/preview` produces a shared core transition plan and `base_revision` without storing a plan or changing the model. Send that response to `/transitions/apply` with the original ETag. Apply checks the server revision and the core's model-change fingerprint, preserves planned IDs, validates the candidate, and commits once. Plans with unresolved conflicts cannot apply. A submitted plan is a client-owned command proposal, not a signed server authorization; a client able to apply a plan already has the same write permission as the transaction endpoint.
@@ -102,6 +109,7 @@ The HTTP integration suite starts real loopback listeners over isolated temporar
Verified on 2026-09-05:
- Windows authored-source service extension: nine new real HTTP tests and one OpenAPI contract test pass, covering exact multi-file/comment round trips, comment-only ETags, ID insertion, source/model parity, command provenance through UUID swaps and equal-model delete/recreate, dry runs, transitions, invalid dependencies, concurrent writes, restart migration and forced source-write failure. The source-byte accounting unit passes; the focused `source_` regression selection passes all 14 HTTP/schema tests. Strict server all-targets Clippy passes. These are local Windows results; Linux and remote deployment remain separate checks.
- Windows R1/R2/R4 source/API integration: all 16 current HTTP/configuration tests plus the deterministic commit test pass; strict server all-targets Clippy passes. New cases cover mission maps, branching/reused chains, typed call value edits, original V2 compatibility, source/JSON equality and persistence after restart. See `harness/release-language-integration.md`; native UI and remaining release semantics are separate gates.
- Windows R2 release checkpoint: all 14 server integration/configuration tests plus one deterministic commit test pass; formatting and strict all-targets Clippy pass. A separate optimized executable probe rejects the previously stalling 12,000-activity request in 0.050 seconds; concurrent JSON import and accepted-source preparation leave catalog reads responsive. See `harness/release-text-service-audit.md` for exact measurements and scope.
- Ubuntu 24.04 under WSL, prior V2 baseline: all 12 then-existing tests passed using native Linux processes and temporary filesystem stores; strict all-targets Clippy and the optimized build passed with the locked, vendored dependency set. A separate release-executable smoke run verified bearer authentication, non-loopback token enforcement, exclusive directory locking, conditional create/update/delete, update and deletion persistence across restarts, served OpenAPI, and clean SIGTERM shutdown. The new first-release changes still require native Linux re-verification.
+41
View File
@@ -0,0 +1,41 @@
// Operational Analysis describes the need without choosing a technical solution.
oa_actor vessel "Vessel in distress" id "00000000-0000-4000-8000-000000000101" {
description "The vessel's crew needs to communicate its position and obtain assistance.";
}
oa_entity rescue_team "Rescue coordination" id "00000000-0000-4000-8000-000000000102" {
description "The coordination team assesses the report and decides on a response.";
}
// This is work decomposition. It does not assign either activity to a participant.
oa_activity respond "Respond to distress" id "00000000-0000-4000-8000-000000000103" {
// Keep the crew's reporting responsibility visible when decomposing the need.
oa_activity report_distress "Report distress" id "00000000-0000-4000-8000-000000000104" {
description "Provide the vessel's situation and last known position.";
}
oa_activity assess_incident "Assess incident" id "00000000-0000-4000-8000-000000000105" {
description "Decide which assistance is needed using the received report.";
}
}
oa_capability protect_life "Protect life at sea" id "00000000-0000-4000-8000-000000000106" {}
oa_exchange_item oa_report_data "Distress report" id "00000000-0000-4000-8000-000000000107" {
kind Information;
oa_data_field reported_latitude "Reported latitude" id "00000000-0000-4000-8000-000000000108" {
type_ref Real;
unit "degrees north";
}
}
// Allocation remains explicit even though the saved architecture view nests work.
allocation crew_reports "Crew reports distress" id "00000000-0000-4000-8000-000000000201" {
source @vessel; target @report_distress;
}
allocation team_assesses "Team assesses incident" id "00000000-0000-4000-8000-000000000202" {
source @rescue_team; target @assess_incident;
}
interaction distress_signal "Distress signal" id "00000000-0000-4000-8000-000000000203" {
source @report_distress; target @assess_incident; items [@oa_report_data];
}
capability_involvement operational_contribution "Assessment supports rescue" id "00000000-0000-4000-8000-000000000204" {
source @assess_incident; target @protect_life;
}
+11
View File
@@ -0,0 +1,11 @@
arcadia 3;
project distress_response "Distress response — nested source project" id "00000000-0000-4000-8000-000000000001" {
description "A small OA/SA model for learning source ownership, typed ports and reviewed graphical edits. Open this file. Start with the saved allocation views, then browse Handle distress reports and use Source for selection.";
// Includes organize documents; aliases are shared by the whole project.
include "oa.arcadia";
include "sa/participants.arcadia";
include "sa/reporting.arcadia";
include "sa/assurance.arcadia";
include "views.arcadia";
}
@@ -0,0 +1,11 @@
// A separate functional aggregate provides a cross-file reparenting example.
// Move Assess report here through Properties, then inspect both source files.
sa_function assure_response "Assure response quality" id "00000000-0000-4000-8000-000000000411" {
description "Review the service response independently of report transport.";
sa_function review_response "Review response" id "00000000-0000-4000-8000-000000000412" {
description "Check that a coordinator can review the received information.";
}
}
allocation system_reviews "System supports response review" id "00000000-0000-4000-8000-000000000504" {
source @rescue_system; target @review_response;
}
@@ -0,0 +1,22 @@
// System Analysis identifies one system of interest and its external participant.
sa_system rescue_system "Rescue coordination system" id "00000000-0000-4000-8000-000000000301" {
description "The selected system receives reports and supports the coordinator.";
// A component port belongs to its participant, not to an allocated function.
sa_port reporting_input "Reporting input" id "00000000-0000-4000-8000-000000000303" {
contract { Component { kind Flow; orientation In; }; };
}
}
sa_actor vessel_service "Vessel communication service" id "00000000-0000-4000-8000-000000000302" {
description "An external service sends distress information across the system boundary.";
sa_port reporting_output "Reporting output" id "00000000-0000-4000-8000-000000000304" {
contract { Component { kind Flow; orientation Out; }; };
}
}
// The boundary exchange connects actual participant ports. Function realization
// is declared separately in reporting.arcadia using port/exchange allocations.
boundary_exchange radio_reporting "Radio reporting" id "00000000-0000-4000-8000-000000000501" {
source @reporting_output; target @reporting_input;
oriented true;
items [@sa_report_data];
}
@@ -0,0 +1,52 @@
// Functional containment is independent of the participant allocations below.
sa_function handle_reports "Handle distress reports" id "00000000-0000-4000-8000-000000000401" {
description "A functional decomposition of reporting and assessment.";
sa_function transmit_report "Transmit report" id "00000000-0000-4000-8000-000000000402" {
description "Encode and send the vessel's distress report.";
sa_port report_out "Report output" id "00000000-0000-4000-8000-000000000404" {
contract { FunctionOutput { outgoing_items [@sa_report_data]; }; };
}
}
// Assessment rationale travels with this declaration when it is reparented.
sa_function assess_report "Assess report" id "00000000-0000-4000-8000-000000000403" {
description "Validate the received report and present it to the coordinator.";
sa_port report_in "Report input" id "00000000-0000-4000-8000-000000000405" {
contract { FunctionInput { incoming_items [@sa_report_data]; }; };
}
} // Assessment remains performed by rescue_system after a hierarchy edit.
}
// This group comment stays in this file; it is not attached to the function above.
sa_exchange_item sa_report_data "Digital distress report" id "00000000-0000-4000-8000-000000000406" {
kind Information;
sa_data_field encoded_latitude "Encoded latitude" id "00000000-0000-4000-8000-000000000407" {
type_ref Real;
unit "degrees north";
}
}
sa_capability coordinate_response "Coordinate distress response" id "00000000-0000-4000-8000-000000000408" {}
allocation vessel_transmits "Vessel service transmits" id "00000000-0000-4000-8000-000000000502" {
source @vessel_service; target @transmit_report;
}
allocation system_assesses "System assesses report" id "00000000-0000-4000-8000-000000000503" {
source @rescue_system; target @assess_report;
}
functional_exchange validated_report "Validated report" id "00000000-0000-4000-8000-000000000505" {
source @transmit_report; source_port @report_out;
target @assess_report; target_port @report_in;
items [@sa_report_data];
}
port_allocation output_realization "Reporting output realizes function output" id "00000000-0000-4000-8000-000000000506" {
source @reporting_output; target @report_out;
}
port_allocation input_realization "Reporting input realizes function input" id "00000000-0000-4000-8000-000000000507" {
source @reporting_input; target @report_in;
}
exchange_allocation report_transport "Radio reporting carries the function exchange" id "00000000-0000-4000-8000-000000000508" {
source @radio_reporting; target @validated_report;
}
capability_involvement system_contribution "Assessment supports coordination" id "00000000-0000-4000-8000-000000000509" {
source @assess_report; target @coordinate_response;
}
+71
View File
@@ -0,0 +1,71 @@
// Views are projections. Their visual allocation containers do not set parent.
diagram oa_architecture "OA — participants and activities" id "00000000-0000-4000-8000-000000000601" {
description "The crew reports; the coordination team assesses. Containment of work is shown separately in the Model explorer.";
layer Oa; projection Allocation;
}
node oa_vessel_view id "00000000-0000-4000-8000-000000000701" {
diagram @oa_architecture; element @vessel;
position { x 60; y 60; }; size { width 272; height 210; };
}
node oa_team_view id "00000000-0000-4000-8000-000000000702" {
diagram @oa_architecture; element @rescue_team;
position { x 540; y 60; }; size { width 300; height 210; };
}
node oa_report_view id "00000000-0000-4000-8000-000000000703" {
diagram @oa_architecture; element @report_distress;
position { x 84; y 128; }; size { width 224; height 104; };
}
node oa_assessment_view id "00000000-0000-4000-8000-000000000704" {
diagram @oa_architecture; element @assess_incident;
position { x 564; y 128; }; size { width 252; height 104; };
}
diagram sa_architecture "SA — system boundary and functions" id "00000000-0000-4000-8000-000000000602" {
description "Flow component ports define the boundary; separate function ports carry the functional exchange.";
layer Sa; projection Allocation;
}
node sa_vessel_view id "00000000-0000-4000-8000-000000000711" {
diagram @sa_architecture; element @vessel_service;
position { x 60; y 60; }; size { width 272; height 220; };
}
node sa_system_view id "00000000-0000-4000-8000-000000000712" {
diagram @sa_architecture; element @rescue_system;
position { x 540; y 60; }; size { width 300; height 340; };
}
node sa_transmission_view id "00000000-0000-4000-8000-000000000713" {
diagram @sa_architecture; element @transmit_report;
position { x 84; y 128; }; size { width 224; height 104; };
}
node sa_assessment_view id "00000000-0000-4000-8000-000000000714" {
diagram @sa_architecture; element @assess_report;
position { x 564; y 128; }; size { width 252; height 104; };
}
node sa_review_view id "00000000-0000-4000-8000-000000000715" {
diagram @sa_architecture; element @review_response;
position { x 564; y 272; }; size { width 252; height 104; };
}
diagram sa_decomposition "SA — functional containment" id "00000000-0000-4000-8000-000000000603" {
description "These containers represent real function parents. Compare them with the explicit allocations in the architecture view.";
layer Sa; projection FunctionalDataflow;
}
node handling_view id "00000000-0000-4000-8000-000000000721" {
diagram @sa_decomposition; element @handle_reports;
position { x 40; y 60; }; size { width 500; height 230; };
}
node transmission_view id "00000000-0000-4000-8000-000000000722" {
diagram @sa_decomposition; element @transmit_report;
position { x 64; y 136; }; size { width 204; height 104; };
}
node assessment_view id "00000000-0000-4000-8000-000000000723" {
diagram @sa_decomposition; element @assess_report;
position { x 310; y 136; }; size { width 204; height 104; };
}
node assurance_view id "00000000-0000-4000-8000-000000000724" {
diagram @sa_decomposition; element @assure_response;
position { x 590; y 60; }; size { width 300; height 230; };
}
node review_view id "00000000-0000-4000-8000-000000000725" {
diagram @sa_decomposition; element @review_response;
position { x 614; y 136; }; size { width 252; height 104; };
}
+2
View File
@@ -33,3 +33,5 @@
- Model navigation uses one cached semantic containment index for both outline and focused browser. Group only roots, preserve full depth, and show allocated work separately. Direct children and paged contextual search replace the flat catalog. The outline uses constant widgets per row; no new virtual-tree framework is introduced.
- Port creation is explicit at the participant: Add port chooses Standard/Flow, then the selected port exposes Connect boundary exchange. Function Input/Output actions remain distinct. Candidate feasibility shares exact core validation with an explicit orientation parameter, avoiding per-choice project cloning and preserving Legacy behavior.
- Recovery state is independent of source diagnostics. Interrupted Save All journals remain discoverable after lint, typing, missing entry files or first-save failure. Recovery validates current bytes against known transaction states; it does not promise atomic multi-file visibility or cross-process filesystem locking. Source/property draft guards prevent one representation from silently replacing pending edits in the other.
- Production source3 nesting expresses semantic element ownership only. Extend the existing parser and source index with real lexical owners/body spans, rather than adopting the experiment's parallel parser. Flat canonical interchange remains valid. Structural writeback composes declaration pieces after ordinary token edits and uses original occurrence provenance through temporary UUID collisions. This preserves independent parent/child moves, authored files, comments and exact history while allowing new children to be emitted inside their owners. Allocation and ordered occurrence membership remain explicit.
- A hosted revision owns the accepted model and stabilized source bundle together. Store one atomic envelope, use the same core writer for commands and previews, and advance the ETag for comment-only edits. Old model-only envelopes acquire deterministic source in memory without a read-time write or revision change. A detached source sidecar would create inconsistent revisions and unsafe restart behavior. Accepted model equality alone is insufficient for a no-op when command provenance deletes/recreates a declaration; this was reproduced and corrected in desktop and REST adapters.
+2
View File
@@ -14,6 +14,8 @@ The current forms are a closed typed-record adapter, not a second metamodel. Exp
## Milestones and measurable acceptance
Nested source ownership and persistent REST bundles are implemented and under final native acceptance. Per-file repair/recovery passed at 1100×700; production parsing, exact structural writeback and coupled service revisions passed independent review and Windows/Linux suites. Root's Windows nested reparent/source/Undo/Redo/Save/reopen passed; minimum-size source-to-graphics review and rebuilt Linux trial remain. Preserve the existing grammar/updater and separate allocation/order semantics. Next, use the updated direct-state core/desktop maps to freeze one small persisted-canvas contract before implementation.
The user-directed actor/system port and model-navigation milestone is accepted: visible Add port → Connect boundary exchange, grouped roots, focused containment, breadcrumbs, contextual search and paging, clear full-depth outline, and separate allocations. Native Windows authoring/save/reopen, 251-root paging, 12-level navigation, 1440×900/1100×700 inspection, rebuilt Linux save/reopen and single-line font correction passed. Independent requirements, correctness, native UI and simplicity findings are addressed; release-explorer-usability.md records exact evidence and remaining performance boundaries. Resume source recovery acceptance and the release milestones below; the full scope remains intact.
Editable connection routing has passed code/native acceptance and independent follow-up at desktop checkpoint `23535b4`, with the Linux-discovered viewport correction in `366ce44` also verified on the actual rebuilt Linux app. Current WSL automated checks and routing runtime acceptance are recorded in release-routing-linux.md. Continue production source authoring under release-source-integration.md and direct behavior editing. Typed port integration and its native authoring/reopen checks are complete at `fa096ce`; `release-diagram-port-correction.md` records the domain correction. Existing R5 state authoring is preserved and still needs direct gestures and full native acceptance.
+4
View File
@@ -8,6 +8,10 @@ The editable connection-routing milestone is accepted at `23535b4` with viewport
## Completed foundations
Nested source/REST integration checkpoint: production parser and iterative structural writer are implemented; REST persists the accepted bundle and model in one ETag envelope. Final workspace runs passed 422 Windows and 425 Linux tests; strict workspace Clippy, formatting and Windows/Linux builds passed. Actual server import, comment-only revision, cross-file reparent, stale ETag and exact restart/conditional GET passed. Owner-opening-line comment reassignment and equal-model delete/recreate provenance defects were reproduced, fixed and reverified. Independent parser/writer/model-source boundary reviews found no remaining material findings; fresh spawn was unavailable because of the thread limit, so workers reviewed one another's artifacts. Strict language/API reST rendering passed after correcting an API heading underline. Root's Windows nested reparent/arrange/source/Undo/Redo/Save/reopen passed with 22 objects,13 links,0 checks. Independent minimum-size/source-created port QA is active; actual rebuilt Linux nesting trial remains. Evidence: release-source-integration.md.
Current continuation: previous goal turn made concrete progress at `d364d3c` (native-verified explorer/port usability). Root reread the complete authoritative objective. Native source recovery acceptance passed: comment-preserving reopen, graphical rename/writeback/Undo, inactive-file syntax repair, mixed interrupted-save rollback, first-save recovery with retained draft/retry, and included-file repair/Save/reopen at 1100×700. The prominent recovery banner closed normally with an empty runtime log. All implementation ownership is released to root. `explorer_usability_review` alone owns native input for its bounded QA; `source_document_review` and `source_integration_map` are mapping direct-state core/desktop contracts read-only. No blocker; release scope is unchanged.
- The user-directed explorer/port usability milestone is accepted. Add port is visible on participants, and selected component ports expose Connect boundary exchange. The browser uses grouped roots, focused direct children, breadcrumbs, paged contextual search and Locate; the outline keeps real depth, guides and single-line labels with full-name tooltips. Root created two Standard ports and a Boundary Exchange, exercised Undo/Redo, Save/reopen, duplicate-name search, 251-root paging and 12-level navigation. Independent native review and root's final Windows build passed at 1100×700; actual rebuilt Linux save/reopen and font-fix inspection passed. Full Windows workspace tests passed 382, followed by all 138 desktop tests; Linux full workspace passed 386. Strict checks and final Windows/Linux builds passed. Slow picker, stale reparent focus, deep-row cost, collapsed header sizing, scroll reset and font wrapping findings are fixed. Exact scope, hashes and evidence: release-explorer-usability.md.
- Source integration checkpoint `3c87576` passed full366 Windows tests; its draft-history cap follow-up passed122 desktop tests and strict workspace Clippy. Root ran native build SHA256 `FCA6140E835B17AE4FAB003B0DAC38D7D820DD36C7559F536BA3CC607E30FE51`: opened two included buffers, typed separate rationale comments, switched with drafts retained, Apply all, model Undo/Redo, then Save All to `ui-source-project/native-source-trial.arcadia`. The entry is1496 bytes with20 other included files; both authored comments are present on disk and the UI reports all changes saved,41objects/25links/0checks. Runtime log is empty; the window closed normally (exec session99563 exited0). Native source reopen/failure/minimum-size acceptance is still required.
- Fresh source integration review at 3c87576 reproduced property-draft loss and diagnostic-only recovery state. Both are fixed in the current app: guards retain both drafts, and durable pending_recovery survives typing/lint, missing entries and initial Save failure. Four real-journal recovery tests and source/property collision tests pass in the full workspace run. The bounded reviews are complete; all agents released file/native ownership to root. Frozen source probe evidence: D:/CodexBuilds/Capella2-source-app-independent/REVIEW.md. The explorer Linux trial additionally verified failed Save to a missing directory retained the model and successful retry saved all files; interrupted-journal UI recovery still needs native acceptance.
+6 -2
View File
@@ -16,6 +16,10 @@ System engineers can develop a coherent OA→SA system model, including iterativ
## Verified facts
- Native modular source editing/reopen/repair/recovery passed on Windows at 1100×700. Production nested ownership, structural writeback and coupled REST source bundles now pass an initial 416-test workspace run. Actual service import/comment-only revision/cross-file move/stale ETag passed. Comment fidelity needs independent checks: review exposed owner-opening-line comment reassignment; root reproduced it and all 12 updater tests pass after the fix. Equal-model delete/recreate provenance was also reproduced and fixed in desktop and REST. Native nested workflow and final integrated review/checks remain.
- Explorer/port usability accepted at `d364d3c`: actual Windows Actor → Standard port → Boundary Exchange, save/reopen and Undo/Redo; contextual duplicate-name search, 251-root paging, 12-level ancestry and 1100×700 inspection. Linux port save/reopen and font-dependent outline wrapping correction were verified. Exact evidence: release-explorer-usability.md. This resolves the latest discovery/navigation complaint without claiming broader release completeness.
- Native Capella SAB authoring creates function input/output endpoints with an FE. Component ports separately carry Standard/Flow kind, orientation, interface roles and explicit allocated function ports. Details and successfully performed versus merely observed operations are in release-capella-workflow-benchmark.md.
- Studio uses one typed Project and validated atomic commands across Rust core/desktop/server. Hierarchy projection distinguishes actual allocation from inferred parent summaries; IDs and placement are separate.
- Drag selection bug fixed and committed `2ef16dc`; 71 desktop tests and real moved-card selection/property editing passed before typed-port integration.
@@ -23,7 +27,7 @@ System engineers can develop a coherent OA→SA system model, including iterativ
- Independent core review reproduced alias reservation and same-transaction allocation replacement defects; fixes have focused regression evidence. Typed desktop integration `fa096ce` passed full checks and native creation/generation, undo/redo, save/reopen and no-op synchronization trials. Long-reference previews were inspected at 1440×900 and 1100×700; detailed evidence is in STATE.md.
- Per-diagram pins and labels, source/API parity and shared orthogonal routing are implemented in `4e5a88d`, `a7c6f96`, `68b1eff` and `30c7db1`. `65fa9ab` resolves all six reproduced default SA label-overlap pairs. Native OA/SA editing, movement/undo, fold restoration, save/reopen, SVG inspection and short gestures at 1100×700 passed. Routing code/native acceptance and independent native follow-up are complete at `23535b4`; exact scope is recorded in release-routing-verification.md.
- The source experiment passed 23 tests, but an independent probe reproduced positional item-comment misattachment, shorthand description rejection and unsupported deletion. Whole-model equality does not establish source-comment fidelity. The production updater retained comment attachment on the same reorder probe; extend its parser/updater instead of copying the experimental parallel implementation.
- Current behavior diagrams derive geometry and only handle selection; direct behavior editing remains a product gap. The integrated source editor now retains modular accepted source instead of flattening it, with223core/121desktop/22server Windows tests passing; native acceptance and production nesting/shorthand remain incomplete.
- Current behavior diagrams derive geometry and only handle selection; direct behavior editing remains a product gap. The source editor retains modular accepted source; nested element syntax is implemented and under native acceptance. Port/exchange/steps shorthand remains an independent future implementation slice required by the current authoring strategy.
- Independent source review exposed declaration/comment reassignment during UUID changes and swaps, Linux FIFO hangs during disk admission, file-mode changes on save, and overlong detached-file receipt lifetimes. Command provenance, pre-open regular-file admission, permission retention and saved-detach receipt retirement resolve the reproduced cases. Exact probes and test scopes are in STATE.md.
## Assumptions to test
@@ -35,7 +39,7 @@ System engineers can develop a coherent OA→SA system model, including iterativ
## Open questions
- Which selected nesting/shorthand constructs justify production parser/updater extension while preserving comment attachment, exact source Undo, complete editing and clear diagnostics? The independent source probe provides concrete acceptance cases.
- Which port/exchange/steps shorthand constructs add enough clarity to justify extending the accepted nested ownership syntax? Preserve comment attachment, exact source Undo and complete edit/delete support against the independent probe cases.
- What remaining OA/SA gaps materially block target workflows? R3 semantic reconciliation, R6 data contracts, directly editable behavior, lifecycle/recovery and hosted editing remain in the release plan.
## Candidate designs and current decisions
+117
View File
@@ -92,3 +92,120 @@ outgrow shorthand expand that declaration locally instead of rejecting the edit.
injected partial failures and interrupted recovery preserve coherent content.
- Default/minimum-size native editing and save/reopen are observed, not inferred
from compilation. Windows/Linux builds and focused regressions pass.
## Native integration follow-up
On Windows build SHA256 `3210042E61E95748386A57AD67120506F01A9EDB0A74DB40E36F94896C4068EC`
(commit `d364d3c`), root reopened `ui-source-project/native-source-trial.arcadia`
through the native picker. The 21-file project reported 41 objects, 25 links,
zero checks and all changes saved. Both entry rationale and included
`oa/participants.arcadia` rationale were visible in the real editor.
Root renamed Vessel in distress graphically, followed Properties → Source to
the same declaration in its owning file, and inspected the retained comment,
alias and UUID. The expected OA/SA name-drift warning appeared. Model Undo
restored the name/source and removed that warning, returning to all changes saved.
Root inserted an invalid declaration in the included file, switched to the entry,
and observed the inactive file's path/line diagnostic and dirty marker. Apply
rejected it while preserving the accepted 41-object model and the draft.
Architecture displayed a Source drafts pending notice instead of editable
properties. Open source drafts and the diagnostic link returned to the correct
file and selected the invalid line. Deleting it and Apply succeeded; Model Undo
restored the original saved source. Interrupted-journal and minimum-size source
acceptance are still underway; full release completion is not implied.
The independent recovery fixture generator used production core APIs to validate
four isolated originals and recover copies. Exact paths/contracts are in
`D:/CodexBuilds/Capella2-release1-01a06e2e/ui-source-recovery/README.md`.
Root opened `interrupted-mixed/project.arcadia`, inspected the AFTER included
bytes alongside the BEFORE entry, then recovered through the actual UI. The
result was Recovery baseline, two objects, one allocation and zero checks;
BEFORE comment/name bytes returned and the journal retired. The original UI put
recovery below the file list and displayed an internal journal path without
explaining rollback. Root replaced that with a prominent contextual banner.
Banner build SHA256 `91C03202C495DD074DFF328419E215A5C3BE935B15EE88BBA90467565D874C22`
passed native inspection at 1440×900 and exactly 1100×700 client. Save As to the
missing-entry fixture stopped before overwriting its pending transaction and
opened the banner. Root typed a new source comment; the banner remained. Recover
removed the partial first-save files/journal while preserving the current
41-object model and the newly typed draft. Save All then succeeded at the same
path without another picker, writing all 21 files. The app reported all changes
saved, 41 objects, 25 links and zero checks. Disk inspection confirmed the new
entry comment exactly.
The build succeeded after adding the omitted tooltip import. A later Clippy run
encountered the concurrent parser worker's incomplete Declaration initializer;
rerun once parser/writeback interfaces settle rather than treating it as a
verified lint pass.
At 1100×700, root opened `included-syntax-error/project.arcadia`. The included
`model.arcadia:4:17` diagnostic remained visible from the entry file, while the
previous 41-object graphical model remained accepted. Clicking the diagnostic
opened and selected the invalid line. Root inserted the missing description
value, retained the inline comment, and applied all files. The repaired model
adopted atomically as Recovery baseline, two objects, one allocation and zero
checks. Save All succeeded. Native Open reopened the same project with all
changes saved; the selected activity's property showed Saved activity rationale,
and Source navigation showed the original UUID, alias and retained comments.
The banner build then closed normally (session 29002, exit 0); its runtime log
was empty. This completes these Windows source recovery/minimum-size trials;
new nesting and REST implementations still need their own integrated evidence.
## Production nested ownership and REST integration (2026-09-06)
The existing source3 parser now accepts nested element declarations with real
lexical-owner/body spans. The structural writer relocates declaration pieces
after scalar edits, carrying attached comments and original record provenance.
The six-file `examples/source-project/project.arcadia` fixture compiles with
stable IDs, zero semantic diagnostics and three warning-free initial views.
Initial Windows integration passed 416 workspace tests. Root's final pass after
comment, provenance and example follow-ups passed 422; Linux passed 425 (including
platform-specific filesystem checks). Strict Windows/Linux workspace Clippy,
formatting, and optimized Linux desktop/server builds passed. Linux desktop
SHA256 is `C794426BD91980B2384B92884176542B2B081FA7B4ADE8ADA5D7EECEB72D6351`;
server SHA256 is `F7EFAAF3FA42D33D7CA77633BFDC9B9C87B57C558C3D4B1C71D508BF80293992`.
Both normative reST documents rendered through strict Docutils after fixing an
API section underline. Independent parser probes passed 14
malformed cases, 308 token mutations, budgets/spans/stabilization and 600 levels
on a 128 KiB stack. Independent writer probes passed all 125 valid parent
forests on four elements, whitespace/CRLF/compact variants, deletion/UUID reuse,
unchanged-file fidelity and budget atomicity. No material implementation finding
remains from those reviews. Source-preservation wording was qualified for
explicit nested reparenting. A fresh agent spawn hit the thread limit, so
reviewers examined artifacts implemented by another worker.
Review/reproduction fixed two material defects: equal-model delete/recreate
commands skipped provenance-aware source updates in desktop/server, and a
comment after an owner's opening brace followed a moved child. The latter
failed its focused reproducer before the fix and all 12 nested updater tests
passed afterward. All 12 Document tests passed, including exact nested source
Undo/Redo, rejected Apply and reopen.
Root operated the actual server at loopback port 18788: imported a nested
three-file project (201), applied a comment-only revision 2 with an unchanged
model, reparented a child across files with its rationale at revision 3, rejected
the original ETag (412), stopped and restarted the service, and verified exact
model/source/ETag preservation plus conditional source GET (304). Trial records
are in `D:/CodexBuilds/Capella2-release1-01a06e2e/source-project-service-trial`.
Runtime logs contain only the expected listening messages. The restarted server
was stopped; fresh process inventory confirmed it is no longer running.
Windows native build SHA256
`51002E90C7C75880FF054931343ADD48DE9970A29D9DDD294EB749E3C665F49C`
opened a separate copy of the six-file example. Root inspected OA allocation,
SA boundary/ports and functional containment views, changed Assess report's
semantic parent to Assure response quality in Properties, and applied it. The
outline and diagram adopted the new hierarchy; the performer remained Rescue
coordination system and Report input stayed owned by Assess report. Existing
coordinates remain authored: Arrange plus Fit produced a readable updated view.
Source navigated directly to `sa/assurance.arcadia` and displayed the relocated
function, port, UUIDs and leading/trailing rationale. Undo arrangement then Undo
reparent restored the original files and all-changes-saved state. Both Redo
operations succeeded. Save All wrote six files; disk inspection confirmed the
new declaration ownership and unchanged allocation. Native Open reopened the
saved project with 22 objects, 13 links, zero checks and all changes saved; the
SA allocation view retained the same participant/function ports and exchanges.
Minimum-size follow-up, independent native review and actual rebuilt Linux
nesting trial are still pending.