diff --git a/.cursor/rules/atlas-api.mdc b/.cursor/rules/atlas-api.mdc index 3e079687..d704f798 100644 --- a/.cursor/rules/atlas-api.mdc +++ b/.cursor/rules/atlas-api.mdc @@ -29,7 +29,7 @@ We are building a maintainable codebase. During audits, watch for duplicated log The goal is clean, DRY code that is easy to reason about and hard to get wrong. If the same "compute meta path, check if name needs slugify, upsert/remove name field" sequence appears in 3+ places, that's a refactor candidate. -**Protocol Version:** 5 | Server/plugin MAJOR.MINOR must match +**Protocol Version:** 6 | Server/plugin MAJOR.MINOR must match ## Architecture @@ -50,19 +50,25 @@ Studio → ChangeBatcher → Plugin → WebSocket → Server → Filesystem | `/api/write` | POST | Two-way sync: plugin sends changes | | `/api/open/:id` | POST | Open script in editor (`{"cursor": [line, col]}`) | | `/api/ref-patch/:id` | GET | Fix Ref properties after syncback | +| `/api/socket/:cursor` | GET | WebSocket upgrade for live patch streaming | ### Server Info Response (`GET /api/rojo`) ```json { "serverVersion": "7.7.0-rc.1", - "protocolVersion": 5, + "serverFork": "atlas", + "protocolVersion": 6, "sessionId": "uuid", "projectName": "MyProject", "expectedPlaceIds": [123456], + "unexpectedPlaceIds": [111111], "rootInstanceId": "00000000-0000-0000-0000-000000000000", "syncSourceOnly": true, - "visibleServices": ["Workspace", "ServerScriptService"] + "visibleServices": ["Workspace", "ServerScriptService"], + "placeId": 123456, + "gameId": 789012, + "ignoreHiddenServices": true } ``` @@ -70,17 +76,18 @@ Studio → ChangeBatcher → Plugin → WebSocket → Server → Filesystem ```json { + "sessionId": "uuid", "removed": ["id-1"], - "added": { "id-2": { "id": "id-2", "className": "Folder", "name": "New", "properties": {}, "children": [] } }, - "updated": [{ "id": "id-3", "changedName": "Renamed", "changedProperties": { "Source": "print('hi')" } }] + "added": { "id-2": { "parent": "parent-id", "className": "Folder", "name": "New", "properties": {}, "children": [] } }, + "updated": [{ "id": "id-3", "changedName": "Renamed", "changedClassName": "Script", "changedProperties": { "Source": "print('hi')" }, "changedMetadata": {} }] } ``` **Format detection:** `Name/init.luau` → directory | `Name.luau` → file | neither → use `has_children` (`.lua` also supported) -## WebSocket Protocol (v5) +## WebSocket Protocol (v6) -**Connection:** `ws://host:port/api/rojo` +**Connection:** `ws://host:port/api/socket/:cursor` 1. Connect → Receive `ServerInfo` handshake → Send `{type = "Subscribe"}` → Receive patches @@ -112,11 +119,15 @@ Studio → ChangeBatcher → Plugin → WebSocket → Server → Filesystem ```rust pub struct ServeSession { - session_id: Uuid, - tree_handle: Arc>, - message_queue: MessageQueue, - project: Project, - vfs: Vfs, + session_id: SessionId, + tree: Arc>, + message_queue: Arc>, + root_project: Project, + vfs: Arc, + change_processor: ChangeProcessor, + start_time: Instant, + tree_mutation_sender: Sender, + suppressed_paths: Arc>>, } ``` @@ -223,8 +234,8 @@ for _, update in patch.updated do Reconciler.hydrate(instanceMap:fromId(update.i │ Sends PatchSet to tree_mutation_sender channel │ │ ▼ │ │ ChangeProcessor::handle_tree_event() [src/change_processor.rs] │ -│ │ For removed: fs::remove_file(instigating_source.path) │ -│ │ For updated: if "Source" property → fs::write(path, value) │ +│ │ For removed: fs::remove_file(path) + remove meta │ +│ │ For updated: Source writes, renames, ClassName changes, properties │ │ ▼ │ │ apply_patch_set() → Updates RojoTree │ │ ▼ │ @@ -510,54 +521,14 @@ true, false, 42, "string" ### Server-Side: ChangeProcessor (`src/change_processor.rs`) -Receives patches from plugin via `tree_mutation_receiver` channel: +Receives patches from plugin via `tree_mutation_receiver` channel. Handles removals, Source writes, renames (with slugify/dedup/meta lifecycle), ClassName changes (script type transitions via file renames), and property writes to meta/model files. -```rust -fn handle_tree_event(&self, patch_set: PatchSet) { - let mut tree = self.tree.lock().unwrap(); - - // Process removals - for &id in &patch_set.removed_instances { - if let Some(instance) = tree.get_instance(id) { - match &instance.metadata().instigating_source { - Some(InstigatingSource::Path(path)) => { - log::info!("Two-way sync: Removing file {}", path.display()); - fs::remove_file(path)?; - } - Some(InstigatingSource::ProjectNode { .. }) => { - log::warn!("Cannot remove instance from project file"); - } - None => { - log::warn!("Cannot remove instance without instigating source"); - } - } - } - } - - // Process updates - for update in &patch_set.updated_instances { - if let Some(instance) = tree.get_instance(update.id) { - // Currently only Source property is supported - if let Some(Variant::String(source)) = update.changed_properties.get("Source") { - match &instance.metadata().instigating_source { - Some(InstigatingSource::Path(path)) => { - log::info!("Two-way sync: Writing Source to {}", path.display()); - fs::write(path, source)?; - } - _ => log::warn!("Cannot update instance without path source"), - } - } - - // Other property changes logged but not written - if update.changed_name.is_some() { log::warn!("Cannot rename instances yet"); } - if update.changed_class_name.is_some() { log::warn!("Cannot change ClassName yet"); } - } - } - - apply_patch_set(&mut tree, patch_set); // Update DOM - self.message_queue.push_messages(&[applied_patch]); // Broadcast to plugins -} -``` +**Capabilities:** +- **Removals:** Deletes file + associated meta file via `InstigatingSource::Path` +- **Source writes:** Updates script file contents +- **Renames:** Slugifies new name, deduplicates against siblings, renames file on disk, creates/updates/removes meta `name` field as needed. Handles both standalone files and init-file directories. +- **ClassName changes:** Script-to-script transitions (e.g. ModuleScript → Script) via file extension renames +- **Property writes:** Attributes and other properties written to meta/model files via `syncback_updated_properties` in `api.rs` **InstigatingSource:** Every instance in RojoTree tracks where it came from: - `InstigatingSource::Path(PathBuf)` - Created from a file, can be written back @@ -620,15 +591,10 @@ end | **Undo/redo triggers spurious changes** | Studio's ChangeHistoryService fires property changes during undo | Can cause unexpected writes or conflicts | | **Rapid changes interleave** | 200ms batch window may split logically related changes | Partial updates can leave inconsistent state | | **Reparenting not supported** | `Parent` property changes to non-nil values are ignored | Moving instances in Studio doesn't sync | -| **Renaming not supported** | `changedName` logged but not written | Renaming in Studio doesn't sync | -| **ClassName changes ignored** | Would require file type change | Changing script type doesn't sync | -| **Only Source property writes** | Other properties logged but not written to filesystem | Only script content syncs reliably | | **Project node instances immutable** | InstigatingSource::ProjectNode cannot be modified | Instances from project file can't be changed | | **Duplicate-named siblings skipped** | Path ambiguity makes reliable sync impossible | Instances with same-name siblings ignored | | **Ref properties complex** | Instance references may become invalid when instances move | ObjectValue.Value may not sync correctly | -**Recommended workflow:** Use one-way sync (server → Studio) as primary. Use two-way sync only for script Source changes with careful attention to undo/redo behavior. - ## Protocol Enforcement ```rust diff --git a/.cursor/rules/atlas-project.mdc b/.cursor/rules/atlas-project.mdc index 1a530b57..1b1c20cf 100644 --- a/.cursor/rules/atlas-project.mdc +++ b/.cursor/rules/atlas-project.mdc @@ -101,6 +101,7 @@ Used in `tree` and recursively for all children. | `*.lua` | ModuleScript | — | Legacy | | `*.server.lua` | Script | Legacy | Legacy | | `*.client.lua` | LocalScript | — | Legacy | +| `*.plugin.lua` | Script | Plugin | Legacy | **Prefer `.luau`** — modern RunContext. `.lua` is legacy (still supported). @@ -199,6 +200,9 @@ Configure `syncbackRules` object for Roblox → filesystem sync: | `syncCurrentCamera` | bool | `false` | Whether to sync `Workspace.CurrentCamera` | | `syncUnscriptable` | bool | `true` | Whether to sync unscriptable properties | | `ignoreReferents` | bool | `false` | Whether to skip Ref properties (e.g., `Model.PrimaryPart`) | +| `createIgnoreDirPaths` | bool | `false` | Whether `/**` in ignore paths also matches the directory itself | +| `ignoreHiddenServices` | bool | — | Override root-level `ignoreHiddenServices` for syncback | +| `warnDuplicateNames` | bool | `false` | Warn on duplicate child names during syncback | ```json5 { @@ -212,42 +216,70 @@ Configure `syncbackRules` object for Roblox → filesystem sync: "ignoreClasses": ["Terrain", "Camera"], "syncCurrentCamera": false, "syncUnscriptable": true, - "ignoreReferents": false + "ignoreReferents": false, + "createIgnoreDirPaths": false, + "warnDuplicateNames": false } } ``` ## Meta Files -Add metadata to file-based instances. Example: `MyScript.server.meta.json5` (for `MyScript.server.luau`) +Add metadata to file-based instances. Meta files use **camelCase keys** (no `$` prefix -- that's only for ProjectNode). + +**Adjacent meta** (e.g. `MyScript.server.meta.json5` for `MyScript.server.luau`): ```json5 { - "$className": "Script", // Override (rare) - "$properties": { "Disabled": false }, - "$attributes": { "Priority": 1 }, - "$ignoreUnknownInstances": true + "name": "Key:Script", // Override filename-derived instance name (slugify support) + "id": "unique-id", // For Ref property linking + "properties": { "Disabled": false }, + "attributes": { "Priority": 1 }, + "ignoreUnknownInstances": true } ``` -**Meta files CAN'T:** Override name, set `$path`, have children. +**Directory meta** (e.g. `init.meta.json5` inside a directory): + +```json5 +{ + "name": "My:Folder", // Override directory-derived instance name + "className": "Configuration", // Override class (directory meta only) + "id": "unique-id", + "properties": { "Anchored": true }, + "attributes": { "Health": 100 }, + "ignoreUnknownInstances": false +} +``` + +**Key differences from ProjectNode:** Meta uses `className`, `properties`, `attributes` (camelCase, no `$` prefix). Adjacent meta does NOT support `className`. + +**`name` field (slugify support):** When an instance name contains forbidden filesystem characters (`: / ? * < > | " \`), syncback slugifies the filename (forbidden chars → `_`) and stores the real name in the `name` field. On forward sync, `name` overrides the filename-derived instance name. On collisions (e.g. `A/B` and `A_B` both slugify to `A_B`), `~1`, `~2` suffixes are appended and meta `name` fields distinguish them. + +**Meta files CAN'T:** Set `$path`, have children. ## Model.json5 Format -Inline instance definition: +Inline instance definition. Like meta files, model files use **camelCase keys** (no `$` prefix). ```json5 { - "$className": "Tool", - "$properties": { "CanBeDropped": false }, - "$attributes": { "Damage": 50 }, - "Handle": { - "$className": "Part", - "$properties": { "Size": [1, 1, 3] } + "name": "What?Model", // Override filename-derived name (slugify support) + "className": "Tool", + "id": "unique-id", + "properties": { "CanBeDropped": false }, + "attributes": { "Damage": 50 }, + "children": { + "Handle": { + "className": "Part", + "properties": { "Size": [1, 1, 3] } + } } } ``` +**Note:** Children are nested under a `"children"` key (not flattened like in ProjectNode). + ## Property Formats ### Shorthand (Recommended) diff --git a/.cursor/rules/atlas-syncback.mdc b/.cursor/rules/atlas-syncback.mdc index 6026014e..d0da6c07 100644 --- a/.cursor/rules/atlas-syncback.mdc +++ b/.cursor/rules/atlas-syncback.mdc @@ -52,6 +52,7 @@ syncback/ snapshot.rs - Convert instances → snapshots fs_snapshot.rs - Filesystem representation file_names.rs - Generate valid filenames + meta.rs - Meta file read/write helpers property_filter.rs - Filter properties per syncback rules ref_properties.rs - Handle Ref properties (cross-references) hash/ - Content hashing for change detection @@ -119,18 +120,33 @@ pub struct SyncbackRules { /// Roblox tree paths to ignore (e.g., "ServerStorage/Secrets") pub ignore_trees: Vec, - /// Filesystem paths to ignore (e.g., "src/client/*") - pub ignore_paths: Vec, + /// Filesystem glob patterns to ignore (e.g., "src/client/*") + pub ignore_paths: Vec, /// Properties to not sync back per class /// Example: {"BasePart": ["Color"], "Script": ["Disabled"]} - pub ignore_properties: HashMap>, + pub ignore_properties: IndexMap>, + + /// Instance classes to skip entirely + pub ignore_classes: Vec, /// Whether to sync Workspace.CurrentCamera (default: false) - pub sync_current_camera: bool, + pub sync_current_camera: Option, /// Whether to sync unscriptable properties (default: true) - pub sync_unscriptable: bool, + pub sync_unscriptable: Option, + + /// Whether to skip Ref properties (default: false) + pub ignore_referents: Option, + + /// Whether /** in ignore paths also matches the directory itself + pub create_ignore_dir_paths: Option, + + /// Override root-level ignoreHiddenServices for syncback + pub ignore_hidden_services: Option, + + /// Warn on duplicate child names during syncback + pub warn_duplicate_names: Option, } ``` @@ -207,6 +223,7 @@ pub struct SyncbackSnapshot<'sync> { pub new: Ref, // New tree instance (Roblox file) pub path: PathBuf, // Target filesystem path pub middleware: Option, // How to write (Lua, Rbxm, etc.) + pub needs_meta_name: bool, // Filesystem name differs from instance name } ``` @@ -333,9 +350,9 @@ stats.record_duplicate_name(path, name) stats.record_rbxm_fallback(path, reason) stats.record_unknown_class(class_name) stats.record_unknown_property(class, property) -stats.has_issues() // Any warnings? +stats.has_issues() // Any warnings? stats.has_unknown_types() // Unknown classes/properties? -stats.log_summary() // Log final report +stats.log_summary() // Log final report ``` **Logged at end of syncback:** @@ -364,7 +381,7 @@ print('hello') ```json5 // src/MyScript.server.meta.json5 (for MyScript.server.luau) { - "$properties": { + "properties": { "Disabled": false } } @@ -407,12 +424,7 @@ Filesystem: 4. **Write permission** - Can't write to file - Error reported, syncback continues for other files -**Error collection:** -```rust -stats.record_error(&format!("Failed to sync {}: {}", path.display(), error)); -``` - -All errors collected and reported at end. +**Error propagation:** Errors are propagated via `anyhow::bail!`, not collected in stats. Fatal errors abort the syncback; non-fatal issues are logged as warnings. ## Integration with Plugin diff --git a/.cursor/rules/atlas.mdc b/.cursor/rules/atlas.mdc index bf06bcb5..7f718c2d 100644 --- a/.cursor/rules/atlas.mdc +++ b/.cursor/rules/atlas.mdc @@ -294,7 +294,8 @@ Prefer JSON5 over JSON for comments and trailing commas: ### Loading a Project (Rust) ```rust use librojo::Project; -let project = Project::load_from_slice(path, contents)?; +let project = Project::load_fuzzy(&vfs, path)?; +// or: Project::load_exact(path)?; ``` ### Building Snapshots (Rust) @@ -312,26 +313,29 @@ Reconciler.applyPatch(instanceMap, patch) ## File Organization ``` -src/ - Rust server/CLI - cli/ - Command implementations - snapshot/ - Core snapshot system - snapshot_middleware/ - File type handlers - syncback/ - Roblox → filesystem - web/ - HTTP/WebSocket server +src/ - Rust server/CLI + cli/ - Command implementations + snapshot/ - Core snapshot system + snapshot_middleware/ - File type handlers + syncback/ - Roblox → filesystem + web/ - HTTP/WebSocket server + change_processor.rs - Two-way sync filesystem writer -plugin/src/ - Lua Studio plugin - App/ - Roact UI components - Reconciler/ - Patch application logic - Config.lua - Version and protocol config +plugin/src/ - Lua Studio plugin + App/ - Roact UI components + Reconciler/ - Patch application logic + ChangeBatcher/ - Two-way sync patch creation + Config.lua - Version and protocol config -rbx-dom/ - Git submodule (separate repo) - rbx_binary/ - Binary format (rbxm/rbxl) - rbx_xml/ - XML format (rbxmx/rbxlx) - rbx_dom_weak/ - Instance representation - rbx_reflection/ - Property metadata +rbx-dom/ - Git submodule (separate repo) + rbx_binary/ - Binary format (rbxm/rbxl) + rbx_xml/ - XML format (rbxmx/rbxlx) + rbx_dom_weak/ - Instance representation + rbx_reflection/ - Property metadata + rbx_reflection_database/ - Reflection DB -tests/ - Rust integration tests -rojo-test/ - Test fixtures and utilities +tests/ - Rust integration tests +rojo-test/ - Test fixtures and utilities ``` ## Anti-Patterns to Avoid @@ -349,7 +353,7 @@ rojo-test/ - Test fixtures and utilities ## Version Information -- **Protocol version:** 5 (`plugin/src/Config.lua`) +- **Protocol version:** 6 (`plugin/src/Config.lua`) - **Server/Plugin must match:** MAJOR.MINOR (e.g., 7.7.x works with 7.7.y) - **MSRV:** Rust 1.88 - **License:** MPL-2.0 (rbx-dom uses MIT)