Recover from rejected changes#
Get an editor working again after Apply rejects part of a change set, without restarting the app or losing the user's edits.
When Apply rejects an operation, the project needs a resync. From then on, Solve(null, …), Apply and Level throw a ProjectException with Code = ProjectError.NeedsResync. Calls that need a baseline (Preview, LoadProfile, Evaluate, SuggestAssignments) throw ProjectError.NoBaseline. To clear the resync state, reload the full data with Solve(data, …). See Data Updates for why.
Prerequisites#
- A long-lived
Projectsolved once withSolve(data, …). - Your own model that can produce the full
ProjectDatathe user expects to see. You resync from this data.
Steps#
-
Apply the change set and keep the
ApplyReport:var report = project.Apply(changes);If
Applythrows, nothing was applied (NeedsResyncfrom an earlier rejection, orStaleBaseline). A rejection inside the batch doesn't throw. -
Check
NeedsResync. If it'sfalse, continue as usual withSolve(null, …):if (!report.NeedsResync) { project.Solve(null, new SolveOptions()); } -
Show the user what was refused. Each
RejectedOperationhas the operation'sPath(such astasks.remove[0]) and aMessage:foreach (var op in report.Rejected) { Console.Error.WriteLine($"rejected {op.Path}: {op.Message}"); }The engine applies the other operations in the batch. A rejected operation leaves no partial edit behind.
-
Make your model hold the state you want. Undo the rejected edits in it, or keep it as it is if it's already correct. Don't try to repair the project with more
Applycalls: they throwNeedsResync. -
Resync with the full data. Set
IncludeUnchangedso the result carries the dates of every task, not only the ones that moved:var result = project.Solve(model, new SolveOptions { IncludeUnchanged = true });Solvecopies the data, so you can keep editingmodelafterwards. -
Write every entry of
result.Updatesback into your UI. The engine's state can differ from what the UI shows, so refresh all task dates, not just the edited ones. -
Take the new
BaselineIdfromresultfor the nextPreviewor change set. Baseline ids from before the resync are stale.
The full example below stretches one task and, in the same batch, removes a task the engine doesn't have:
using Plen;
var jan5 = Moment.FromYmdHm(2026, 1, 5, 0, 0) ?? throw new InvalidOperationException("invalid date");
// The editor's own model: the data you resync from.
var model = new ProjectData
{
Tasks = [new TaskData("design") { StartDate = jan5, Duration = 2.0 }],
};
// 24/7 time keeps the example dates easy to check.
model.Config.UseCalendars = false;
var project = new Project();
project.Solve(model, new SolveOptions());
// The user stretches "design" to 4 days. The UI also sends a removal
// of "review", a task the engine never had.
model.Tasks[0].Duration = 4.0;
var changes = new SchedulingChanges();
changes.Tasks.Update.Add(new TaskUpdate("design") { Duration = 4.0 });
changes.Tasks.Remove.Add("review");
var report = project.Apply(changes);
if (!report.NeedsResync)
{
project.Solve(null, new SolveOptions());
return;
}
foreach (var op in report.Rejected)
{
Console.Error.WriteLine($"rejected {op.Path}: {op.Message}");
}
try
{
var result = project.Solve(model, new SolveOptions { IncludeUnchanged = true });
foreach (var m in result.Updates)
{
Console.WriteLine($"{m.Id} {m.StartDate}..{m.EndDate}");
}
}
catch (InputException e)
{
// The project keeps its state and still needs a resync.
Console.Error.WriteLine($"resync data invalid at {e.Path}: {e.Message}");
}
When the resync data is invalid#
The resync runs the same validation as the first load. If your model produces invalid data, such as a duplicate task id, Solve(data, …) throws an InputException with the field path in Path, for example tasks[1].id. The project keeps its previous state and still throws NeedsResync. Fix the data and call Solve(data, …) again. See Error handling for the full list of validation errors.
Drop the edit instead of resyncing#
To avoid a full reload, apply the change set to a clone first. A clone is cheap, and a rejection there doesn't touch the original:
var draft = project.Clone();
var report = draft.Apply(changes);
if (report.NeedsResync)
{
// Drop `draft`. `project` still has its baseline; roll the edit back in your model too.
}
else
{
draft.Solve(null, new SolveOptions());
project = draft;
}
Use this when the user should get either the whole change set or none of it.
Result#
The example prints the rejected removal on stderr and the refreshed date of every task on stdout:
rejected tasks.remove[0]: unknown task id
design 2026-01-05 00:00..2026-01-09 00:00
After the resync, Apply, Solve(null, …), Level and Preview work again, and report.NeedsResync is false for the next valid change set.