Add CI pipeline documentation and enhance syncback architecture

- Introduced a new CI pipeline documentation file outlining steps for auto-fixing formatting, running linters, building, and testing.
- Documented the Rojo syncback architecture, detailing improvements to prevent duplicate file creation and format mismatches.
- Updated syncback logic to ensure existing instances are updated instead of creating duplicates, enhancing project file management.
- Added tests for encoding instances and handling nested children to improve reliability in syncback operations.
This commit is contained in:
Joe 2026-02-01 18:18:31 -06:00
parent edc09741bf
commit b368eb2401
46 changed files with 3688 additions and 112 deletions

View file

@ -21,6 +21,7 @@ use rbx_dom_weak::{
use crate::{
serve_session::ServeSession,
snapshot::{InstanceWithMeta, InstigatingSource, PatchSet, PatchUpdate},
syncback::VISIBLE_SERVICES,
web::{
interface::{
ErrorResponse, Instance, InstanceMetadata, MessagesPacket, OpenResponse, ReadResponse,
@ -32,6 +33,22 @@ use crate::{
web_api::{InstanceUpdate, RefPatchResponse, SerializeResponse},
};
/// Represents the existing file format for a script/instance on disk.
/// Used to preserve the current format when doing partial updates from the plugin.
///
/// When the plugin sends a script update, it might not include children info.
/// We check the filesystem to see if this is already a directory (with init file)
/// or a standalone file, and preserve that format to avoid creating duplicates.
#[derive(Debug, Clone, Copy, PartialEq)]
enum ExistingFileFormat {
/// No existing file found - use has_children to decide
None,
/// Standalone file exists (e.g., Name.luau)
Standalone,
/// Directory with init file exists (e.g., Name/init.luau)
Directory,
}
/// Convert a Variant to a JSON-compatible value for .model.json5 files
fn variant_to_json(variant: &Variant) -> Option<serde_json::Value> {
use serde_json::{json, Value};
@ -150,6 +167,13 @@ impl ApiService {
let tree = self.serve_session.tree();
let root_instance_id = tree.get_root_id();
let ignore_hidden_services = self.serve_session.ignore_hidden_services();
let visible_services = if ignore_hidden_services {
VISIBLE_SERVICES.iter().map(|s| s.to_string()).collect()
} else {
Vec::new()
};
msgpack_ok(&ServerInfoResponse {
server_version: SERVER_VERSION.to_owned(),
protocol_version: PROTOCOL_VERSION,
@ -161,6 +185,8 @@ impl ApiService {
game_id: self.serve_session.game_id(),
root_instance_id,
sync_source_only: true,
ignore_hidden_services,
visible_services,
})
}
@ -403,11 +429,36 @@ impl ApiService {
}
}
/// Find a child instance by name under a given parent in the tree.
/// Returns the Ref of the child if found, None otherwise.
fn find_child_by_name(
tree: &crate::snapshot::RojoTree,
parent_ref: Ref,
name: &str,
) -> Option<Ref> {
let parent = tree.get_instance(parent_ref)?;
parent
.children()
.iter()
.find(|&&child_ref| {
tree.get_instance(child_ref)
.map(|c| c.name() == name)
.unwrap_or(false)
})
.copied()
}
/// This follows the same middleware selection logic as the dedicated syncback
/// system in `src/syncback/mod.rs` to ensure consistent behavior.
///
/// Takes the tree reference and duplicate siblings cache as parameters to ensure
/// consistency - the cache was computed from this exact tree state.
///
/// IMPORTANT: Before creating new files, this function checks if an instance
/// with the same name already exists in the tree. If so, it updates the existing
/// file at its `instigating_source.path` instead of creating a new file.
/// This prevents duplicate file creation when the plugin sends an instance
/// that already exists on the filesystem.
fn syncback_added_instance(
&self,
added: &crate::web::interface::AddedInstance,
@ -446,6 +497,26 @@ impl ApiService {
);
}
// CRITICAL: Check if instance already exists in tree before creating new files.
// This prevents duplicate file creation when the plugin sends an instance
// that already exists (e.g., user "pulls" an instance that appeared as
// "to delete" due to duplicate detection issues).
if let Some(existing_ref) = Self::find_child_by_name(tree, parent_ref, &added.name) {
if let Some(existing) = tree.get_instance(existing_ref) {
if let Some(source) = &existing.metadata().instigating_source {
let existing_path = source.path();
log::info!(
"Syncback: Instance '{}' already exists in tree at {}, updating in place",
added.name,
existing_path.display()
);
// Update the existing instance instead of creating new files
return self.syncback_update_existing_instance(added, existing_path, stats);
}
}
}
// Instance doesn't exist in tree - create new files
// Get the parent's filesystem path from its metadata
let parent_path = parent_instance
.metadata()
@ -468,6 +539,152 @@ impl ApiService {
self.syncback_instance_to_path_with_stats(added, &parent_dir, stats)
}
/// Update an existing instance in place instead of creating new files.
/// This is called when the plugin sends an "added" instance that already
/// exists in the tree. We update the existing file at its instigating_source
/// path to preserve the file format and avoid creating duplicates.
///
/// For children, we use the normal syncback path which will check for
/// existing files via `detect_existing_script_format`.
fn syncback_update_existing_instance(
&self,
added: &crate::web::interface::AddedInstance,
existing_path: &std::path::Path,
stats: &crate::syncback::SyncbackStats,
) -> anyhow::Result<()> {
use anyhow::Context;
let class_name = &added.class_name;
// For scripts, update the Source property at the existing path
match class_name.as_str() {
"ModuleScript" | "Script" | "LocalScript" => {
let source = self.get_source_property(added);
// Determine the actual file to write based on existing structure
// If existing_path is a directory, look for init file inside
let file_path = if existing_path.is_dir() {
// Find the init file based on class
let init_name = match class_name.as_str() {
"ModuleScript" => {
if existing_path.join("init.luau").exists() {
"init.luau"
} else {
"init.lua"
}
}
"Script" => {
// Check which init file exists
if existing_path.join("init.server.luau").exists() {
"init.server.luau"
} else if existing_path.join("init.client.luau").exists() {
"init.client.luau"
} else if existing_path.join("init.server.lua").exists() {
"init.server.lua"
} else {
"init.server.luau" // Default
}
}
"LocalScript" => {
if existing_path.join("init.client.luau").exists() {
"init.client.luau"
} else if existing_path.join("init.local.luau").exists() {
"init.local.luau"
} else if existing_path.join("init.client.lua").exists() {
"init.client.lua"
} else {
"init.client.luau" // Default
}
}
_ => unreachable!(),
};
existing_path.join(init_name)
} else {
// It's already a file path
existing_path.to_path_buf()
};
fs::write(&file_path, source.as_bytes()).with_context(|| {
format!("Failed to write file: {}", file_path.display())
})?;
log::info!(
"Syncback: Updated existing {} at {}",
class_name,
file_path.display()
);
// Handle children - they go into the directory
if !added.children.is_empty() {
let children_dir = if existing_path.is_dir() {
existing_path.to_path_buf()
} else {
// For standalone scripts, children would need directory conversion
// For now, use the parent directory as the base
existing_path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| existing_path.to_path_buf())
};
// Filter duplicate children
let inst_path = format!("{}", existing_path.display());
let unique_children =
self.filter_duplicate_children(&added.children, &inst_path, stats);
// Process children using normal syncback path
// This will use detect_existing_script_format to check existing files
for child in &unique_children {
self.syncback_instance_to_path_with_stats(child, &children_dir, stats)?;
}
}
}
// For non-script types, update the appropriate file
_ => {
if existing_path.is_dir() {
// Update init.meta.json5 if needed
if !added.properties.is_empty() {
self.write_init_meta_json(existing_path, added)?;
log::info!(
"Syncback: Updated existing {} at {}/init.meta.json5",
class_name,
existing_path.display()
);
}
// Handle children
if !added.children.is_empty() {
let inst_path = format!("{}", existing_path.display());
let unique_children =
self.filter_duplicate_children(&added.children, &inst_path, stats);
for child in &unique_children {
self.syncback_instance_to_path_with_stats(
child,
existing_path,
stats,
)?;
}
}
} else {
// It's a standalone file (e.g., .model.json5)
let content = self.serialize_instance_to_model_json(added)?;
fs::write(existing_path, &content).with_context(|| {
format!("Failed to write file: {}", existing_path.display())
})?;
log::info!(
"Syncback: Updated existing {} at {}",
class_name,
existing_path.display()
);
}
}
}
Ok(())
}
/// Syncback a removed instance by deleting its file(s) from the filesystem.
/// This is called when the user selects "pull" for an instance that exists in Rojo
/// but has been deleted in Studio.
@ -600,6 +817,78 @@ impl ApiService {
.collect()
}
/// Detects what file format currently exists on disk for a given instance.
/// This is used to preserve the existing format during partial updates.
///
/// When the plugin sends a script update, it might not include children info.
/// We check the filesystem to see if this is already a directory (with init file)
/// or a standalone file, and preserve that format.
fn detect_existing_script_format(
parent_dir: &std::path::Path,
name: &str,
class_name: &str,
) -> ExistingFileFormat {
let dir_path = parent_dir.join(name);
// Check for directory with init file first (takes precedence)
if dir_path.is_dir() {
// Check for any init file based on class type
let init_files = match class_name {
"ModuleScript" => vec!["init.luau", "init.lua"],
"Script" => vec![
"init.server.luau",
"init.server.lua",
"init.client.luau",
"init.client.lua",
],
"LocalScript" => vec![
"init.client.luau",
"init.client.lua",
"init.local.luau",
"init.local.lua",
],
// For non-scripts, check for init.model.json5 or similar
_ => vec!["init.model.json5", "init.model.json", "init.meta.json5"],
};
for init_file in init_files {
if dir_path.join(init_file).exists() {
return ExistingFileFormat::Directory;
}
}
}
// Check for standalone file
let standalone_patterns = match class_name {
"ModuleScript" => vec![format!("{}.luau", name), format!("{}.lua", name)],
"Script" => vec![
format!("{}.server.luau", name),
format!("{}.server.lua", name),
format!("{}.client.luau", name),
format!("{}.client.lua", name),
],
"LocalScript" => vec![
format!("{}.client.luau", name),
format!("{}.client.lua", name),
format!("{}.local.luau", name),
format!("{}.local.lua", name),
],
// For non-scripts, check for model files
_ => vec![
format!("{}.model.json5", name),
format!("{}.model.json", name),
],
};
for pattern in standalone_patterns {
if parent_dir.join(&pattern).exists() {
return ExistingFileFormat::Standalone;
}
}
ExistingFileFormat::None
}
/// Recursively syncback an instance and its children to the filesystem.
/// This is the internal implementation that handles the actual file creation.
#[allow(dead_code)]
@ -642,13 +931,70 @@ impl ApiService {
// Determine the appropriate middleware/file format based on class name
// This matches the logic in src/syncback/mod.rs::get_best_middleware
// Scripts with children become directories with init files
//
// IMPORTANT: When an instance already exists on disk, preserve its format.
// The plugin might send a partial update (e.g., just the script source without
// children), so we can't rely solely on has_children to decide the format.
// Only transition formats when creating truly new instances.
let existing_format =
Self::detect_existing_script_format(parent_dir, &added.name, &added.class_name);
match added.class_name.as_str() {
// Script types: .luau files, or directories with init files if has children
// Non-Source properties (like Attributes) go into init.meta.json5
"ModuleScript" => {
let source = self.get_source_property(added);
if has_children {
// Decide whether to use directory or standalone format:
// - If directory already exists on disk, preserve it (update init file)
// - If standalone already exists on disk, preserve it (update standalone)
// - If nothing exists, use has_children to decide (new instance)
let use_directory = match existing_format {
ExistingFileFormat::Directory => {
log::debug!(
"Syncback: Preserving existing directory format for {}",
added.name
);
true
}
ExistingFileFormat::Standalone => {
log::debug!(
"Syncback: Preserving existing standalone format for {}",
added.name
);
false
}
ExistingFileFormat::None => {
// New instance - use has_children to decide
has_children
}
};
if use_directory {
// Directory format: Name/init.luau
// Only remove standalone if this is a new instance transitioning to directory
if existing_format == ExistingFileFormat::None {
let standalone_path = parent_dir.join(format!("{}.luau", added.name));
if standalone_path.exists() {
if let Err(err) = fs::remove_file(&standalone_path) {
log::warn!(
"Syncback: Failed to remove old standalone file {}: {}",
standalone_path.display(),
err
);
} else {
log::info!(
"Syncback: Removed old standalone file {} (script now has children)",
standalone_path.display()
);
}
let meta_path = parent_dir.join(format!("{}.meta.json5", added.name));
if meta_path.exists() {
let _ = fs::remove_file(&meta_path);
}
}
}
let dir_path = parent_dir.join(&added.name);
fs::create_dir_all(&dir_path).with_context(|| {
format!("Failed to create directory: {}", dir_path.display())
@ -657,32 +1003,83 @@ impl ApiService {
fs::write(&init_path, source.as_bytes()).with_context(|| {
format!("Failed to write file: {}", init_path.display())
})?;
// Write init.meta.json5 if there are non-Source properties (like Attributes)
self.write_script_meta_json_if_needed(&dir_path, added)?;
log::info!(
"Syncback: Created ModuleScript directory at {}",
dir_path.display()
"Syncback: Updated ModuleScript at {}",
init_path.display()
);
// Recurse into children if any were provided
for child in &unique_children {
self.syncback_instance_to_path_with_stats(child, &dir_path, stats)?;
}
} else {
// Standalone format: Name.luau
// Only remove directory if this is a new instance transitioning to standalone
if existing_format == ExistingFileFormat::None {
let dir_path = parent_dir.join(&added.name);
if dir_path.is_dir() {
if let Err(err) = fs::remove_dir_all(&dir_path) {
log::warn!(
"Syncback: Failed to remove old directory {}: {}",
dir_path.display(),
err
);
} else {
log::info!(
"Syncback: Removed old directory {} (script no longer has children)",
dir_path.display()
);
}
}
}
let file_path = parent_dir.join(format!("{}.luau", added.name));
fs::write(&file_path, source.as_bytes()).with_context(|| {
format!("Failed to write file: {}", file_path.display())
})?;
// Create adjacent meta file if there are non-Source properties
self.write_adjacent_script_meta_if_needed(parent_dir, &added.name, added)?;
log::info!("Syncback: Created ModuleScript at {}", file_path.display());
log::info!("Syncback: Updated ModuleScript at {}", file_path.display());
}
}
"Script" => {
let source = self.get_source_property(added);
// Determine script type based on RunContext property
// This ensures proper round-tripping regardless of emitLegacyScripts mode
let script_suffix = self.get_script_suffix_for_run_context(added);
if has_children {
// Use existing format if present, otherwise decide based on has_children
let use_directory = match existing_format {
ExistingFileFormat::Directory => {
log::debug!(
"Syncback: Preserving existing directory format for Script {}",
added.name
);
true
}
ExistingFileFormat::Standalone => {
log::debug!(
"Syncback: Preserving existing standalone format for Script {}",
added.name
);
false
}
ExistingFileFormat::None => has_children,
};
if use_directory {
// Only clean up if this is a new instance
if existing_format == ExistingFileFormat::None {
for suffix in &["server", "client"] {
let standalone_path =
parent_dir.join(format!("{}.{}.luau", added.name, suffix));
if standalone_path.exists() {
let _ = fs::remove_file(&standalone_path);
}
}
let meta_path = parent_dir.join(format!("{}.meta.json5", added.name));
if meta_path.exists() {
let _ = fs::remove_file(&meta_path);
}
}
let dir_path = parent_dir.join(&added.name);
fs::create_dir_all(&dir_path).with_context(|| {
format!("Failed to create directory: {}", dir_path.display())
@ -692,27 +1089,66 @@ impl ApiService {
format!("Failed to write file: {}", init_path.display())
})?;
self.write_script_meta_json_if_needed(&dir_path, added)?;
log::info!(
"Syncback: Created Script directory at {}",
dir_path.display()
);
log::info!("Syncback: Updated Script at {}", init_path.display());
for child in &unique_children {
self.syncback_instance_to_path_with_stats(child, &dir_path, stats)?;
}
} else {
// Only clean up if this is a new instance
if existing_format == ExistingFileFormat::None {
let dir_path = parent_dir.join(&added.name);
if dir_path.is_dir() {
let _ = fs::remove_dir_all(&dir_path);
}
}
let file_path =
parent_dir.join(format!("{}.{}.luau", added.name, script_suffix));
fs::write(&file_path, source.as_bytes()).with_context(|| {
format!("Failed to write file: {}", file_path.display())
})?;
// Create adjacent meta file if there are non-Source properties
self.write_adjacent_script_meta_if_needed(parent_dir, &added.name, added)?;
log::info!("Syncback: Created Script at {}", file_path.display());
log::info!("Syncback: Updated Script at {}", file_path.display());
}
}
"LocalScript" => {
let source = self.get_source_property(added);
if has_children {
// Use existing format if present, otherwise decide based on has_children
let use_directory = match existing_format {
ExistingFileFormat::Directory => {
log::debug!(
"Syncback: Preserving existing directory format for LocalScript {}",
added.name
);
true
}
ExistingFileFormat::Standalone => {
log::debug!(
"Syncback: Preserving existing standalone format for LocalScript {}",
added.name
);
false
}
ExistingFileFormat::None => has_children,
};
if use_directory {
// Only clean up if this is a new instance
if existing_format == ExistingFileFormat::None {
for suffix in &["local", "client"] {
let standalone_path =
parent_dir.join(format!("{}.{}.luau", added.name, suffix));
if standalone_path.exists() {
let _ = fs::remove_file(&standalone_path);
}
}
let meta_path = parent_dir.join(format!("{}.meta.json5", added.name));
if meta_path.exists() {
let _ = fs::remove_file(&meta_path);
}
}
let dir_path = parent_dir.join(&added.name);
fs::create_dir_all(&dir_path).with_context(|| {
format!("Failed to create directory: {}", dir_path.display())
@ -722,21 +1158,25 @@ impl ApiService {
format!("Failed to write file: {}", init_path.display())
})?;
self.write_script_meta_json_if_needed(&dir_path, added)?;
log::info!(
"Syncback: Created LocalScript directory at {}",
dir_path.display()
);
log::info!("Syncback: Updated LocalScript at {}", init_path.display());
for child in &unique_children {
self.syncback_instance_to_path_with_stats(child, &dir_path, stats)?;
}
} else {
// Only clean up if this is a new instance
if existing_format == ExistingFileFormat::None {
let dir_path = parent_dir.join(&added.name);
if dir_path.is_dir() {
let _ = fs::remove_dir_all(&dir_path);
}
}
let file_path = parent_dir.join(format!("{}.local.luau", added.name));
fs::write(&file_path, source.as_bytes()).with_context(|| {
format!("Failed to write file: {}", file_path.display())
})?;
// Create adjacent meta file if there are non-Source properties
self.write_adjacent_script_meta_if_needed(parent_dir, &added.name, added)?;
log::info!("Syncback: Created LocalScript at {}", file_path.display());
log::info!("Syncback: Updated LocalScript at {}", file_path.display());
}
}
@ -843,8 +1283,29 @@ impl ApiService {
}
// All other classes -> .model.json5 or directory if has children
// Preserves existing format to avoid creating duplicates during partial updates
_ => {
if has_children {
let use_directory = match existing_format {
ExistingFileFormat::Directory => {
log::debug!(
"Syncback: Preserving existing directory format for {} {}",
added.class_name,
added.name
);
true
}
ExistingFileFormat::Standalone => {
log::debug!(
"Syncback: Preserving existing standalone format for {} {}",
added.class_name,
added.name
);
false
}
ExistingFileFormat::None => has_children,
};
if use_directory {
// Create directory and put the instance as init.meta.json5
let dir_path = parent_dir.join(&added.name);
fs::create_dir_all(&dir_path).with_context(|| {
@ -855,7 +1316,7 @@ impl ApiService {
self.write_init_meta_json(&dir_path, added)?;
log::info!(
"Syncback: Created {} directory at {} with init.meta.json5",
"Syncback: Updated {} at {}/init.meta.json5",
added.class_name,
dir_path.display()
);
@ -872,7 +1333,7 @@ impl ApiService {
format!("Failed to write file: {}", file_path.display())
})?;
log::info!(
"Syncback: Created {} at {}",
"Syncback: Updated {} at {}",
added.class_name,
file_path.display()
);

View file

@ -193,6 +193,14 @@ pub struct ServerInfoResponse {
/// Other property changes will be ignored.
#[serde(default)]
pub sync_source_only: bool,
/// When true, hidden/internal services should be ignored during sync.
/// The plugin should not mark these services as "to delete" during forward sync.
#[serde(default)]
pub ignore_hidden_services: bool,
/// List of service names that are considered "visible" when ignoreHiddenServices is true.
/// Services not in this list should be ignored during sync operations.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub visible_services: Vec<String>,
}
// Serialize place IDs as f64 to avoid msgpack uint64 encoding issues with Lua