[Canonical page](<https://docs.dhtmlx.com/planning-engine/wasm/scheduling/links-constraints-summaries/>)
[Full documentation index](<https://docs.dhtmlx.com/planning-engine/llms.txt>)


# Links, constraints and summaries

This guide covers the rules that decide where a task lands: dependency links with lag, date constraints, how much free time the scheduler keeps between tasks, how summary tasks move, and what happens when links form a cycle. It's for developers who already [schedule a project](<https://docs.dhtmlx.com/planning-engine/wasm/scheduling/scheduling-a-project.md>) through the raw exports and now need to control or explain the dates it produces.

## How the scheduler thinks

Every task has a **preferred position**: the dates you send in its [`TaskData`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/task-data.md>) record. Scheduling starts from those dates and applies three kinds of rules on top:

- **Links** push or pull a task relative to another task. A [`LinkData`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/link-data.md>) record in `links` connects a `source` to a `target` with one of four types and an optional `lag`. Links are the only thing that ties one task's dates to another's.
- **Constraints** bound a single task to a date (`"snet"`, `"mfo"`, ...) or ask it to sit as early or as late as its links allow (`"asap"`, `"alap"`). They act only when [`config.apply_constraints`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/scheduling-config.md>) is `true`.
- **Gap behaviour** decides what to do with free time. With `"preserve"` (the default), a task keeps its preferred dates whenever its links allow them. With `"compress"`, every task is pulled to its earliest possible position.

Summary tasks sit on top of this. A summary's span is always derived from its children, so a summary takes both dates or none, never a duration. When a link points at a summary, the scheduler either moves the whole summary as one block or expands the link onto the summary's leaves. `config.move_projects` picks the mode.

Some tasks never move. They act as fixed points for everything linked to them:

- tasks with `auto_scheduling: false`
- unscheduled tasks (`unscheduled: true`, which also drops their links)
- completed tasks, when `config.use_progress` is `true` and `progress` is `1`

Nothing in this guide produces an error envelope at scheduling time. A constraint the links can't satisfy becomes an entry in [`conflicts`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/schedule-result.md#conflict>). A link that can't apply becomes a `dropped-link` entry in [`notes`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/schedule-result.md#note>). A dependency cycle becomes an entry in [`cycles`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/schedule-result.md#cycle>). All three come back in an `ok: true` [`ScheduleResult`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/schedule-result.md>). Error envelopes come only from invalid input, such as a dated constraint without a date. See [Reading results](<https://docs.dhtmlx.com/planning-engine/wasm/scheduling/reading-results.md>) for the full result shape.

To see which rule placed a task, send `{"reasons": true}` as the options. Each update then carries a [`reason`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/schedule-result.md#reason>) whose `kind` is `"asap"` (pushed by a predecessor link), `"alap"` (pulled by a successor link), `"summary-translation"` (moved with its summary block), `"constraint"` or `"preferred"`.

The examples below run in Node.js against the `pkg-node/` build and call the stateless [`schedule`](<https://docs.dhtmlx.com/planning-engine/wasm/api/schedule.md>) export unless they need a project handle. 2026-08-10 is a Monday, and the default calendar works Monday to Friday, 08:00-12:00 and 13:00-17:00.

## Link types and lag

A link's `type` names which end of the source drives which end of the target:

| `type` | The target's... | ...follows the source's... |
|---|---|---|
| `"fs"` (default) | start | finish |
| `"ss"` | start | start |
| `"ff"` | finish | finish |
| `"sf"` | finish | start |

The type string is case-insensitive, and the numbers `0` to `3` (or `"0"` to `"3"`) are accepted in the same order. See [Link type](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/common.md#link-type>) for the full list.

`lag` adds working time between the two ends. It's counted on the target task's calendar, in `config.duration_unit` unless the link sets its own `lag_unit`. See [Working time and calendars](<https://docs.dhtmlx.com/planning-engine/wasm/scheduling/working-time-calendars.md>) for how calendars define working time.

### Chaining tasks with lag

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

const data = {
  tasks: [
    { id: 1, start_date: "2026-08-10 08:00", duration: 3 },
    { id: 2, start_date: "2026-08-10 08:00", duration: 2 },
    { id: 3, start_date: "2026-08-10 08:00", duration: 2 },
  ],
  links: [
    // Task 2 starts 2 working days after task 1 finishes.
    { id: 1, source: 1, target: 2, type: "fs", lag: 2 },
    // Task 3 starts 1 working day after task 1 starts.
    { id: 2, source: 1, target: 3, type: "ss", lag: 1 },
  ],
};

const response = JSON.parse(schedule(JSON.stringify(data), "{}"));
if (!response.ok) throw new Error(response.error.message);
for (const update of response.result.updates) {
  console.log(`task ${update.id}: ${update.start_date} -> ${update.end_date}`);
}
```

Output:

```
task 2: 2026-08-17 08:00 -> 2026-08-19 08:00
task 3: 2026-08-11 08:00 -> 2026-08-13 08:00
```

Task 1 works Monday to Wednesday and ends on Thursday at 08:00. The FS lag of two working days covers Thursday and Friday, so task 2 starts the next Monday. Task 1 isn't listed because it didn't move: `updates` holds only changed tasks.

### Waiting for several predecessors

A task with more than one incoming link starts when the latest of them allows:

```js
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: 5 },
    { id: 3, start_date: "2026-08-10 08:00", duration: 1 },
  ],
  links: [
    { id: 1, source: 1, target: 3, type: "fs" },
    { id: 2, source: 2, target: 3, type: "fs" },
  ],
};

const response = JSON.parse(schedule(JSON.stringify(data), "{}"));
if (!response.ok) throw new Error(response.error.message);
for (const update of response.result.updates) {
  console.log(`task ${update.id}: ${update.start_date} -> ${update.end_date}`);
}
```

Output:

```
task 3: 2026-08-17 08:00 -> 2026-08-18 08:00
```

Task 1 would let task 3 start on Wednesday, but the five-day task 2 doesn't finish until the next Monday. The later bound wins.

### Lag in a different unit

A project measured in days can still have a link whose lag is in hours. Set `lag_unit` on that link:

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

const data = {
  tasks: [
    { id: "pour", start_date: "2026-08-10 08:00", duration: 1 },
    { id: "strip", start_date: "2026-08-10 08:00", duration: 1 },
  ],
  links: [{ id: "cure", source: "pour", target: "strip", type: "fs", lag: 4, lag_unit: "hour" }],
};

const response = JSON.parse(schedule(JSON.stringify(data), "{}"));
if (!response.ok) throw new Error(response.error.message);
for (const update of response.result.updates) {
  console.log(`${update.id}: ${update.start_date} -> ${update.end_date}`);
}
```

Output:

```
strip: 2026-08-11 12:00 -> 2026-08-12 12:00
```

`pour` works all of Monday. Four working hours after that, on the default calendar, is Tuesday at 12:00.

:::note
A link without `lag_unit` measures lag in `config.duration_unit`.
:::

### Links that get dropped

A link that can't apply is skipped and reported in [`notes`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/schedule-result.md#note>) as `{"kind": "dropped-link", "link": ..., "reason": ...}`. The rest of the plan still runs. This happens when:

- the source or target id doesn't exist, or names an unscheduled task
- the link connects a summary to its own descendant and `config.descendant_links` is `false` (the default)
- an endpoint is a summary with `auto_scheduling: false` and summaries expand to leaves (see [Summary tasks](<https://docs.dhtmlx.com/planning-engine/wasm/scheduling/links-constraints-summaries.md#summary-tasks>))

A link to a task id that doesn't exist comes back like this:

```json
{ "kind": "dropped-link", "link": "x", "reason": "unknown target task \"99\"" }
```

:::note
`lag` must be a JSON number or `null`. A string such as `"2"` fails the whole call with a `parse` error. JSON can't carry `NaN` or `Infinity`: `JSON.stringify` turns them into `null`, which reads as the default lag of `0`. Check that lag values are finite before you serialize them.
:::

## Constraints

A constraint is set per task with [`constraint_type`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/common.md#constrainttype>) and, for the six dated types, `constraint_date`:

| `constraint_type` | Meaning | Needs `constraint_date` |
|---|---|---|
| `"asap"` | as soon as the links allow | no |
| `"alap"` | as late as the links allow | no |
| `"snet"` / `"snlt"` | start no earlier / no later than | yes |
| `"fnet"` / `"fnlt"` | finish no earlier / no later than | yes |
| `"mso"` / `"mfo"` | must start on / must finish on | yes |

Constraints are hard bounds, and they exist only when `config.apply_constraints` is `true`. With the default `false`, the scheduler ignores `constraint_type` and `constraint_date` completely, so even an `"mso"` pin has no effect.

A dated constraint without `constraint_date` is invalid input whatever `apply_constraints` says. In strict validation the call returns an error envelope:

```json
{
  "ok": false,
  "error": {
    "code": null,
    "kind": "validation",
    "message": "dated constraint requires constraint_date",
    "path": "tasks[0].constraint_date"
  }
}
```

### Holding a task back with a dated constraint

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

const data = {
  config: { apply_constraints: true },
  tasks: [
    {
      id: "install",
      start_date: "2026-08-10 08:00",
      duration: 2,
      constraint_type: "snet",
      constraint_date: "2026-08-13 08:00",
    },
  ],
};

const options = { reasons: true };
const response = JSON.parse(schedule(JSON.stringify(data), JSON.stringify(options)));
if (!response.ok) throw new Error(response.error.message);
for (const update of response.result.updates) {
  console.log(`${update.id}: ${update.start_date} -> ${update.end_date} (${update.reason.kind})`);
}
```

Output:

```
install: 2026-08-13 08:00 -> 2026-08-17 08:00 (constraint)
```

`install` moves from Monday to Thursday. It works Thursday and Friday, so its end is the start of the next working day, Monday at 08:00.

### Detecting a constraint that a link breaks

When a link and a hard constraint disagree, the constraint wins. The task keeps the constrained date, and the result reports the clash as a `constraint-violation` [conflict](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/schedule-result.md#conflict>):

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

const data = {
  config: { apply_constraints: true },
  tasks: [
    { id: "build", start_date: "2026-08-10 08:00", duration: 3 },
    {
      id: "demo",
      start_date: "2026-08-11 08:00",
      duration: 1,
      constraint_type: "mso",
      constraint_date: "2026-08-11 08:00",
    },
  ],
  links: [{ id: 1, source: "build", target: "demo", type: "fs" }],
};

const response = JSON.parse(schedule(JSON.stringify(data), "{}"));
if (!response.ok) throw new Error(response.error.message);
for (const conflict of response.result.conflicts) {
  if (conflict.kind === "constraint-violation") {
    const { task, constraint, violation, required, actual } = conflict;
    console.log(`${task}: ${constraint} ${violation}, required ${required}, actual ${actual}`);
  }
}
```

Output:

```
demo: mso latest-start, required 2026-08-11 08:00, actual 2026-08-13 08:00
```

`build` finishes on Thursday at 08:00, so the link wants `demo` to start then. The `"mso"` pin keeps it on Tuesday, `updates` is empty, and `violation` says which bound broke: here the latest allowed start. `required` is the constraint's bound, and `actual` is the date the link asked for.

### Placing a task as late as possible

`"alap"` moves a task as close to its successors as the links allow, even in a forward-scheduled project:

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

const data = {
  config: { apply_constraints: true },
  tasks: [
    { id: "order-food", start_date: "2026-08-10 08:00", duration: 1, constraint_type: "alap" },
    {
      id: "party",
      start_date: "2026-08-14 08:00",
      duration: 1,
      constraint_type: "mso",
      constraint_date: "2026-08-14 08:00",
    },
  ],
  links: [{ id: 1, source: "order-food", target: "party", type: "fs" }],
};

const response = JSON.parse(schedule(JSON.stringify(data), "{}"));
if (!response.ok) throw new Error(response.error.message);
for (const update of response.result.updates) {
  console.log(`${update.id}: ${update.start_date} -> ${update.end_date}`);
}
```

Output:

```
order-food: 2026-08-13 08:00 -> 2026-08-14 08:00
```

`order-food` moves from Monday to Thursday, the last working day before `party`. To plan a whole project backward from a deadline, set `config.schedule_from_end` and `config.project_end` instead. See [`SchedulingConfig`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/scheduling-config.md>).

### Inheriting a summary's constraint

With `config.project_constraint` set to `true`, a task without its own constraint inherits the nearest ancestor's:

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

const data = {
  config: { apply_constraints: true, project_constraint: true },
  tasks: [
    { id: "phase", type: "summary", constraint_type: "snet", constraint_date: "2026-08-12 08:00" },
    { id: "step", parent: "phase", start_date: "2026-08-10 08:00", duration: 1 },
  ],
};

const response = JSON.parse(schedule(JSON.stringify(data), "{}"));
if (!response.ok) throw new Error(response.error.message);
for (const update of response.result.updates) {
  console.log(`${update.id} (${update.kind}): ${update.start_date} -> ${update.end_date}`);
}
```

Output:

```
step (task): 2026-08-12 08:00 -> 2026-08-13 08:00
phase (summary): 2026-08-12 08:00 -> 2026-08-13 08:00
```

`step` moves to Wednesday, and the rolled-up `phase` follows it as an update with `kind: "summary"`. With `project_constraint: false`, nothing in this example moves. `config.group_mode` turns inheritance off again. That's the only thing `group_mode` does.

## Gap behaviour

[`config.gap_behavior`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/common.md#gap-behavior>) decides what happens to the free time between linked tasks:

- `"preserve"` (default) keeps each task at its preferred dates as long as its links allow them. A link only pushes a task when the task would otherwise start too early.
- `"compress"` pulls every task to the earliest position its links and constraints allow, closing the gaps.

### Keeping or closing a gap

The same data, scheduled with both settings:

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

for (const gapBehavior of ["preserve", "compress"]) {
  const data = {
    config: { gap_behavior: gapBehavior },
    tasks: [
      { id: "design", start_date: "2026-08-10 08:00", duration: 1 },
      { id: "review", start_date: "2026-08-13 08:00", duration: 1 },
    ],
    links: [{ id: 1, source: "design", target: "review", type: "fs" }],
  };

  const options = { includeUnchanged: true };
  const response = JSON.parse(schedule(JSON.stringify(data), JSON.stringify(options)));
  if (!response.ok) throw new Error(response.error.message);
  for (const update of response.result.updates) {
    console.log(`${gapBehavior} ${update.id}: starts ${update.start_date}`);
  }
}
```

Output:

```
preserve design: starts 2026-08-10 08:00
preserve review: starts 2026-08-13 08:00
compress design: starts 2026-08-10 08:00
compress review: starts 2026-08-11 08:00
```

`includeUnchanged` lists every task, including the ones that didn't move. Under `"preserve"`, `review` stays on Thursday because the link only requires it to start after Monday. Under `"compress"`, it moves to Tuesday.

`"compress"` also changes how summaries behave: it always expands summary links onto leaves, as the next section describes.

## Summary tasks

A summary is a task with `type: "summary"`. `"project"` is accepted as an input alias, and the output always says `"summary"`. A summary groups child tasks through their `parent` field. Its span is always rolled up from its children, and changed summaries appear in `updates` with `kind: "summary"`. Write them back to your model like any other update.

[`config.move_projects`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/scheduling-config.md>) picks one of two modes for links that touch a summary:

- **Block mode** (`move_projects: true`, the default, together with `gap_behavior: "preserve"`): the summary moves as one block. A link into the summary shifts the whole block, and every child keeps its offset inside it.
- **Leaf expansion** (`move_projects: false`, or any project with `gap_behavior: "compress"`): the link is expanded onto the summary's leaf tasks, and each leaf is scheduled on its own.

Inside a moving block, some children stay put: a child pinned by a dated constraint, a child with `auto_scheduling: false`, and, with `config.use_progress`, a completed child. A summary with `auto_scheduling: false` is a fixed boundary that keeps its explicit dates. In leaf-expansion mode its links are dropped with a `dropped-link` note.

With `config.strict_calendar` set to `true`, a child that a moving block lands on non-working time is reported as a `calendar-non-working` [conflict](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/schedule-result.md#conflict>).

:::caution
Chains of summary-to-summary links in block mode are a known open issue. On large graphs that also have leaf links crossing summary boundaries, dates can grow without bound until the module traps with a `RuntimeError`. A minimal summary-to-summary link schedules correctly.
:::

### Moving a summary as a block

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

for (const moveProjects of [true, false]) {
  const data = {
    config: { move_projects: moveProjects },
    tasks: [
      { id: "prep", start_date: "2026-08-10 08:00", duration: 3 },
      { id: "phase", type: "summary" },
      { id: "a", parent: "phase", start_date: "2026-08-10 08:00", duration: 1 },
      { id: "b", parent: "phase", start_date: "2026-08-11 08:00", duration: 1 },
    ],
    links: [{ id: 1, source: "prep", target: "phase", type: "fs" }],
  };

  const options = { reasons: true };
  const response = JSON.parse(schedule(JSON.stringify(data), JSON.stringify(options)));
  if (!response.ok) throw new Error(response.error.message);
  console.log(`move_projects: ${moveProjects}`);
  for (const update of response.result.updates) {
    console.log(`  ${update.id} ${update.kind}: ${update.start_date} -> ${update.end_date} (${update.reason.kind})`);
  }
}
```

Output:

```
move_projects: true
  a task: 2026-08-13 08:00 -> 2026-08-14 08:00 (summary-translation)
  b task: 2026-08-14 08:00 -> 2026-08-17 08:00 (summary-translation)
  phase summary: 2026-08-13 08:00 -> 2026-08-17 08:00 (asap)
move_projects: false
  phase summary: 2026-08-13 08:00 -> 2026-08-14 08:00 (preferred)
  a task: 2026-08-13 08:00 -> 2026-08-14 08:00 (asap)
  b task: 2026-08-13 08:00 -> 2026-08-14 08:00 (asap)
```

The `phase` summary has no dates of its own, so its span comes from `a` and `b`. In block mode, the link pushes `phase` to Thursday, and `a` and `b` move with it, one day apart as before. Their `reason` is `{"kind": "summary-translation", "summaryId": "phase"}`. With `move_projects: false`, the link lands on each leaf: both `a` and `b` start on Thursday, pushed by link `1`, and `phase` shrinks to one day.

### Keeping completed work in place

With `config.use_progress`, a child with `progress: 1` is completed and doesn't move with its block:

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

const data = {
  config: { use_progress: true },
  tasks: [
    { id: "prep", start_date: "2026-08-10 08:00", duration: 3 },
    { id: "phase", type: "summary" },
    { id: "done", parent: "phase", start_date: "2026-08-10 08:00", duration: 1, progress: 1 },
    { id: "open", parent: "phase", start_date: "2026-08-11 08:00", duration: 1 },
  ],
  links: [{ id: 1, source: "prep", target: "phase", type: "fs" }],
};

const response = JSON.parse(schedule(JSON.stringify(data), "{}"));
if (!response.ok) throw new Error(response.error.message);
console.log(JSON.stringify(response.result.updates, null, 2));
```

The printed `updates`, one entry per line:

```json
[
  { "end_date": "2026-08-17 08:00", "id": "open", "kind": "task", "start_date": "2026-08-14 08:00" },
  { "duration": 5, "end_date": "2026-08-17 08:00", "id": "phase", "kind": "summary" }
]
```

Only `open` moves, to Friday. `done` stays on Monday, so the summary keeps its start and only its end and duration change. An update lists only the fields that changed, so `phase` has no `start_date` here.

### Linking a summary to its own children

By default, a link between a summary and one of its descendants is dropped:

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

const data = {
  tasks: [
    { id: "phase", type: "summary" },
    { id: "kickoff", parent: "phase", start_date: "2026-08-10 08:00", duration: 1 },
  ],
  links: [{ id: "l1", source: "phase", target: "kickoff", type: "ss" }],
};

const response = JSON.parse(schedule(JSON.stringify(data), "{}"));
if (!response.ok) throw new Error(response.error.message);
for (const note of response.result.notes) {
  if (note.kind === "dropped-link") {
    console.log(`link ${note.link} dropped: ${note.reason}`);
  }
}
```

Output:

```
link l1 dropped: link between a summary and its own descendant (descendant_links disabled)
```

Set `config.descendant_links` to `true` to keep such links.

To reschedule only one summary and the tasks that depend on it, see [Reschedule a branch](<https://docs.dhtmlx.com/planning-engine/wasm/how-to/reschedule-a-branch.md>).

## Cycles

A dependency cycle (A waits for B, B waits for A) has no valid schedule. Planning Engine doesn't treat it as an error. A cyclic run:

- leaves every date unchanged and returns empty `updates`
- lists each unique simple cycle in [`result.cycles`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/schedule-result.md#cycle>), each with its `tasks` and `links`
- returns [`analytics`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/schedule-result.md#analytics>) with `status: "invalid"` if you asked for any
- still installs a baseline on a [`JsonProject`](<https://docs.dhtmlx.com/planning-engine/wasm/api/json-project.md>), so you can edit the data to remove the cycle

Cycle detection covers fixed and completed tasks and summary containment too. A self-link is a one-task cycle: a link from task `1` to task `1` comes back as `{"tasks": [1], "links": [1]}`. A cycle in the `parent` hierarchy is different. It's invalid input and returns a `validation` error envelope.

### Reporting cycles

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

const data = {
  tasks: [
    { id: "a", start_date: "2026-08-10 08:00", duration: 1 },
    { id: "b", start_date: "2026-08-10 08:00", duration: 1 },
    { id: "c", start_date: "2026-08-10 08:00", duration: 1 },
  ],
  links: [
    { id: "ab", source: "a", target: "b", type: "fs" },
    { id: "ba", source: "b", target: "a", type: "fs" },
    { id: "bc", source: "b", target: "c", type: "fs" },
  ],
};

const project = new JsonProject("{}");
try {
  const options = { analytics: { criticalPath: true } };
  const response = JSON.parse(project.solve(JSON.stringify(data), JSON.stringify(options)));
  if (!response.ok) throw new Error(response.error.message);
  const { updates, cycles, analytics } = response.result;

  console.log(`updates: ${updates.length}`);
  for (const cycle of cycles) {
    console.log(`cycle: tasks ${JSON.stringify(cycle.tasks)}, links ${JSON.stringify(cycle.links)}`);
  }
  console.log(`analytics: ${analytics.status}`);
} finally {
  project.free();
}
```

Output:

```
updates: 0
cycle: tasks ["a","b"], links ["ab","ba"]
analytics: invalid
```

Link `bc` isn't part of the cycle, but `c` doesn't move either: a cyclic run schedules nothing.

### Breaking a cycle

Remove one of the cycle's links with [`apply`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-apply.md>), then call [`solve`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-solve.md>) without data to schedule the current state:

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

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

const data = {
  tasks: [
    { id: "a", start_date: "2026-08-10 08:00", duration: 1 },
    { id: "b", start_date: "2026-08-10 08:00", duration: 1 },
    { id: "c", start_date: "2026-08-10 08:00", duration: 1 },
  ],
  links: [
    { id: "ab", source: "a", target: "b", type: "fs" },
    { id: "ba", source: "b", target: "a", type: "fs" },
    { id: "bc", source: "b", target: "c", type: "fs" },
  ],
};

const project = new JsonProject("{}");
try {
  const result = unwrap(project.solve(JSON.stringify(data), "{}"));
  const cycle = result.cycles[0];
  if (cycle) {
    const culprit = cycle.links[cycle.links.length - 1];
    const report = unwrap(project.apply(JSON.stringify({ links: { remove: [culprit] } })));
    console.log(`removed: ${JSON.stringify(report.effects.removedLinks)}`);

    const next = unwrap(project.solve(undefined, "{}"));
    console.log(`cycles: ${next.cycles.length}`);
    for (const update of next.updates) {
      console.log(`${update.id} starts ${update.start_date}`);
    }
  }
} finally {
  project.free();
}
```

Output:

```
removed: ["ba"]
cycles: 0
b starts 2026-08-11 08:00
c starts 2026-08-12 08:00
```

After `ba` is removed, the project is acyclic again. `b` moves to Tuesday, after `a`, and `c` follows `b`. You decide which link to remove: the engine only reports the cycle. For more on `apply` and partial edits, see [Editing a project](<https://docs.dhtmlx.com/planning-engine/wasm/live-projects/editing-a-project.md>).

## See also

- [`LinkData`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/link-data.md>) and [`TaskData`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/task-data.md>)
- [`SchedulingConfig`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/scheduling-config.md>) for `apply_constraints`, `gap_behavior`, `move_projects`, `project_constraint`, `descendant_links`, `use_progress` and `strict_calendar`
- [`ScheduleResult`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/schedule-result.md>) for `conflicts`, `notes`, `cycles` and `reason`
- [Common types](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/common.md>) for link types, `ConstraintType` and gap behavior
- [Critical path and slack](<https://docs.dhtmlx.com/planning-engine/wasm/scheduling/critical-path-and-slack.md>)
