Candidate manifest sha256 ec3f7a42f3bf6436634a5a83a12bb5678ca1e58a10ff4f66a23dfca7ff3f375f, 13 files onedc5de71. Gate on6ae8b844: 13 suites green. The candidate itself is not committed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
226 lines
12 KiB
Markdown
226 lines
12 KiB
Markdown
# Row 62 (#1555): the adapter reads a project tree, candidate packet
|
||
|
||
Author: Darkwing. Reviewers: Dewey and Filbert. Brief:
|
||
`docs/plans/2026-10-11_multi-project-sync.md`, row A. Base: `edc5de71`
|
||
(`base.txt`). It applies unchanged to `6ae8b844`, where the gate ran: the
|
||
commits between the two touch no file in this candidate. The feature is
|
||
uncommitted; only this packet is committed. I pushed nothing.
|
||
|
||
## Files
|
||
|
||
`files.txt` lists the 13 repository paths. `candidate-manifest.sha256`
|
||
has their sha256. `build.patch` is `git diff --cached --binary edc5de71`
|
||
over them: 13 files, +608/−73.
|
||
|
||
| File | Change |
|
||
|---|---|
|
||
| `packages/business/src/vars.mjs` | `tracker.descendants`: project layer, boolean, default false |
|
||
| `packages/business/tests/vars.test.mjs` | accepts true, refuses `"true"`, `1` and the business layer; default false with provenance `default` |
|
||
| `packages/cli/src/config.mjs` | one line, `descendants: v["tracker.descendants"]` in `trackerFor()` (decision 87) |
|
||
| `packages/cli/tests/config.test.mjs` | the two tracker tests expect `descendants: false`; one more assertion that `true` reaches the boot entry |
|
||
| `packages/cli/tests/trackers-boot.test.mjs` | line 44 expects `descendants: false`; see "Scope" |
|
||
| `packages/tasks/src/adapter.mjs` | `settings()` takes `descendants` (absent means false, a non-boolean refuses `tracker-config`); `ctx.tree` |
|
||
| `packages/tasks/src/startup.mjs` | `inspect(ctx, project)` is the install check for any project; new `discover(ctx)` |
|
||
| `packages/tasks/src/sync.mjs` | tick and reconcile read each project in `ctx.tree`; watch-only reads; the leave-tree rule in `missing()` |
|
||
| `packages/tasks/src/fake.mjs` | project trees: parents, the project list, archive, inherited access |
|
||
| `packages/tasks/tests/tree.test.mjs` | new: 11 tests, listed below |
|
||
| `packages/tasks/tests/shapes.test.mjs` | new test: the fake's project list against recorded shapes |
|
||
| `packages/tasks/README.md` | new section "Project trees"; the host contract and startup notes |
|
||
| `packages/tasks/live/README.md` | new section "Project trees" |
|
||
|
||
`verbs.mjs`, `vikunja.mjs`, `index.mjs` and `packages/bus/` are unchanged.
|
||
|
||
## Scope
|
||
|
||
`packages/cli/src/config.mjs` and `config.test.mjs` are outside the
|
||
brief's files. Sage added them as lead decision 87 (commit `9277de33`), a
|
||
decided scope addition: without the line, a host config with
|
||
`tracker.descendants` set never reaches the adapter, and the gate read
|
||
would need a packet script.
|
||
|
||
`packages/cli/tests/trackers-boot.test.mjs` is not named in decision 87.
|
||
It follows from it. Line 44 compares `boot.trackers` with an exact
|
||
object, so the config line breaks it: with the original line 44 and the
|
||
candidate `config.mjs` the test fails, 0 passed and 1 failed. The change
|
||
adds `descendants: false` to that object and nothing else. Reviewers and
|
||
Sage should treat it as part of decision 87's addition, or tell me to
|
||
take it out.
|
||
|
||
The extra `config.test.mjs` assertion, `true` reaching the boot entry, is
|
||
mine. Decision 87 asked for the two `false` updates. Without the new
|
||
assertion no test sent `true` through `config.mjs`, and the gate's live
|
||
run is the first place that value is used.
|
||
|
||
## How it works
|
||
|
||
With `descendants` off, `ctx.tree` holds the root and its view from
|
||
startup and nothing else. Every read is the v0 read with the root's
|
||
project id. The v0 tests pass unchanged, and tree test 1 checks that no
|
||
`GET /projects` and no read under a child happens.
|
||
|
||
With it on:
|
||
|
||
1. Startup is unchanged. The root's install check still refuses the
|
||
business with `tracker-install`.
|
||
2. Each reconcile, including the first one at start, calls `discover()`.
|
||
It reads `GET /projects`, every page, and walks `parent_project_id`
|
||
breadth-first from the root. It skips ids at or below 0 and archived
|
||
projects. Each child gets `inspect()`. A pass gives its view, a
|
||
`tracker-install` refusal gives `null` (watch-only), and any other
|
||
refusal propagates. `ctx.tree` is replaced only after the walk
|
||
finishes, so a failed reconcile keeps the previous tree.
|
||
3. Tick and reconcile read each project in `ctx.tree`. An installed
|
||
project is read through its board, as v0 reads the root. A watch-only
|
||
one is read through `walk(ctx, 'done = false', {}, project)`, and each
|
||
task gets bucket `WATCH_ONLY` (0).
|
||
4. Gone detection in a tick uses a set of refs placed by this tick's
|
||
reads, not task ids. A task moved from A to B shows on B's board under
|
||
the same id; keying by id would hide the old ref's tombstone. The
|
||
`placed-by-id` mutant below fails the move test.
|
||
5. `missing()` takes the project from the ref (`parseRef`). If the task
|
||
still reads 200 in the same project but that project is no longer in
|
||
`ctx.tree`, the task gets `no-access` with its last `updated`.
|
||
|
||
`fieldsFor()` builds the fields of a watch-only task with
|
||
`taskFields(task, 1)` and then sets `bucket: 0`. `taskFields` checks the
|
||
bucket is a positive integer, so the 1 is a placeholder that never
|
||
reaches the ledger. The bus schema accepts 0 (an integer).
|
||
|
||
One line in reconcile changed meaning: `if (!bucket) continue` became
|
||
`if (bucket === undefined) continue`. The old form would skip every
|
||
watch-only task, since 0 is falsy. `undefined` is still the case it was
|
||
written for, an open task the board didn't show.
|
||
|
||
## The brief, line by line
|
||
|
||
| Brief | Where |
|
||
|---|---|
|
||
| `tracker.descendants`, project-layer boolean, default false | `vars.mjs`; `vars.test.mjs` "types" and "merge" |
|
||
| The tree from `GET /projects` by `parent_project_id` | `discover()`; tree tests 2 and 5 |
|
||
| Discovery at startup and each reconcile, not each tick | tree test 2 (tick has no `GET /projects`, reconcile starts with it); test 8 (a late child joins at reconcile, not tick) |
|
||
| Archived projects are not in the tree | tree test 5 (with the list forced to include archived ones); test 9 |
|
||
| Leave-tree rule, recorded and tested | README "Project trees"; tree test 9, three ways |
|
||
| The ledger is never rewritten | tree test 9: snapshots read `open`, then `no-access` with the same `updated` |
|
||
| Root install fails: refuse | tree test 4 |
|
||
| Child passes: installed. Fails: watch-only, business ready | tree tests 2 and 3 |
|
||
| Watch-only records title, done, assignees, comments; no bucket state | tree test 11, decision 87's bucket 0 |
|
||
| Moves root to child, child to child, child to outside | tree test 7 |
|
||
| Poll cost stated | README; tree test 2 counts 6 GETs per tick and 11 per reconcile; probes.md P4 |
|
||
| Cross-project read probed | probes.md P4 and "Gate read" |
|
||
| The fake models the tree | `fake.mjs`; probes.md "Fake"; shapes test 2 |
|
||
| Descendants off matches v0 | tree test 1; every pre-existing tasks test unchanged |
|
||
| verbs read-only; a child task refuses `task-project` | `verbs.mjs` unchanged; tree test 11 |
|
||
| No change to `packages/bus/`; no Vikunja writes | `files.txt`; tree test 11 checks `sync.mjs` has no write method string |
|
||
|
||
## Tests
|
||
|
||
`packages/tasks/tests/tree.test.mjs`:
|
||
|
||
1. With descendants off the adapter reads no project list and no child, as in v0.
|
||
2. A three-level tree is read per project, and a project outside it is
|
||
not. Root, an installed child, a watch-only grandchild and an outside
|
||
project shared with the sync bot. The tick's six requests are checked
|
||
in order; the reconcile is `GET /projects` plus 10.
|
||
3. A watch-only child keeps the business ready, and a failed child read
|
||
refuses as transient (a 503 on its views).
|
||
4. A root without the installed buckets refuses with descendants on.
|
||
5. The project list refuses without `read_all`, and skips archived
|
||
projects and saved filters. A fetch wrapper adds `is_archived=true`,
|
||
so the fake returns archived projects and the adapter's own check has
|
||
to drop them.
|
||
6. `descendants` must be true or false.
|
||
7. A move inside the tree is a moved tombstone plus a snapshot under the
|
||
new ref: root to A, A to B (watch-only), A to outside.
|
||
8. A child made after startup joins the tree at the next reconcile.
|
||
9. A child that leaves the tree has its tasks recorded missing, and the
|
||
ledger keeps their history: archived, reparented to the top with a
|
||
direct share, and reparented under a project the bot can't see. The
|
||
hidden parent's title never reaches the bus.
|
||
10. A task in a bucket outside the five keeps its raw bucket id.
|
||
11. Bucket 0 on a watch-only task never becomes a bucket or a state. A
|
||
move between open buckets records nothing; done then reopen records
|
||
`done` twice; every snapshot has bucket 0; no `task.state` event; a
|
||
verb refuses `task-project`; the source of `sync.mjs` has no
|
||
`.titles` or `.ids[` lookup and no write method string. This is the
|
||
test decision 87 asked for.
|
||
|
||
`shapes.test.mjs` adds "the project list holds recorded project objects
|
||
in the recorded list envelope". No recording holds a `GET /projects`
|
||
list, so it checks the envelope against the recorded views list and each
|
||
item against the recorded `GET /projects/{p}` body, keys and types.
|
||
|
||
### Mutants
|
||
|
||
`out/mutants.sh` edits one source file in the build worktree, runs the
|
||
tests, and restores the file from the candidate. Each mutant fails at
|
||
least one test (`out/mutants.txt`):
|
||
|
||
| Mutant | Fails |
|
||
|---|---|
|
||
| `no-leave-tree-check`: drop the `ctx.tree.has(project)` branch in `missing()` | tree 9 |
|
||
| `no-watch-only-bucket`: `fieldsFor` keeps the bucket it is given | tree 2, 3, 7, 8, 11 |
|
||
| `placed-by-id`: gone detection keyed by task id | tree 7 |
|
||
| `keep-archived`: `discover()` keeps archived projects | tree 5 |
|
||
| `config-drops-descendants`: `config.mjs` passes `false` | cli config "trackers come from..." |
|
||
|
||
## Things a reviewer should know
|
||
|
||
- The `p.id <= 0` skip guards shape only. The walk starts at the root
|
||
and follows positive parent ids, so it can't reach a negative id. The
|
||
skip stops a saved filter from failing the `parent_project_id` check.
|
||
- The fake's `GET /projects/{p}` body now has `parent_project_id`,
|
||
`position`, `created` and `updated`, from a shared `#projectJson`.
|
||
Shapes test 1 still matches it to the recorded body.
|
||
- `commentsAt` and `commentCount` stay keyed by task id. Vikunja task ids
|
||
are global, so a moved task keeps its comment state.
|
||
- A watch-only child's open tasks come from its task list, so a tick
|
||
costs the same two GETs as an installed project.
|
||
- I noticed in the v2.7.0 source that creating a project under v2 also
|
||
creates a default kanban with a backlog bucket. A child made in the UI
|
||
is then watch-only until someone installs the five buckets by hand.
|
||
Nothing in this row depends on it.
|
||
- No probe in this row touched a live Vikunja. probes.md has the source
|
||
evidence and the exact gate read.
|
||
|
||
## Gate
|
||
|
||
The gate ran in a detached worktree of `6ae8b844` with `build.patch`
|
||
applied and `node_modules` linked to the main checkout's
|
||
(`out/gate/apply.txt`, `out/gate/gate.sh`). Every suite ran in order and
|
||
each log is in `out/gate/`. test-task ran with
|
||
`DOCKER_HOST=unix:///nonexistent`.
|
||
|
||
Every suite passed on this candidate (`out/gate/summary.txt`):
|
||
|
||
| Suite | rc | Result |
|
||
|---|---|---|
|
||
| `packages/tasks` | 0 | 63 passed, 0 failed |
|
||
| `packages/business` | 0 | 60 passed, 0 failed |
|
||
| `packages/bus` | 0 | 74 passed, 0 failed |
|
||
| `packages/cli` | 0 | 83 passed, 0 failed |
|
||
| test-auth | 0 | 15 passed, 0 failed |
|
||
| test-conductor | 0 | 17 passed, 0 failed |
|
||
| test-config | 0 | 24 passed, 0 failed |
|
||
| test-discord | 0 | 66 passed, 0 failed |
|
||
| test-extension-package | 0 | 18 passed, 0 failed |
|
||
| test-foundation | 0 | 44 passed, 0 failed |
|
||
| test-queue | 0 | 27 passed, 0 failed |
|
||
| test-release | 0 | 14 passed, 0 failed |
|
||
| test-task | 0 | 26 passed, 0 failed |
|
||
|
||
After the run I removed the gate worktree's `gate_default` Docker
|
||
network; it had no containers.
|
||
|
||
Two earlier gate runs were stopped. The first had no `node_modules` link,
|
||
so test-discord failed its "pi binary present" check, 57 passed and 1
|
||
failed. The second I stopped to add the `true` assertion to
|
||
`config.test.mjs`, which changed the candidate. Neither run's logs are
|
||
in this packet; they were on the earlier candidate.
|
||
|
||
## Gate read for Sage
|
||
|
||
probes.md "Gate read" has the exact requests: `GET /api/v2/projects` and
|
||
`GET /api/v2/tasks` with a `project in 19, 18, 20, 21` filter, sync key 9,
|
||
GETs only. The adapter reads per project whatever read 2 answers; a 200
|
||
there opens a follow-up row.
|