Reading results#

Every scheduling call returns a ScheduleResult inside the success envelope. This guide shows how to write the new dates back into your own model, explain why tasks moved, and handle constraint conflicts, dependency cycles and dropped records. It also covers the baseline token and how to pass results on without rebuilding them.

How a result is organized#

ScheduleResult is the result payload of schedule, solve, preview and analyze. level returns the same fields flattened into its result, next to rounds. The fields fall into four groups:

Group Fields What it tells you
Dates updates The tasks that changed, with their new fields
Problems conflicts, cycles, notes, converged Rules the plan breaks, loops in the links, records left out, whether the planner settled
Extras analytics, overallocations, profile Critical path and slack, resource overload, load profile (each only when requested or relevant)
Identity baselineId Which accepted baseline the result belongs to

A few rules hold for every result:

  • Updates are sparse. updates lists only the tasks whose dates changed, and each entry carries only the fields that changed. A task that didn't move isn't there. Set includeUnchanged: true in the solve options when you need every task's dates, for example on the first load.
  • Problems are data, not errors. A plan with conflicts, cycles or dropped links still comes back with ok: true. Only invalid input (parse, validation) and calls that don't fit the project's state (project) return an error envelope. See Error handling.
  • Order is stable. The same input and options always produce the same result, in the same order. The one exception is baselineId, which is an opaque token.
  • Updates use the input names. An update entry uses the snake_case task-data names: start_date, end_date, duration, leveling_delay, pauses. The other result fields are camelCase (baselineId, criticalPath). The wire format explains why.
  • Absent means absent. Optional fields are left out when they don't apply. They're never null.

Updates#

Read updates after each call and copy the changed fields into your model. Check whether a field is present, not whether it's truthy: a missing field means "unchanged", not "cleared".

const { schedule } = require("./pkg-node/plen_wasm.js");

const data = {
  tasks: [
    { id: 1, start_date: "2026-08-10 08:00", duration: 2 },
    { id: 2, start_date: "2026-08-10 08:00", duration: 1 },
  ],
  links: [{ id: 1, source: 1, target: 2, type: "fs" }],
};

// The caller's own copy of the dates, keyed by id.
const model = new Map([
  ["1", { start: "2026-08-10 08:00", end: "2026-08-12 08:00", duration: 2 }],
  ["2", { start: "2026-08-10 08:00", end: "2026-08-11 08:00", duration: 1 }],
]);

const response = JSON.parse(schedule(JSON.stringify(data), "{}"));
if (!response.ok) {
  throw new Error(`${response.error.kind}: ${response.error.message}`);
}
for (const update of response.result.updates) {
  console.log(
    `${update.id} (${update.kind}): start ${update.start_date}, end ${update.end_date}, duration ${update.duration}`,
  );
  const task = model.get(String(update.id));
  if (task) {
    if ("start_date" in update) task.start = update.start_date;
    if ("end_date" in update) task.end = update.end_date;
    if ("duration" in update) task.duration = update.duration;
  }
}

Output:

2 (task): start 2026-08-12 08:00, end 2026-08-13 08:00, duration undefined

Task 1 is missing because it didn't move. Task 2 moved, so it reports a new start and end. It has no duration because its working time didn't change.

The model is keyed by String(update.id) because ids are compared like JavaScript object keys: 1 and "1" name the same task.

kind is "task", "summary" or "milestone". Summaries appear in updates too, because their spans roll up from their children. leveling_delay and pauses only appear in results from level, and only when leveling changed them. Leveling resources covers how to write them back.

To get every task's dates instead of the changed ones, see Load all task dates.

Reasons#

Set reasons: true in the solve options to learn why each task ended up where it did. Each update then carries a reason object, tagged by kind:

const { schedule } = require("./pkg-node/plen_wasm.js");

const data = {
  tasks: [1, 2, 3].map((id) => ({ id, start_date: "2026-08-10 08:00", duration: 1 })),
  links: [
    { id: 10, source: 1, target: 2 },
    { id: 11, source: 2, target: 3 },
  ],
};

const response = JSON.parse(schedule(JSON.stringify(data), JSON.stringify({ reasons: true })));
if (!response.ok) {
  throw new Error(`${response.error.kind}: ${response.error.message}`);
}

