Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
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
198 changes: 108 additions & 90 deletions src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -681,21 +681,16 @@ private static CoreObject CloneCurrentRoot(IEditingSession session, JsonObject c
private static HashSet<Guid> CollectFallbackIds(CoreObject root)
{
var ids = new HashSet<Guid>();
if (root is IHierarchical hierarchical)
var visited = new HashSet<object>(ReferenceEqualityComparer.Instance);
TraverseSerializedGraph(root, "$", visited, (node, _) =>
{
foreach (IFallback fallback in hierarchical.EnumerateAllChildren<IFallback>())
if (node is IFallback)
{
if (fallback is CoreObject coreObject)
{
ids.Add(coreObject.Id);
}
ids.Add(node.Id);
}
}

if (root is IFallback rootFallback)
{
ids.Add(((CoreObject)rootFallback).Id);
}
return false;
});

return ids;
}
Expand All @@ -705,112 +700,135 @@ private static HashSet<Guid> CollectFallbackIds(CoreObject root)
string path,
HashSet<Guid> existingFallbackIds)
{
var visited = new HashSet<Guid>();
return FindFirstNewFallbackCore(root, path, existingFallbackIds, visited);
FallbackOccurrence? result = null;
var visited = new HashSet<object>(ReferenceEqualityComparer.Instance);
TraverseSerializedGraph(root, path, visited, (node, nodePath) =>
{
if (node is not IFallback fallback || existingFallbackIds.Contains(node.Id))
{
return false;
}

fallback.TryGetTypeName(out string? fallbackTypeName);
result = new FallbackOccurrence(
nodePath,
node.Id,
fallbackTypeName,
fallback.Reason.ToString(),
fallback.ErrorMessage);
return true;
});
return result;
}

