Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
b10445d
feat(project-system): recover malformed element files without ever re…
yuto-trd Jul 31, 2026
1688213
fix(review): harden malformed-element recovery per review findings
yuto-trd Jul 31, 2026
b8acc32
fix(review): preserve recovered sidecars as raw bytes and reject bad …
yuto-trd Jul 31, 2026
e663648
fix(review): harden recovery detection, identity, and rehome writes
yuto-trd Jul 31, 2026
13d9a62
fix(review): make the rehome write atomic and null-guard generic disc…
yuto-trd Jul 31, 2026
29f8926
fix(review): guard nested discriminators, animation warnings, delete …
yuto-trd Jul 31, 2026
be2f05d
fix(review): tighten rehome failure handling, recovered-id determinis…
yuto-trd Jul 31, 2026
59bd61d
fix(review): stabilize recovered identities cross-platform and unbloc…
yuto-trd Jul 31, 2026
0b0eb8f
fix(review): reserve recovered ids scene-wide, persist them authorita…
yuto-trd Jul 31, 2026
defc57d
fix(review): project all recovered fallbacks and expose structured re…
yuto-trd Jul 31, 2026
e69843f
fix(review): deduplicate recovered elements' descendant ids determini…
yuto-trd Jul 31, 2026
c285943
fix(review): survive repairs, migrations, and wrapped IO across the r…
yuto-trd Jul 31, 2026
1f6048e
fix(review): stabilize remap keys, undoable repairs, and richer recov…
yuto-trd Jul 31, 2026
ef21083
fix(review): keep lossy elements frozen, surface incident-only recove…
yuto-trd Jul 31, 2026
6e14fe9
fix(review): resume persistence on every repair path and harden claim…
yuto-trd Jul 31, 2026
cd491cc
fix(review): migrate reference expressions, resume persistence on eve…
yuto-trd Aug 8, 2026
7ad8efc
fix(review): resume persistence on remove/target assignment, harden e…
yuto-trd Aug 8, 2026
eb982ce
fix(review): preserve recovered references and sidecars
yuto-trd Aug 9, 2026
cf86c1d
fix(review): close remaining recovery persistence gaps
yuto-trd Aug 9, 2026
b7c3029
fix(review): close nested recovery gaps
yuto-trd Aug 9, 2026
c559def
fix(review): preserve escaped recovered element ids
yuto-trd Aug 9, 2026
920a767
fix(review): cover extended recovery graphs
yuto-trd Aug 9, 2026
7085180
fix(review): close remaining recovery gaps
yuto-trd Aug 9, 2026
714efed
fix(review): preserve recovery integrity
yuto-trd Aug 9, 2026
ccf5e4d
fix(review): close recovery gaps
yuto-trd Aug 9, 2026
4402ffb
fix(review): harden recovered sidecar state
yuto-trd Aug 9, 2026
c532e37
fix(review): resume persistence for cleared presenter targets
yuto-trd Aug 9, 2026
1a0b98d
fix(review): harden recovery migrations and edits
yuto-trd Aug 9, 2026
b01b8b9
fix(review): close recovery traversal gaps
yuto-trd Aug 9, 2026
400f7a9
fix(review): complete recovery graph handling
yuto-trd Aug 9, 2026
d0db527
fix(review): harden recovery boundaries
yuto-trd Aug 9, 2026
cbb2493
fix(review): preserve recovered reference state
yuto-trd Aug 9, 2026
81cfb0c
refactor!: harden malformed-element recovery
yuto-trd Aug 9, 2026
5dd66cd
style: satisfy format check
yuto-trd Aug 9, 2026
9c0ccf9
fix: preserve recovery repair workflows
yuto-trd Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 74 additions & 3 deletions src/Beutl.AgentToolkit/Tools/SessionTools.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
using System.ComponentModel;
using System.Collections;
using System.ComponentModel;
using System.Globalization;
using Beutl.AgentToolkit.Common;
using Beutl.AgentToolkit.Reconciliation;
using Beutl.AgentToolkit.Rendering;
using Beutl.AgentToolkit.Sessions;
using Beutl.AgentToolkit.Workspace;
using Beutl.Editor;
using Beutl.Engine;
using Beutl.ProjectSystem;
using Beutl.Serialization;
using ModelContextProtocol.Server;