function explain(reason) {
  switch (reason?.kind) {
    case "asap":
      return `pushed by task ${reason.sourceId} through link ${reason.linkId}`;
    case "alap":
      return `pulled by task ${reason.targetId} through link ${reason.linkId}`;
    case "summary-translation":
      return `moved with summary ${reason.summaryId}`;
    case "constraint":
      return "placed by its constraint";
    case "preferred":
      return "kept at its preferred date";
    default:
      return "no reason recorded";
  }
}

for (const update of response.result.updates) {
  console.log(`task ${update.id}: ${explain(update.reason)}`);
}

Output:

task 2: pushed by task 1 through link 10
task 3: pushed by task 2 through link 11

The kinds:

  • asap (linkId, sourceId): a predecessor link pushed the task later.
  • alap (linkId, targetId): a successor link pulled the task earlier (backward planning).
  • summary-translation (summaryId): the task moved as part of its summary block.
  • constraint: the task's own constraint placed it.
  • preferred: the task stayed at, or returned to, its own date.

Preview options take the same reasons flag, so a drag preview can explain itself too. A cyclic solve reports no reasons, because the planner doesn't run.

Conflicts#

A conflict is a rule the scheduled plan breaks. It doesn't stop scheduling: the dates are still valid output, and conflicts lists what doesn't fit. Each conflict is tagged by kind:

  • constraint-violation (task, constraint, violation, required, actual): a task's constraint and its links disagree. violation names the bound that broke: "earliest-start", "latest-start", "earliest-end" or "latest-end".
  • calendar-non-working (task, proposed, snapped): with config.strict_calendar, a task moved with its summary block landed on non-working time.

Constraints only act when config.apply_constraints is true. In this example, task 2 must start no later than Tuesday, but its predecessor runs until Wednesday:

const { schedule } = require("./pkg-node/plen_wasm.js");

const data = {
  config: { apply_constraints: true },
  tasks: [
    { id: 1, start_date: "2026-08-10 08:00", duration: 2 },
    // Task 2 must start no later than Tuesday, but task 1 runs until Wednesday.
    {
      id: 2,
      start_date: "2026-08-10 08:00",
      duration: 1,
      constraint_type: "snlt",
      constraint_date: "2026-08-11 08:00",
    },
  ],
  links: [{ id: 1, source: 1, target: 2 }],
};

const response = JSON.parse(schedule(JSON.stringify(data), "{}"));
if (!response.ok) {
  throw new Error(`${response.error.kind}: ${response.error.message}`);
}
const { conflicts, updates } = response.result;
for (const conflict of conflicts) {
  switch (conflict.kind) {
    case "constraint-violation":
      console.log(
        `task ${conflict.task}: ${conflict.constraint} violated (${conflict.violation}), required ${conflict.required}, actual ${conflict.actual}`,
      );
      break;
    case "calendar-non-working":
      console.log(
        `task ${conflict.task}: lands on non-working time at ${conflict.proposed} (snapped ${conflict.snapped})`,
      );
      break;
  }
}
for (const update of updates) {
  console.log(`task ${update.id}: start ${update.start_date}`);
}

Output:

task 2: snlt violated (latest-start), required 2026-08-11 08:00, actual 2026-08-12 08:00
task 2: start 2026-08-11 08:00

The upper-bound constraint wins: task 2 starts on Tuesday, at required. actual is where the link wanted it to start. Show both dates to the user, so they can relax the constraint or move the predecessor. Links, constraints and summaries explains how each constraint type resolves.

Cycles and notes#

A dependency cycle (task 1 waits for task 2, which waits for task 1) has no valid schedule. Planning Engine doesn't fail on it. It returns the dates unchanged, leaves updates empty and lists every unique simple cycle in cycles. Each cycle holds the task ids (tasks) and link ids (links) that form the loop.

notes lists records the scheduler left out or changed. Each note is tagged by kind:

  • dropped-link (link, reason): a link that can't apply, for example one that points to an unscheduled task.
  • dropped-task (task, reason): an unscheduled task.
  • dropped-assignment (assignment, reason): an assignment that doesn't take part.
  • repaired (path, message): a fix applied when the project was created with {"validation": "repair"}.

This example has a cycle between tasks 1 and 2, plus a parked task 3:

