Skip to content
Open
Show file tree
Hide file tree
Changes from 26 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
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ A `session` may be **file-opened** (headless server) or **live** (in-app host bo

### `open_project`
- **Input**: `{ "path": string }` (read — unrestricted).
- **Output**: `{ "session": string, "source": "File", "summary": { scenes, elements, duration, frameSize } }`.
- **Output**: `{ "session": string, "source": "File", "summary": { scenes, elements, duration, frameSize }, "warnings": string[], "recoveryIncidents": [{ "sceneId": string, "sceneName": string, "elementFile": string, "reason": "TypeNotFound" | "DeserializationFailed", "typeName": string | null, "message": string | null }] }`. `warnings` remains the presentation-oriented recovery summary; `recoveryIncidents` exposes the same incidents as stable structured data. `sceneId` and `sceneName` identify the containing scene when different scenes contain the same relative element path. `elementFile` is a forward-slash scene-relative path when available, otherwise the element name. `typeName` is the original serialized discriminator when available, otherwise `null`; `message` is the unmodified nullable deserialization error rather than a presentation fallback.
- **Errors**: `media_not_found`, `schema_version_mismatch` (project written by an incompatible schema — surfaced, not silently dropped, per FR-031/FR-013).

### `attach_active_editor` *(in-app host only)*
Expand Down
269 changes: 181 additions & 88 deletions src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ private ReconcileResult ApplyCore(IEditingSession session, JsonObject desired, I
{
JsonObject desiredDocument = PrepareDesired(session, desired);
ReconcilePlan plan = PlanPrepared(session, desiredDocument, knownNewIds);
Element[] affectedSuppressedElements = GetAffectedSuppressedElements(session.Root, plan);
session.History.ExecuteInTransaction(
() =>
{
Expand All @@ -338,6 +339,20 @@ private ReconcileResult ApplyCore(IEditingSession session, JsonObject desired, I
{
ProjectOperations.NormalizeSidecarUrisWithinProject(scene);
}

foreach (Element element in affectedSuppressedElements)
{
if (Scene.TryResumeElementPersistence(element) is { } suppression)
Comment thread
yuto-trd marked this conversation as resolved.
{
session.History.Record(
() => element.SuppressedStorageSource = null,
() =>
{
suppression.WasReinstated = true;
element.SuppressedStorageSource = suppression;
});
}
}
},
"Agent edit");

Expand All @@ -349,6 +364,31 @@ private ReconcileResult ApplyCore(IEditingSession session, JsonObject desired, I
return new ReconcileResult(plan, session.Documents.Read(session.Root));
}

private static Element[] GetAffectedSuppressedElements(CoreObject root, ReconcilePlan plan)
{
if (plan.Changes.Count == 0)
{
return [];
}

if (root is Element { SuppressedStorageSource: not null } element)
{
return [element];
}

if (root is not Scene scene)
{
return [];
}

return scene.Children
.Where(static child => child.SuppressedStorageSource is not null)
.Where(child => plan.Changes.Any(change =>
change.Path.StartsWith($"$/Elements[Id={child.Id}]", StringComparison.Ordinal)
|| string.Equals(change.TargetId, child.Id.ToString(), StringComparison.Ordinal)))
.ToArray();
}

// Build the plan on the editor's dispatcher: PlanPrepared reads session.Documents/Root, so off
// the MCP request thread it would race the live scene the editor mutates on the UI thread.
public ReconcilePlan PlanFromCurrent(
Expand Down Expand Up @@ -681,21 +721,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 +740,170 @@ 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)
if (visitCoreObject(coreObject, path))
{
return true;
}

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 Element element:
for (int i = 0; i < element.Objects.Count; i++)
{
if (FindFirstNewFallbackCore(
element.Objects[i],
$"{path}/Objects[{i}]",
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;
break;

case EngineObject engineObject:
foreach (IProperty property in engineObject.Properties)
{
if (TraverseSerializedGraph(
property.CurrentValue,
$"{path}/{property.Name}",
visited,
visitCoreObject))
{
return true;
}

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;
}

case EngineObject engineObject:
foreach (IProperty property in engineObject.Properties)
if (coreObject is IHierarchical hierarchical)
{
int index = 0;
foreach (IHierarchical child in hierarchical.HierarchicalChildren)
{
if (FindFirstNewFallbackInValue(
property.CurrentValue,
$"{path}/{property.Name}",
existingFallbackIds,
visited) is { } occurrence)
if (TraverseSerializedGraph(
child,
$"{path}/HierarchicalChildren[{index}]",
visited,
visitCoreObject))
{
return occurrence;
return true;
}

index++;
}
break;
}
}

return null;
}
return false;
}

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

index++;
}
return true;
}

break;
index++;
}
}
else if (value is IEnumerable enumerable)
{
int index = 0;
foreach (object? item in enumerable)
{
if (TraverseSerializedGraph(
item,
$"{path}[{index}]",
visited,
visitCoreObject))
{
return true;
}

index++;
}
}

return null;
return false;
}

private static string CreateFallbackHint(FallbackOccurrence occurrence)
Expand Down
Loading
Loading