namespace Beutl.AgentToolkit.Tools;
Expand All @@ -15,7 +18,10 @@ public sealed record SceneSummary(string SceneId, string Name, int Width, int He

public sealed record SessionSummary(IReadOnlyList<SceneSummary> Scenes);

public sealed record OpenProjectResponse(string Session, string Source, SessionSummary Summary);
public sealed record OpenProjectResponse(string Session, string Source, SessionSummary Summary)
{
public IReadOnlyList<string> Warnings { get; init; } = [];
Comment thread
yuto-trd marked this conversation as resolved.
}

public sealed record CreateProjectResponse(string Session, string SavedPath, SessionSummary Summary);

Expand Down Expand Up @@ -71,10 +77,75 @@ public ValueTask<ToolResult<OpenProjectResponse>> OpenProject(string path, Cance
return new OpenProjectResponse(
result.Session.SessionId,
result.Session.Source.ToString(),
CreateSummary(result.Session, result.Project));
CreateSummary(result.Session, result.Project))
{
Warnings = CollectDeserializationWarnings(result.Project)
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
}

private static IReadOnlyList<string> CollectDeserializationWarnings(Project project)
{
var warnings = new List<string>();
foreach (Scene scene in project.Items.OfType<Scene>())
{
foreach (Element element in scene.Children)
{
var fallbacks = new List<IFallback>();
var visited = new HashSet<object>(ReferenceEqualityComparer.Instance);
foreach (EngineObject obj in element.Objects)
{
CollectFallbacks(obj, visited, fallbacks);
}
Comment thread
yuto-trd marked this conversation as resolved.
Outdated

string elementFile = element.Uri is { IsFile: true } uri
? Path.GetFileName(uri.LocalPath)
: element.Name;
Comment thread
yuto-trd marked this conversation as resolved.
foreach (IFallback fallback in fallbacks)
Comment thread
yuto-trd marked this conversation as resolved.
{
string error = string.IsNullOrWhiteSpace(fallback.ErrorMessage)
? fallback.Reason.ToString()
: fallback.ErrorMessage;
warnings.Add(
$"Element file '{elementFile}' contains content that could not be deserialized: {error}");
}
}
}

return warnings;
}

private static void CollectFallbacks(
object? value,
ISet<object> visited,
ICollection<IFallback> fallbacks)
{
if (value is null or string || !visited.Add(value))
return;

if (value is IFallback fallback)
{
fallbacks.Add(fallback);
return;
}

if (value is EngineObject engineObject)
{
foreach (IProperty property in engineObject.Properties)
{
CollectFallbacks(property.CurrentValue, visited, fallbacks);
}
Comment thread
yuto-trd marked this conversation as resolved.
Outdated
}

if (value is IEnumerable enumerable)
{
foreach (object? item in enumerable)
{
CollectFallbacks(item, visited, fallbacks);
Comment thread
yuto-trd marked this conversation as resolved.
Outdated
}
}
}

[McpServerTool(Name = "create_project")]
[Description("Creates and saves a new Beutl .bep project with one scene, then makes it the active editing session. In the in-app host the project opens in the Beutl editor (single open project, LiveEditor session); in the stdio host it becomes a file-backed session. Paths without an extension are saved as .bep; .beutl is reserved for project packages. The output path is restricted to BEUTL_WORKSPACE.")]
public ValueTask<ToolResult<CreateProjectResponse>> CreateProject(
Expand Down
2 changes: 2 additions & 0 deletions src/Beutl.Core/CoreObject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ public string Name

public Uri? Uri { get; set; }

internal bool IsStorageWriteSuppressed { get; set; }

private Dictionary<int, IEntry> Values => _values ??= [];

private Dictionary<int, string> Errors => _errors ??= [];
Expand Down
5 changes: 5 additions & 0 deletions src/Beutl.Core/Serialization/CoreSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,11 @@ public static void PopulateFromUri(ICoreSerializable obj, Type type, Uri uri)
public static void StoreToUri<T>(T obj, Uri uri, CoreSerializationMode? mode = null)
where T : ICoreSerializable
{
if (obj is CoreObject { IsStorageWriteSuppressed: true })
{
return;
Comment thread
yuto-trd marked this conversation as resolved.
Outdated
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if (uri.Scheme == "file")
{
if (obj is CoreObject coreObj)
Expand Down
2 changes: 1 addition & 1 deletion src/Beutl.Editor/AutoSaveService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public void SaveObjects(IEnumerable<CoreObject> objectsToSave)
{
if (obj is IHierarchical hierarchical && hierarchical.HierarchicalRoot == null)
{
if (obj.Uri!.Scheme == "file")
if (!obj.IsStorageWriteSuppressed && obj.Uri!.Scheme == "file")
{
var path = obj.Uri.LocalPath;
if (File.Exists(path))
Expand Down
192 changes: 189 additions & 3 deletions src/Beutl.ProjectSystem/ProjectSystem/Scene.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
using System.Collections.Immutable;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Collections.Specialized;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Beutl.Collections;
using Beutl.Configuration;
using Beutl.Engine;
using Beutl.Language;
using Beutl.Media;
using Beutl.Serialization;
Expand Down Expand Up @@ -43,6 +48,10 @@ public enum ElementOverlapHandling

public class Scene : ProjectItem, INotifyEdited
{
private static readonly Guid s_recoveredElementNamespace = new("dfad2f76-1d04-5593-ae3b-f371fb1f42ee");
private static readonly Regex s_idPattern = new(
"\"Id\"\\s*:\\s*\"(?<id>[0-9a-fA-F-]{36})\"",
RegexOptions.CultureInvariant);
Comment thread
yuto-trd marked this conversation as resolved.
public static readonly CoreProperty<PixelSize> FrameSizeProperty;
public static readonly CoreProperty<Elements> ChildrenProperty;
public static readonly CoreProperty<TimeSpan> StartProperty;
Expand All @@ -52,6 +61,8 @@ public class Scene : ProjectItem, INotifyEdited
public static readonly CoreProperty<CoreList<SceneMarker>> MarkersProperty;
private readonly List<string> _includeElements = ["**/*.belm"];
private readonly List<string> _excludeElements = [];
private readonly ConcurrentDictionary<Element, RecoveredElementSource> _recoveredElements
= new(ReferenceEqualityComparer.Instance);
private readonly Elements _children;
private readonly HierarchicalList<TimelineLayer> _layers;
private readonly HierarchicalList<SceneMarker> _markers;
Expand Down Expand Up @@ -544,7 +555,10 @@ static void Process(JsonObject jobject, string jsonName, List<string> list)
{
foreach (Element item in Children)
{
CoreSerializer.StoreToUri(item, item.Uri!);
if (!_recoveredElements.ContainsKey(item))
{
CoreSerializer.StoreToUri(item, item.Uri!);
Comment thread
yuto-trd marked this conversation as resolved.
Outdated
}
}
}

Expand Down Expand Up @@ -670,13 +684,185 @@ private void SyncronizeFiles(IEnumerable<string> pathToElement)
Children.Remove(item);
}

Children.AddRange(urisAdd.AsParallel().Select(CoreSerializer.RestoreFromUri<Element>));
Children.AddRange(urisAdd.AsParallel().Select(RestoreElementOrFallback));

activity?.SetTag("addCount", urisAdd.Length);
activity?.SetTag("removeCount", elementsRemove.Length);
activity?.SetTag("childrenCount", Children.Count);
}

private Element RestoreElementOrFallback(Uri uri)
{
string rawText = File.ReadAllText(uri.LocalPath);
try
{
Element element = CoreSerializer.RestoreFromUri<Element>(uri);
Comment thread
yuto-trd marked this conversation as resolved.
Outdated
Comment thread
yuto-trd marked this conversation as resolved.
IFallback[] fallbacks = element.EnumerateAllChildren<IFallback>().ToArray();
if (fallbacks.Length > 0)
Comment thread
yuto-trd marked this conversation as resolved.
Outdated
{
foreach (IFallback fallback in fallbacks)
{
EnsureFallbackProjection(fallback);
Comment thread
yuto-trd marked this conversation as resolved.
}

MarkRecoveredElement(element, rawText);
}

return element;
}
catch (JsonException ex)
Comment thread
yuto-trd marked this conversation as resolved.
Outdated
{
var element = new Element
{
Id = ResolveRecoveredElementId(rawText, uri),
Name = Path.GetFileNameWithoutExtension(uri.LocalPath),
Uri = uri,
IsEnabled = false,
};
var fallback = new FallbackEngineObject
{
Name = "Unreadable element data",
Reason = FallbackReason.DeserializationFailed,
Comment thread
yuto-trd marked this conversation as resolved.
Outdated
ErrorMessage = $"{ex.GetType().Name}: {ex.Message}",
};
fallback.Json = CreateFallbackProjection(fallback);
element.AddObject(fallback);
MarkRecoveredElement(element, rawText);
return element;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private void MarkRecoveredElement(Element element, string rawText)
{
element.IsStorageWriteSuppressed = true;
_recoveredElements[element] = new RecoveredElementSource(rawText);
}

private static void EnsureFallbackProjection(IFallback fallback)
{
if (fallback is not CoreObject coreObject)
{
return;
}

JsonObject json = fallback.Json ?? new JsonObject();
json.WriteDiscriminator(coreObject.GetType());
Comment thread
yuto-trd marked this conversation as resolved.
Outdated
json[nameof(CoreObject.Id)] = coreObject.Id.ToString();
fallback.Json = json;
}

private static JsonObject CreateFallbackProjection(FallbackEngineObject fallback)
{
var json = new JsonObject
{
[nameof(CoreObject.Id)] = fallback.Id.ToString(),
[nameof(CoreObject.Name)] = fallback.Name,
};
json.WriteDiscriminator(typeof(FallbackEngineObject));
Comment thread
yuto-trd marked this conversation as resolved.
Outdated
return json;
}

private Guid ResolveRecoveredElementId(string rawText, Uri uri)
{
MatchCollection matches = s_idPattern.Matches(rawText);
Match? topLevelMatch = FindTopLevelIdMatch(rawText, matches);
if (topLevelMatch != null
&& Guid.TryParse(topLevelMatch.Groups["id"].Value, out Guid topLevelId))
{
return topLevelId;
}
Comment thread
yuto-trd marked this conversation as resolved.

if (matches.Count > 0
&& Guid.TryParse(matches[0].Groups["id"].Value, out Guid firstId))
{
return firstId;
}
Comment thread
yuto-trd marked this conversation as resolved.
Outdated

Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
string sceneDirectory = Path.GetDirectoryName(Uri!.LocalPath)!;
string relativePath = Path.GetRelativePath(sceneDirectory, uri.LocalPath);
return CreateVersion5Guid(s_recoveredElementNamespace, relativePath);
Comment thread
yuto-trd marked this conversation as resolved.
Comment thread
yuto-trd marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

private static Match? FindTopLevelIdMatch(string rawText, MatchCollection matches)
{
int matchIndex = 0;
int objectDepth = 0;
bool inString = false;
bool escaped = false;

for (int i = 0; i < rawText.Length && matchIndex < matches.Count; i++)
{
Match match = matches[matchIndex];
if (i == match.Index)
{
if (!inString && objectDepth == 1)
{
return match;
Comment thread
yuto-trd marked this conversation as resolved.
}

matchIndex++;
}

char current = rawText[i];
if (inString)
{
if (escaped)
{
escaped = false;
}
else if (current == '\\')
{
escaped = true;
}
else if (current == '"')
{
inString = false;
}
}
else if (current == '"')
{
inString = true;
}
else if (current == '{')
{
objectDepth++;
}
else if (current == '}' && objectDepth > 0)
Comment thread
yuto-trd marked this conversation as resolved.
{
objectDepth--;
}
}

return null;
}

private static Guid CreateVersion5Guid(Guid namespaceId, string name)
{
byte[] namespaceBytes = namespaceId.ToByteArray();
SwapGuidByteOrder(namespaceBytes);
byte[] nameBytes = Encoding.UTF8.GetBytes(name);
byte[] source = new byte[namespaceBytes.Length + nameBytes.Length];
namespaceBytes.CopyTo(source, 0);
nameBytes.CopyTo(source, namespaceBytes.Length);

byte[] hash = SHA1.HashData(source);
hash[6] = (byte)((hash[6] & 0x0f) | 0x50);
hash[8] = (byte)((hash[8] & 0x3f) | 0x80);
Array.Resize(ref hash, 16);
SwapGuidByteOrder(hash);
return new Guid(hash);
}

private static void SwapGuidByteOrder(Span<byte> bytes)
{
(bytes[0], bytes[3]) = (bytes[3], bytes[0]);
(bytes[1], bytes[2]) = (bytes[2], bytes[1]);
(bytes[4], bytes[5]) = (bytes[5], bytes[4]);
(bytes[6], bytes[7]) = (bytes[7], bytes[6]);
}

private sealed record RecoveredElementSource(string RawText);

private void UpdateInclude()
{
string dirPath = Path.GetDirectoryName(Uri!.LocalPath)!;
Expand Down
Loading
Loading