Error handling#
This guide covers how the raw Planning Engine exports report failures. It explains which problems come back as an error envelope, which arrive as data inside a successful result, which few cases throw on the host side, and how to get a live project working again after each one.
How failures surface#
Every call that does work (schedule and the JsonProject methods except clone and free) returns JSON text, and that text is always an envelope:
A bad request doesn't make a call throw. It returns ok: false, so every call site parses the text and checks ok before it reads result. Every export is synchronous, so there's no separate async failure path. The browser build's init() is the one async step, and it's covered in Host-level throws.
The error.kind field tells you whether the input or the call order is at fault:
kind |
Meaning | code |
Project state after the error |
|---|---|---|---|
parse |
The JSON text doesn't decode: malformed JSON, a wrong type, a missing required field, or an unknown field where one isn't allowed | null |
Unchanged |
validation |
The JSON decodes, but a value is invalid | null |
Unchanged. The previous accepted state and its cached result are kept. |
project |
The input may be fine, but the call doesn't fit the project's current state | no-baseline, stale-baseline or needs-resync |
Unchanged. You fix it by calling the right method first. |
For a project error, message repeats code and path is null. The full field table is in Wire format.
Most of what goes wrong in a plan isn't an error at all. Dependency cycles, constraint conflicts, dropped links, a planner that didn't converge and rejected apply operations all come back with ok: true. An ok: false envelope means the call itself couldn't run.
Reading the envelope#
A small helper keeps the parsing in one place. It returns result and turns an error envelope into an exception with a readable message:
const = ;
const = ;
The same code works with the browser build (pkg/) once await init() has resolved. If your host only forwards the envelope, for example a server that returns it as the response body, you don't have to parse it at all.
Input errors#
An input error has kind parse or validation, a short message and a path:
parsecomes from the JSON decoder. Itspathis alwaysnull, and themessagenames the problem, for exampleunknown field `resolved`, expected one of `subtree`, `analytics`, `reasons`, `includeUnchanged`orinvalid type: string "bad", expected f64.validationcovers values the decoder or the engine rejects. Engine checks setpathto the offending field in the formtasks[0].duration,assignments[1].valueorconfig.schedule_from_end. Value checks done by the decoder itself (an invalid date, an unknown enum string, apriorityout of range, aweekduration unit) havepath: null.
With the default "validation": "strict", a solve with data or a schedule call rejects the input on the first problem it finds. Strict validation rejects, among other things:
- a leaf task with fewer than two of
start_date,end_dateandduration, or with all three when they don't agree - a dated constraint (
snet,fnlt, ...) without aconstraint_date - duplicate task, link or calendar ids (
1and"1"count as the same id) - an unknown
parent, or a cycle in theparenthierarchy - a summary with
start_date+duration(a summary takes both dates or neither) - a negative
durationorleveling_delay, and apriorityoutside 0..1000 - an assignment to an unknown task or resource, or with
value: 0 week,monthoryearduration units,customWeekscalendars, and calendars with no working time- an entirely undated project (no
project_startand no dated task)
The full list per field is on the reference pages for ProjectData, TaskData and AssignmentData.
A priority outside 0..1000 or a malformed hour range fails the whole call with a validation error that has no path. In apply, this means no operation from that change set is applied.
So a change set like this one applies nothing, not even the valid duration update in front of the bad one:
const = ;
const = new;
;
const = ;
;
// {"error":{"code":null,"kind":"validation","message":"priority must be in 0..1000","path":null},"ok":false}
;
A rejected replacement keeps the old state#
On a live JsonProject, a solve with data that fails validation installs nothing. The project keeps its accepted baseline, and solve without data still returns the result you had before:
const = ;
const ;
const = new;
const = ;
const = ;
; // { kind: "validation", path: "tasks[0].duration", ... }
// Still the first data set: the cached result comes back.
const = ;
; // true
;
Project errors#
A project error means the call came at the wrong moment. Each code has one recovery:
code |
Returned by | Recovery |
|---|---|---|
no-baseline |
preview, loadProfile, evaluate, slots and suggestAssignments on a project with no solved baseline, including right after apply |
Call solve first. After apply, call solve(undefined, ...) before any of these. |
stale-baseline |
Any call given a baselineId (in its options or in SchedulingChanges) that doesn't name the current baseline |
The project was solved, edited or leveled since you read the token. Take baselineId from the latest result and retry. |
needs-resync |
solve without data, apply and level after an apply rejected an operation |
Send the full data again with solve(dataJson, ...). Nothing else is accepted until that call succeeds. |
Telling the codes apart#
Branch on code for project errors and fall back to kind and path for everything else:
const = ;
const = new;
// An unsolved project: prints "solve the project first".
;
;
preview checks both options.baselineId and changes.baselineId. Baseline tokens are opaque and can differ between runs for the same data, so never store them across processes or compare them in tests.
Recovering from needs-resync#
A rejected operation in apply isn't an error envelope. The call returns ok: true, lists the operation in rejected and sets needsResync. From then on, solve without data, apply and level fail with needs-resync, because your model and the project's model no longer agree. Resync with the full data you hold:
const = ;
const = ;
const = new;
;
// Task 999 doesn't exist: the operation is rejected.
const = ;
const = ; // logs "rejected tasks.update[0]: unknown task id"
;
;
If the resync data itself fails validation, the project stays in the needs-resync state. Fix the data and call solve with it again. The recovery how-to covers this flow in an editor.
Results that are not errors#
These all come back with ok: true. Check them on every result you show to a user:
| Where | What it means |
|---|---|
cycles |
Dependency cycles. A cyclic solve returns unchanged dates and empty updates, and still installs a baseline. |
conflicts |
constraint-violation (a link and a hard constraint disagree, the constraint wins) and calendar-non-working |
notes |
Input the engine dropped (dropped-link, dropped-task, dropped-assignment) or fixed (repaired) |
converged |
false when the planner didn't reach a fixed point within config.max_iterations. The dates are still valid output. |
rejected in the apply report |
Operations that apply refused. The other operations in the change set were still applied. |
candidates[i].error in the evaluate result |
A candidate whose change set was rejected, as {path, message}. That candidate has no metrics; the other candidates are scored normally. |
unfilled and invalid in the suggestAssignments result |
Targeted slots left empty, and slots whose final resource is outside allowed |
Checking a schedule for problems#
const = ;
const = ;
const = ;
throw new;
const = envelope.;
;
The cycle comes back as data:
Dependency cycles are accepted state. Cycles in the parent hierarchy are different: they're invalid input and fail with a validation error.
Repair instead of reject#
Strict validation suits data your own code produces. For data from a UI or an older store, create the project with {"validation": "repair"}. It coerces invalid data instead of failing:
- duplicate ids: the last record wins, at the first record's position
- missing dates or durations are derived
- in an inconsistent start/end/duration triple the dates win
- dated constraints without a date are cleared
- invalid
pauseslists and assignment slice values are cleared
Each fix shows up as a repaired note with path and message, so you can show the user what changed. Repair mode still rejects an entirely undated project, and other problems still return a validation error. Repair is a project option, so it isn't available through the one-shot schedule, which always validates strictly.
const = ;
const = ;
const = new;
const = ;
// repaired tasks[1].id: duplicate id replaced the earlier record, retaining its position
;
The messy input how-to shows the repair flow end to end.
Errors from slots#
The skill layer has no error type of its own on the wire. slots returns the same envelope as every other call:
- A bad pool or requirement is a
validationerror with a path into the arguments:pool.workers[0].resource,requirements[2].count,requirements[1].assignments[0]. - A project with no clean solved baseline returns
project/no-baseline. Callsolvebeforeslots, including afterapply. - A malformed pool or requirement (a missing
matches, an unknown field) is aparseerror withpath: null.
Because slots and suggestAssignments share one envelope format, one handler covers both:
const = ;
const = ;
const = ;
// An unsolved project: slots fails with no-baseline.
const = new;
const = ;
;
Host-level throws#
A few failures happen outside the envelope and reach your host as a thrown value. They come from the boundary itself, not from the data:
| Failure | What you get |
|---|---|
new JsonProject(optionsJson) with bad options |
A thrown string: the error envelope text, not an Error |
Any method on a handle after free() |
A thrown Error from the bindings (null pointer passed to rust) |
init() can't fetch or instantiate the module (browser build) |
The promise rejects. Calling exports before init() resolves fails too. |
| An internal engine failure | A WebAssembly.RuntimeError (a wasm trap) |
The constructor is the only export that throws for bad input. Catch the string and parse it like any other envelope:
const = ;
To avoid use after free(), free each handle exactly once, or let using do it through [Symbol.dispose]. Each clone() is a separate handle. Module and handles covers handle lifetime and instantiation.
In the browser build, await init() before the first new JsonProject or schedule call, and handle its rejection:
;
Engine failures#
Valid input doesn't normally crash the engine. There's one known exception: in block mode (move_projects: true), chained summary→summary links combined with leaf links that cross the same summaries push dates further with every group. On large chains (about 50 groups, 200 tasks) the date arithmetic overflows and surfaces as a trap.
An internal engine failure surfaces as a WebAssembly.RuntimeError; after one, create the project again (or reload the module) before continuing.
const = ;
A trap isn't an envelope, so a host that forwards envelope text unparsed still needs a try/catch around the call.