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 sets needs_resync. From then on, solve(None, ..), apply and level return ProjectError::NeedsResync, and reads that need a baseline (preview, load_profile, evaluate, suggest_assignments) return ProjectError::NoBaseline. To clear it, reload the full data with solve(Some(data), ..). See Data Updates for why.

Prerequisites#

Steps#

  1. Apply the change set and keep the ApplyReport:

    let report = project.apply(&changes)?;
    

    An Err here means nothing was applied (NeedsResync from an earlier rejection, or StaleBaseline). A rejection inside the batch isn't an Err.

  2. Check needs_resync. If it's false, continue as usual with solve(None, ..):

    if !report.needs_resync {
        project.solve(None, &SolveOptions::default())?;
    }
    
  3. Show the user what was refused. Each RejectedOperation has the operation's path (such as tasks.remove[0]) and a message:

    for op in &report.rejected {
        eprintln!("rejected {}: {}", op.path, op.message);
    }
    

    The other operations in the batch were applied. A rejected operation leaves no partial edit behind.

  4. Make your model hold the state you want. Undo the rejected edits in it, or keep it as is if it's already correct. Don't try to repair the project with more apply calls: they fail with NeedsResync.

  5. Resync with the full data. Set include_unchanged so the result carries the dates of every task, not only the ones that moved:

    let resync = SolveOptions {
        include_unchanged: true,
        ..Default::default()
    };
    let result = project.solve(Some(model.clone()), &resync)?;
    
  6. Write every entry of result.updates back into your UI. The engine's state may differ from what the UI shows, so refresh all task dates, not just the edited ones.

  7. Take the new baseline_id from result for the next preview or change set. Tokens 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:

use plen_core::{
    DateTime, Error, Patch, Project, ProjectData, SchedulingChanges, SolveOptions, TaskData,
    TaskUpdate,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let jan5 = DateTime::from_ymd_hm(2026, 1, 5, 0, 0).ok_or("invalid date")?;
    let mut design = TaskData::new("design");
    design.start_date = Some(jan5);
    design.duration = Some(2.0);

    // The editor's own model: the data you resync from.
    let mut model = ProjectData {
        tasks: vec![design],
        ..Default::default()
    };
    // 24/7 time keeps the example dates easy to check.
    model.config.use_calendars = false;

    let mut project = Project::default();
    project.solve(Some(model.clone()), &SolveOptions::default())?;

    // 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 = Some(4.0);
    let mut longer = TaskUpdate::new("design");
    longer.duration = Patch::Value(4.0);
    let mut changes = SchedulingChanges::default();
    changes.tasks.update.push(longer);
    changes.tasks.remove.push("review".into());

    let report = project.apply(&changes)?;
    if !report.needs_resync {
        project.solve(None, &SolveOptions::default())?;
        return Ok(());
    }

    for op in &report.rejected {
        eprintln!("rejected {}: {}", op.path, op.message);
    }

    let resync = SolveOptions {
        include_unchanged: true,
        ..Default::default()
    };
    match project.solve(Some(model.clone()), &resync) {
        Ok(result) => {
            for m in &result.updates {
                if let (Some(start), Some(end)) = (m.start_date, m.end_date) {
                    println!("{} {}..{}", m.id, start, end);
                }
            }
        }
        Err(Error::Input(e)) => {
            // The project keeps its state and still needs a resync.
            eprintln!("resync data invalid at {:?}: {}", e.path, e.message);
        }
        Err(e) => return Err(e.into()),
    }
    Ok(())
}

When the resync data is invalid#

The resync goes through the same validation as the first load. If your model produces invalid data, such as a duplicate task id, solve(Some(data), ..) returns Error::Input with the field path, for example tasks[1].id. The project keeps its previous state and still returns NeedsResync. Fix the data and call solve(Some(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:

let mut draft = project.clone();
let report = draft.apply(&changes)?;
if report.needs_resync {
    // Drop `draft`. `project` still has its baseline; roll the edit back in your model too.
} else {
    draft.solve(None, &SolveOptions::default())?;
    project = draft;
}

Use this when the user should either get 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(None, ..), level and preview work again, and report.needs_resync is false for the next valid change set.