const { schedule } = require("./pkg-node/plen_wasm.js");

const data = {
  tasks: [
    { id: 1, start_date: "2026-08-10 08:00", duration: 1 },
    { id: 2, start_date: "2026-08-10 08:00", duration: 1 },
    // Task 3 is parked: it isn't scheduled, and neither is the link to it.
    { id: 3, start_date: "2026-08-10 08:00", duration: 1, unscheduled: true },
  ],
  links: [
    { id: 10, source: 1, target: 2 },
    { id: 11, source: 2, target: 1 }, // 1 -> 2 -> 1 is a cycle
    { id: 12, source: 2, target: 3 },
  ],
};

const response = JSON.parse(schedule(JSON.stringify(data), "{}"));
if (!response.ok) {
  throw new Error(`${response.error.kind}: ${response.error.message}`);
}
const { cycles, updates, notes, converged } = response.result;
for (const cycle of cycles) {
  console.log(`cycle: tasks [${cycle.tasks.join(", ")}], links [${cycle.links.join(", ")}]`);
}
console.log(`updates: ${updates.length}`);

for (const note of notes) {
  switch (note.kind) {
    case "dropped-link":
      console.log(`link ${note.link} dropped: ${note.reason}`);
      break;
    case "dropped-task":
      console.log(`task ${note.task} dropped: ${note.reason}`);
      break;
    case "dropped-assignment":
      console.log(`assignment ${note.assignment} dropped: ${note.reason}`);
      break;
    case "repaired":
      console.log(`repaired ${note.path}: ${note.message}`);
      break;
  }
}
if (!converged) {
  console.log("the planner stopped before a fixed point");
}

Output:

cycle: tasks [1, 2], links [10, 11]
updates: 0
task 3 dropped: unscheduled
link 12 dropped: target task "3" is unscheduled

Highlight the links in cycle.links so the user can break the loop. A cyclic solve still installs a baseline. If you requested analytics, they come back with status: "invalid".

converged is false when the planner didn't reach a stable state within config.max_iterations (default 8). The dates are still a valid result, so treat it as a warning, not a failure.

To clean up messy input instead of rejecting it, see Accept messy input.

Baseline token#

baselineId names the accepted baseline of a JsonProject: the dates the last solve or level accepted. These calls fill it in:

  • solve and level: the baseline they just accepted.
  • preview: the baseline it read.
  • analyze without data, on a solved project with no pending edits: the current baseline. Otherwise the field is left out.
  • schedule: sets it too, but the project behind the call is gone afterwards, so the token has no use.

The token changes whenever the data changes: new data, an apply, a solve that moves dates, or leveling edits. Keep it next to the dates you display. Pass it back as baselineId in the preview options or in SchedulingChanges. If the project moved on in the meantime, the call returns a project error with code stale-baseline:

const { JsonProject } = require("./pkg-node/plen_wasm.js");

const data = {
  tasks: [
    { id: 1, start_date: "2026-08-10 08:00", duration: 2 },
    { id: 2, start_date: "2026-08-10 08:00", duration: 1 },
  ],
  links: [{ id: 1, source: 1, target: 2 }],
};

const project = new JsonProject("{}");
try {
  const loaded = JSON.parse(project.solve(JSON.stringify(data), "{}"));
  if (!loaded.ok) throw new Error(loaded.error.message);
  // Keep the token next to the dates you display.
  const shown = loaded.result.baselineId;

  // The user stretches task 1 to three days; preview it against the shown dates.
  const changes = JSON.stringify({ tasks: { update: [{ id: 1, duration: 3 }] } });
  const guarded = JSON.stringify({ baselineId: shown });

  const preview = JSON.parse(project.preview(changes, guarded));
  console.log(`preview read the shown baseline: ${preview.result.baselineId === shown}`);

  // Someone else commits an edit; the accepted baseline changes.
  project.apply(changes);
  const committed = JSON.parse(project.solve(undefined, "{}"));
  console.log(`token changed: ${committed.result.baselineId !== shown}`);

  const stale = JSON.parse(project.preview(changes, guarded));
  if (!stale.ok && stale.error.code === "stale-baseline") {
    console.log("stale: reload the dates before previewing again");
  }
} finally {
  project.free();
}