private static FallbackOccurrence? FindFirstNewFallbackCore(
CoreObject node,
private static bool TraverseSerializedGraph(
object? value,
string path,
HashSet<Guid> existingFallbackIds,
HashSet<Guid> visited)
HashSet<object> visited,
Func<CoreObject, string, bool> visitCoreObject)
{
if (!visited.Add(node.Id))
if (value is null or string)
{
return null;
return false;
}

if (node is IFallback fallback && !existingFallbackIds.Contains(node.Id))
if (!value.GetType().IsValueType && !visited.Add(value))
{
fallback.TryGetTypeName(out string? fallbackTypeName);
return new FallbackOccurrence(
path,
node.Id,
fallbackTypeName,
fallback.Reason.ToString(),
fallback.ErrorMessage);
return false;
}

switch (node)
if (value is CoreObject coreObject)
Comment thread
yuto-trd marked this conversation as resolved.
Outdated
{
case Scene scene:
for (int i = 0; i < scene.Children.Count; i++)
{
if (FindFirstNewFallbackCore(
scene.Children[i],
$"{path}/Elements[{i}]",
existingFallbackIds,
visited) is { } occurrence)
{
return occurrence;
}
}
break;
if (visitCoreObject(coreObject, path))
{
return true;
}

case Element element:
for (int i = 0; i < element.Objects.Count; i++)
{
if (FindFirstNewFallbackCore(
element.Objects[i],
$"{path}/Objects[{i}]",
existingFallbackIds,
visited) is { } occurrence)
switch (coreObject)
{
case Scene scene:
for (int i = 0; i < scene.Children.Count; i++)
{
return occurrence;
if (TraverseSerializedGraph(
scene.Children[i],
$"{path}/Elements[{i}]",
visited,
visitCoreObject))
{
return true;
}
}
}
break;
break;

case EngineObject engineObject:
foreach (IProperty property in engineObject.Properties)
{
if (FindFirstNewFallbackInValue(
property.CurrentValue,
$"{path}/{property.Name}",
existingFallbackIds,
visited) is { } occurrence)
case Element element:
Comment thread
yuto-trd marked this conversation as resolved.
Outdated
for (int i = 0; i < element.Objects.Count; i++)
Comment thread
yuto-trd marked this conversation as resolved.
Outdated
{
return occurrence;
if (TraverseSerializedGraph(
element.Objects[i],
$"{path}/Objects[{i}]",
visited,
visitCoreObject))
{
return true;
}
}
}
break;
}

return null;
}
break;

private static FallbackOccurrence? FindFirstNewFallbackInValue(
object? value,
string path,
HashSet<Guid> existingFallbackIds,
HashSet<Guid> visited)
{
switch (value)
{
case CoreObject coreObject:
return FindFirstNewFallbackCore(coreObject, path, existingFallbackIds, visited);
case IEnumerable enumerable when value is not string:
{
int index = 0;
foreach (object? item in enumerable)
case EngineObject engineObject:
foreach (IProperty property in engineObject.Properties)
{
if (FindFirstNewFallbackInValue(
item,
$"{path}[{index}]",
existingFallbackIds,
visited) is { } occurrence)
if (TraverseSerializedGraph(
property.CurrentValue,
$"{path}/{property.Name}",
visited,
visitCoreObject))
{
return occurrence;
return true;
}

index++;
if (property.Animation is IKeyFrameAnimation animation)
{
int index = 0;
foreach (IKeyFrame keyFrame in animation.KeyFrames)
{
if (TraverseSerializedGraph(
keyFrame.Value,
$"{path}/Animations/{property.Name}/KeyFrames[{index}]/Value",
visited,
visitCoreObject))
{
return true;
}

index++;
}
}
}

break;
}

return false;
}

if (value is IEnumerable enumerable)
{
int index = 0;
foreach (object? item in enumerable)
Comment thread
yuto-trd marked this conversation as resolved.
Outdated
{
if (TraverseSerializedGraph(
item,
$"{path}[{index}]",
visited,
visitCoreObject))
{
return true;
}

index++;
}
}

return null;
return false;
}

private static string CreateFallbackHint(FallbackOccurrence occurrence)
Expand Down
28 changes: 27 additions & 1 deletion src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,36 @@ private void SetProjectPathCore(string projectPath)
Path.Combine(projectDirectory, projectName),
sceneName,
usedDirs);
string? previousSceneDirectory = scene.Uri is { IsFile: true } previousSceneUri
? Path.GetDirectoryName(previousSceneUri.LocalPath)
: null;
scene.Uri = new Uri(scenePath);
string sceneDirectory = Path.GetDirectoryName(scenePath)!;
foreach (Element element in scene.Children)
{
element.Uri = null;
// Keep each sidecar's relative path across Save As: a recovered element's stable
// fallback identity is derived from its scene-relative path, so a regenerated
// path would change the element's Id when the copy is reopened.
if (element.Uri is { IsFile: true } previousUri)
{
string relativePath = previousSceneDirectory != null
? Path.GetRelativePath(previousSceneDirectory, previousUri.LocalPath)
: Path.GetFileName(previousUri.LocalPath);
string sceneRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(sceneDirectory));
string resolvedPath = Path.GetFullPath(Path.Combine(sceneRoot, relativePath));
if (!resolvedPath.StartsWith(
sceneRoot + Path.DirectorySeparatorChar,
PathComparison.ForCurrentPlatform))
Comment thread
yuto-trd marked this conversation as resolved.
Outdated
{
resolvedPath = Path.Combine(sceneRoot, Path.GetFileName(previousUri.LocalPath));
Comment thread
yuto-trd marked this conversation as resolved.
}

element.Uri = new Uri(resolvedPath);
}
else
{
element.Uri = null;
}
}

index++;
Expand Down
90 changes: 87 additions & 3 deletions src/Beutl.AgentToolkit/Tools/SessionTools.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
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.Animation;
using Beutl.Editor;
using Beutl.Engine;
using Beutl.ProjectSystem;
using Beutl.Serialization;
using ModelContextProtocol.Server;

namespace Beutl.AgentToolkit.Tools;
Expand All @@ -15,7 +19,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 +78,87 @@ 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))
{
// The traversal walks the live graph, so it must run on the session thread — a live
// editor may mutate the collections concurrently.
Warnings = result.Session.ReadOnSession(() => 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
&& scene.Uri is { IsFile: true } sceneUri
? Path.GetRelativePath(
Path.GetDirectoryName(sceneUri.LocalPath)!,
uri.LocalPath).Replace('\\', '/')
: 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);
if (property.Animation is IKeyFrameAnimation animation)
{
foreach (IKeyFrame keyFrame in animation.KeyFrames)
{
CollectFallbacks(keyFrame.Value, visited, fallbacks);
}
}
}
}

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
Loading