Files
stack/agents/darkwing/work/queue-62/BUILD.md
T
jason.woltjeandClaude Opus 5.5 6b83edc36a docs(queue-62): row 62 candidate packet, project trees (darkwing)
Candidate manifest sha256 ec3f7a42f3bf6436634a5a83a12bb5678ca1e58a10ff4f66a23dfca7ff3f375f,
13 files on edc5de71. Gate on 6ae8b844: 13 suites green. The candidate
itself is not committed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 14:12:05 -05:00

226 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.