Output:

preview read the shown baseline: true
token changed: true
stale: reload the dates before previewing again

The token is opaque. Don't parse it, don't store it as a persistent id, and don't compare tokens across runs: the same data solved twice may get two different tokens. Only compare it with the current one, and always take it from the latest result. Editing a project shows the full edit cycle.

Passing results through#

A result is already JSON, in the same wire format the plen CLI speaks. You don't need a serialization step. Here's the envelope schedule returns for the conflict example above, with reasons: true:

{
  "ok": true,
  "result": {
    "baselineId": "1-b852b2a8065803a2",
    "conflicts": [
      {
        "actual": "2026-08-12 08:00",
        "constraint": "snlt",
        "kind": "constraint-violation",
        "required": "2026-08-11 08:00",
        "task": 2,
        "violation": "latest-start"
      }
    ],
    "converged": true,
    "cycles": [],
    "notes": [],
    "updates": [
      {
        "end_date": "2026-08-12 08:00",
        "id": 2,
        "kind": "task",
        "reason": { "kind": "constraint" },
        "start_date": "2026-08-11 08:00"
      }
    ]
  }
}

The JSON follows these naming rules:

  • Result fields are camelCase: baselineId, conflicts, criticalPath.
  • Update entries keep the snake_case task-data names: start_date, end_date, leveling_delay.
  • Reasons, conflicts and notes are objects tagged by kind in kebab-case: "constraint-violation", "summary-translation", "dropped-link". Their other fields are camelCase (linkId, sourceId).
  • Plain enum values are kebab-case strings: "task", "latest-start", "snlt".
  • Dates are "YYYY-MM-DD HH:mm" strings. Ids stay numbers or strings, as in the input. Whole numbers come out as integers (5, not 5.0).

baselineId differs from run to run.

Forwarding the envelope#

A server that schedules on behalf of a client can return the envelope text as the response body, without parsing it:

const http = require("node:http");
const { schedule } = require("./pkg-node/plen_wasm.js");

// POST a ProjectData body; the response body is the envelope text.
const server = http.createServer((req, res) => {
  let body = "";
  req.setEncoding("utf8");
  req.on("data", (chunk) => (body += chunk));
  req.on("end", () => {
    res.writeHead(200, { "content-type": "application/json" });
    res.end(schedule(body, "{}"));
  });
});

server.listen(8080);

Invalid input doesn't throw here either. A body without tasks comes back as an error envelope, and the client branches on ok:

{ "error": { "code": null, "kind": "parse", "message": "missing field `tasks`", "path": null }, "ok": false }

Forward JSON from a server covers this setup step by step.

Sending updates back#

An update entry decodes as a valid task update, so you can send it back in tasks.update as is. Its kind and reason fields are ignored there. This commits the dates a preview showed:

const { JsonProject } = require("./pkg-node/plen_wasm.js");

const data = {
  tasks: [
    { id: 1, start_date: "2026-08-10 08:00", duration: 2 },
    { id: 2, start_date: "2026-08-10 08:00", duration: 1 },
  ],
  links: [{ id: 1, source: 1, target: 2 }],
};

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    throw new Error(`${response.error.kind}: ${response.error.message}`);
  }
  return response.result;
}

const project = new JsonProject("{}");
try {
  unwrap(project.solve(JSON.stringify(data), "{}"));

  const edit = JSON.stringify({ tasks: { update: [{ id: 1, duration: 3 }] } });
  const preview = unwrap(project.preview(edit, "{}"));

  // The previewed entries go back unchanged as task updates.
  const report = unwrap(project.apply(JSON.stringify({ tasks: { update: preview.updates } })));
  console.log(`applied: ${report.applied.map((op) => op.path).join(", ")}`);

  for (const update of unwrap(project.solve(undefined, "{}")).updates) {
    console.log(`task ${update.id}: ${update.start_date} -> ${update.end_date}`);
  }
} finally {
  project.free();
}

Output:

applied: tasks.update[0], tasks.update[1]
task 1: 2026-08-10 08:00 -> 2026-08-13 08:00
task 2: 2026-08-13 08:00 -> 2026-08-14 08:00

level results serialize the same way, with rounds next to the ScheduleResult fields. Field-by-field shapes are on ScheduleResult.