Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
25 changes: 24 additions & 1 deletion src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,33 @@ 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);
if (Path.IsPathRooted(relativePath)
|| relativePath.StartsWith("..", StringComparison.Ordinal))
Comment thread
yuto-trd marked this conversation as resolved.
Outdated
{
relativePath = Path.GetFileName(previousUri.LocalPath);
}

element.Uri = new Uri(Path.Combine(sceneDirectory, relativePath));
}
else
{
element.Uri = null;
}
}

index++;
Expand Down
87 changes: 84 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,84 @@ 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
? 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);
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
4 changes: 4 additions & 0 deletions src/Beutl.Core/CoreObject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ public string Name

public Uri? Uri { get; set; }

// Non-null while this object stands in for a file the serializer must not regenerate:
// StoreToUri skips the source location and copies the raw text verbatim to any new one.
internal SuppressedStorageSource? SuppressedStorageSource { get; set; }

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

private Dictionary<int, string> Errors => _errors ??= [];
Expand Down
55 changes: 54 additions & 1 deletion src/Beutl.Core/Serialization/CoreSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ public static object DeserializeFromJsonObject(JsonObject json, Type baseType, C

try
{
if (!baseType.IsAssignableFrom(actualType))
{
throw new InvalidCastException(
$"Discriminator type '{actualType}' is not assignable to the expected type '{baseType}'.");
}

Comment thread
yuto-trd marked this conversation as resolved.
var obj = Activator.CreateInstance(actualType) as ICoreSerializable
?? throw new InvalidOperationException($"Could not create instance of type {actualType.FullName}.");

Expand All @@ -83,6 +89,7 @@ public static object DeserializeFromJsonObject(JsonObject json, Type baseType, C
if (obj is IFallback fallbackObj)
{
fallbackObj.Reason = FallbackReason.TypeNotFound;
DeserializationIncidents.RecordFallback();
}

return obj;
Expand Down Expand Up @@ -158,7 +165,10 @@ public static object RestoreFromUri(Uri uri, Type type)
// 互換性処理
// 1.x で作成されたファイルでは一部のオブジェクトに $type が付与されないため、
// 期待される型に基づいてディスクリミネータを補完する。
if (!node.TryGetDiscriminator(out Type? _))
// Presence is checked on the property key alone: a present-but-unparsable or non-string
// discriminator must fail as an unknown type, not silently deserialize as the legacy
// default and overwrite the original data on the next save.
if (!jsonObject.ContainsKey("$type") && !jsonObject.ContainsKey("@type"))
{
if (type == typeof(ProjectItem))
{
Expand All @@ -176,6 +186,14 @@ public static object RestoreFromUri(Uri uri, Type type)
throw new InvalidOperationException("Discriminator not found in JSON object.");
}
Comment thread
yuto-trd marked this conversation as resolved.

if (!type.IsAssignableFrom(actualType))
{
// Reject before instantiating: deserializing the declared type first would run its own
// load side effects (e.g. a Scene declared in a .belm globs and reopens element files).
throw new InvalidCastException(
$"Discriminator type '{actualType}' is not assignable to the expected type '{type}'.");
Comment thread
yuto-trd marked this conversation as resolved.
Comment thread
yuto-trd marked this conversation as resolved.
}

try
{
var obj = Activator.CreateInstance(actualType) as ICoreSerializable
Expand All @@ -192,6 +210,7 @@ public static object RestoreFromUri(Uri uri, Type type)
if (obj is IFallback fallbackObj)
{
fallbackObj.Reason = FallbackReason.TypeNotFound;
DeserializationIncidents.RecordFallback();
}

return obj;
Expand Down Expand Up @@ -227,6 +246,40 @@ 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 { SuppressedStorageSource: { } suppressed } suppressedObj)
{
if (uri == suppressed.SourceUri || uri.Scheme != "file")
{
return;
Comment thread
yuto-trd marked this conversation as resolved.
Outdated
Comment thread
yuto-trd marked this conversation as resolved.
Outdated
}

// Rehomed (save-as): the retained bytes move verbatim so the new project copy keeps the
// element. The suppression record is never mutated — the source location stays
// skip-protected even if a failed multi-file save rolls Uri back afterwards.
Comment thread
yuto-trd marked this conversation as resolved.
Outdated
string rehomedPath = uri.LocalPath;
string? rehomedDirectory = Path.GetDirectoryName(rehomedPath);
if (rehomedDirectory != null)
{
Directory.CreateDirectory(rehomedDirectory);
}

try
{
using var stream = new FileStream(rehomedPath, FileMode.CreateNew, FileAccess.Write);
stream.Write(suppressed.RawBytes);
}
catch (IOException) when (File.Exists(rehomedPath))
Comment thread
yuto-trd marked this conversation as resolved.
Outdated
{
// An existing file may hold a manual repair of the recovered content (or an earlier
// verbatim copy); never overwrite it.
suppressedObj.Uri = uri;
return;
}

suppressedObj.Uri = uri;
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (uri.Scheme == "file")
{
if (obj is CoreObject coreObj)
Expand Down
16 changes: 16 additions & 0 deletions src/Beutl.Core/Serialization/DeserializationIncidents.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Beutl.Serialization;

/// <summary>
/// Thread-local tally of fallback substitutions, letting a caller detect fallbacks created in
/// positions a hierarchical traversal of the deserialized result cannot reach (e.g. plain
/// property values such as keyframe values).
/// </summary>
internal static class DeserializationIncidents
{
[ThreadStatic]
private static int t_fallbackCount;

internal static int FallbackCount => t_fallbackCount;

internal static void RecordFallback() => t_fallbackCount++;
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ internal static class FallbackDeserializationHelper
fallback.Reason = FallbackReason.DeserializationFailed;
fallback.ErrorMessage = exception?.Message;

DeserializationIncidents.RecordFallback();
return fallback;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ private static bool TryDeserializeCoreSerializable(

try
{
if (!baseType.IsAssignableFrom(actualType))
{
throw new InvalidCastException(
$"Discriminator type '{actualType}' is not assignable to the expected type '{baseType}'.");
}

var instance = Activator.CreateInstance(actualType) as ICoreSerializable
?? throw new InvalidOperationException(
$"Could not create instance of type {actualType.FullName}.");
Expand All @@ -110,6 +116,7 @@ private static bool TryDeserializeCoreSerializable(
if (instance is IFallback fallbackObj)
{
fallbackObj.Reason = FallbackReason.TypeNotFound;
DeserializationIncidents.RecordFallback();
}

result = instance;
Expand Down
8 changes: 8 additions & 0 deletions src/Beutl.Core/Serialization/SuppressedStorageSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Beutl;

/// <summary>
/// The retained on-disk bytes of an object the serializer must not regenerate, together with the
/// location those bytes came from. The source location is never rewritten; any other location
/// receives a verbatim copy.
/// </summary>
internal sealed record SuppressedStorageSource(byte[] RawBytes, Uri SourceUri);
21 changes: 19 additions & 2 deletions src/Beutl.Core/TypeFormat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,20 @@ internal static class TypeFormat
public static Type? ToType(string fullName)
{
fullName = fullName.Replace("Beutl.Embedding.FFmpeg", "Beutl.Extensions.FFmpeg");
List<Token> tokens = new TypeNameTokenizer(fullName).Tokenize();
return new TypeNameParser(tokens).Parse();
try
{
List<Token> tokens = new TypeNameTokenizer(fullName).Tokenize();
return new TypeNameParser(tokens).Parse();
}
// The tokenizer/parser index freely and throw on ill-formed names (e.g. "x" from a
// hand-edited file); every caller already treats null as "unknown type".
catch (Exception ex) when (ex is IndexOutOfRangeException
or ArgumentOutOfRangeException
or ArgumentException
or InvalidOperationException)
{
return null;
Comment thread
yuto-trd marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

public static string ToString(Type type)
Expand Down Expand Up @@ -324,6 +336,11 @@ private static string TakeTypeNameTokens(Span<Token> tokens, out Span<Token> gen
type = _assembly?.GetType($"{_namespace ?? ""}.{typeName}{suffix}")!;
}

if (type == null)
{
return null;
}

if (genericArgs.Length > 0)
{
type = type.MakeGenericType(genericArgs);
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.SuppressedStorageSource is null && obj.Uri!.Scheme == "file")
Comment thread
yuto-trd marked this conversation as resolved.
{
var path = obj.Uri.LocalPath;
if (File.Exists(path))
Expand Down
Loading