R2/B1: after a forbidden or not-found refusal on a child, the tick reads
GET /projects/<id> and drops the child only on 403 or 404; a deleted
kanban view (project read 200) fails the tick as in round 1. Tests 18
and 19, 23/23 mutants killed, gate on ae428a54 green (14 suites).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
595 lines
30 KiB
Markdown
595 lines
30 KiB
Markdown
# Row 62 (#1555): the adapter reads a project tree, candidate packet, rounds 1 and 2
|
||
|
||
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.
|
||
|
||
## Round 2
|
||
|
||
Round 1 drew changes requested from both reviewers on candidate
|
||
`ec3f7a42`. Filbert's comment 27279 (queue rev 408) had one blocking item,
|
||
R1, and three test gaps, N1 to N3. Dewey's comment 27280 (rev 409) found
|
||
the same blocking defect on its own and named three more test gaps. This
|
||
round fixes R1 and adds a test for each of the six gaps. `build.patch` and
|
||
`candidate-manifest.sha256` now hold the round 2 candidate; the round 1
|
||
versions are in this packet's history at `6b83edc3`. `files.txt` is
|
||
unchanged, the same 13 files: 13 files, +778/−73 against `edc5de71`.
|
||
Round 2 changed four of them: `sync.mjs`, `fake.mjs`, `tree.test.mjs`
|
||
and the tasks `README.md`. The gate applied the patch to `122b1223`
|
||
(`out/gate-round2/apply.txt`); no candidate file changed between
|
||
`6ae8b844` and `122b1223`.
|
||
|
||
### Blocking
|
||
|
||
**R1 (Filbert, and Dewey's blocking item): a child that answered 403 or
|
||
404 failed every tick until the next reconcile.** Dewey added that
|
||
`notify(ctx)` runs after `tick(ctx)` in the adapter's timer, so the
|
||
credential notices stopped too. A tick that drops the child no longer
|
||
throws, so `notify` runs again. No test drives the timer path; the tests
|
||
call `tick` directly. The tick now drops it. In `tick()`, a
|
||
`tracker-forbidden` or `tracker-not-found` refusal on a project's board,
|
||
task list or cursor walk removes that project from `ctx.tree`, and the
|
||
tick goes on with the others. The rule is `leftTree()` in `sync.mjs`.
|
||
The dropped project's tasks are then on no board this tick, so
|
||
`missing()` reads each one, as it does after a reconcile: a 403 gives
|
||
`no-access`, a 404 gives `not-found`, a 200 in the same project gives
|
||
`no-access`. The root still fails the tick on a 403 or 404. Any other
|
||
failure on a child, a 503 for one, still fails the tick, and the child
|
||
stays in the tree. A dropped child that comes back is found by the next
|
||
reconcile's `discover()`.
|
||
|
||
I chose drop over skip. A skipped child stays in `ctx.tree`, so every
|
||
tick reads it again and gets the same 403 until the reconcile; dropping
|
||
it does what the reconcile would do, one reconcile early. The tradeoff:
|
||
a child that answers 403 for a short time is out of the tree until the
|
||
next reconcile, and its open tasks get `no-access` tombstones meanwhile.
|
||
The reconcile reads them again if the access is back. No test covers
|
||
that return.
|
||
|
||
`fake.mjs` gained `deleteProject()`. Like Vikunja it deletes the
|
||
project, its descendants and their tasks. After it a project read
|
||
answers 404 with code 3001 and a task read 404 with code 4002.
|
||
|
||
Filbert's list names three ways a child leaves: unshared, reparented
|
||
under a project the bot can't read, and deleted. A child under the root
|
||
inherits the root's share, and project_access.go has no grant that
|
||
takes access away, so a child can't stop being shared on its own while
|
||
the root stays shared. The fake models it the same way. The reparented
|
||
case is the 403 a child can get; tests 9 and 10 use it.
|
||
|
||
Test 9 now ticks between the change and the reconcile. The hidden child
|
||
answers 403 on its board, so the tick drops it and records its task
|
||
`no-access`. The archived child and the one reparented to the top with a
|
||
share of its own still read, so they wait for the reconcile.
|
||
|
||
New test 10, "a child deleted or hidden between reconciles drops out at
|
||
the next tick, and the root still refuses":
|
||
|
||
- An installed child is deleted (404 on its board), and a watch-only
|
||
child is moved under a project the bot can't read (403 on its task
|
||
list). The root and a sibling child have edits in the same tick.
|
||
- The tick leaves the business ready, records `not-found` and
|
||
`no-access` for the two tasks, and records both edits.
|
||
- The next tick sends no request under either project, and the
|
||
reconcile records nothing.
|
||
- A 503 on the sibling's task list fails the tick with
|
||
`tracker-unavailable`, and the tick after it still reads the sibling.
|
||
- The root unshared fails the tick with `tracker-forbidden`; shared
|
||
again, the next tick records a root edit. The root deleted fails the
|
||
tick with `tracker-not-found`.
|
||
|
||
The README sentence "Ticks before that reconcile keep reading the
|
||
project" is gone. The paragraph now says: "A tick doesn't read the
|
||
project list, so it keeps reading an archived child, or one moved out
|
||
with a share of its own, until that reconcile. A child that answers 403
|
||
or 404 in a tick (no longer shared, moved under a project the sync bot
|
||
can't read, or deleted) leaves the tree at that tick, and its tasks get
|
||
the same one-by-one read. A 403 or 404 on the root still fails the tick,
|
||
and so does any other failure on a child. A new child joins at the next
|
||
reconcile." The paragraph above it adds "or deleted" and "or a tick
|
||
before that".
|
||
|
||
### Non-blocking
|
||
|
||
**N1: a failed reconcile keeps the previous tree.** New test 11. Two
|
||
children; at the reconcile the first one's install check gets a 503, so
|
||
the walk stops before the second. The reconcile fails with
|
||
`tracker-unavailable`, and the next tick still records an edit on the
|
||
second child and no `task.missing`.
|
||
|
||
**N2: a closed task found through `missing()` takes its own project's
|
||
done bucket.** New test 12. The fake runs an hour behind, so the cursor
|
||
never returns the close. An installed child's task and a watch-only
|
||
child's task are closed. The tick reads both through `missing()` (`via:
|
||
task`): the installed one gets the child's done bucket, which differs
|
||
from the root's, and the watch-only one gets bucket 0.
|
||
|
||
**N3: the leave-tree tombstone keeps the last recorded `updated`.** Test
|
||
9 now edits the reparented task after the tick and before the reconcile,
|
||
with the fake's clock two seconds ahead, so the task's `updated`
|
||
changes. The reconcile reads the edit through `missing()`, and the
|
||
tombstone still carries the `updated` of the task's first snapshot.
|
||
|
||
**Dewey's three gaps.** Each passed on the round 1 candidate, as Dewey
|
||
said; the mutants below show what each test now catches.
|
||
|
||
- `reconcile-done-root-bucket`: new test 13. A task in an installed child
|
||
and one in a watch-only child are closed before startup. The startup
|
||
reconcile records the child's done bucket and bucket 0.
|
||
- `tick-done-root-bucket`: new test 14, the same check on the tick path,
|
||
where the cursor returns the close. The tick has two such lines: a
|
||
closed task the open read left out, and one closed between the open
|
||
read and the cursor walk. The test covers both. A `fetch` wrapper
|
||
closes a second task in each project right after that project's first
|
||
request in the tick, which is its open read.
|
||
- `tree-check-before-moved`: new test 15. The child is archived and its
|
||
task moved to the root at the same time. The reconcile records `moved`
|
||
to the root, not `no-access`, and a snapshot under the new ref.
|
||
|
||
### Tests, round 2
|
||
|
||
`packages/tasks/tests/tree.test.mjs` has 17 tests (11 in round 1). Test
|
||
9 changed; tests 10 to 15 are new. Round 1's tests 10 and 11 are now 16
|
||
and 17.
|
||
|
||
### Mutants, round 2
|
||
|
||
`out/mutants-round2.sh` uses round 1's method: edit one source file,
|
||
run the tests, restore the file from the worktree index. Its output is
|
||
`out/mutants-round2.txt`. Each mutant ran only the test file meant to
|
||
fail it (`tree.test.mjs`, or `config.test.mjs` for the config one),
|
||
because the row 55 mutant run shared the machine. The gate below runs
|
||
every suite on the candidate. All 18 fail at least one test:
|
||
|
||
| Mutant | Fails |
|
||
|---|---|
|
||
| round 1's five | as in round 1, with round 1's test 11 now 17; `no-watch-only-bucket` also fails 10, 12, 13 and 14 |
|
||
| `r2-child-refuses`: a child's 403 or 404 fails the tick | 9, 10 |
|
||
| `r2-skip-keeps-child`: skip the child, keep it in the tree | 10 |
|
||
| `r2-root-drops`: the root is dropped too | 10 |
|
||
| `r2-any-error-drops`: any refusal drops a child | 10 |
|
||
| `r2-403-only`: a 404 still fails the tick | 10 |
|
||
| `r2-404-only`: a 403 still fails the tick | 9, 10 |
|
||
| `tree-early`: `discover()` sets `ctx.tree` before the walk | 11 |
|
||
| `missing-done-root-bucket`: `missing()` uses the root's done bucket | 12 |
|
||
| `leave-tree-no-keep-updated`: the leave-tree tombstone takes the read's `updated` | 9 |
|
||
| `reconcile-done-root-bucket`: the reconcile uses the root's done bucket | 13 |
|
||
| `tick-done-root-bucket`: a closed task only the cursor returned takes the root's done bucket | 14, 17 |
|
||
| `tick-done-root-bucket-board`: a task the open read showed and the cursor returned closed takes the root's done bucket | 14 |
|
||
| `tree-check-before-moved`: the tree check runs before the moved check | 15 |
|
||
|
||
`tree-early`, `missing-done-root-bucket` and `leave-tree-no-keep-updated`
|
||
use Filbert's names, and the last four use Dewey's, with
|
||
`tick-done-root-bucket` split in two for the tick's two lines. Their
|
||
edits may differ from mine.
|
||
|
||
### Gate, round 2
|
||
|
||
The gate ran in a detached worktree of `122b1223` with the round 2
|
||
`build.patch` applied and `node_modules` linked, using round 1's script
|
||
with a new output directory (`out/gate-round2/gate.sh`). Each log is in
|
||
`out/gate-round2/`. test-task ran with `DOCKER_HOST=unix:///nonexistent`,
|
||
so its Docker groups printed skip, as in round 1.
|
||
|
||
Every suite passed (`out/gate-round2/summary.txt`):
|
||
|
||
| Suite | rc | Result |
|
||
|---|---|---|
|
||
| `packages/tasks` | 0 | 69 passed, 0 failed (63 in round 1) |
|
||
| `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 |
|
||
|
||
A `gate_default` Docker network appeared during the run with no
|
||
containers, and I removed it.
|
||
|
||
Candidate manifest sha256: `55316d885d51e0d089173476dbccacfdcf254e51c37aecff0f556ae80ca99179`.
|
||
|
||
|
||
## Round 3
|
||
|
||
Round 2 drew changes requested from both reviewers on candidate
|
||
`55316d88`: Filbert's comment 27296 (queue rev 426) and Dewey's comment
|
||
27297 (rev 427). Both found the same blocking defect, Filbert's R2 and
|
||
Dewey's B1. Both confirm R1 is fixed, Dewey on the poll timer path too.
|
||
`build.patch` and `candidate-manifest.sha256` now hold the round 3
|
||
candidate; the round 2 versions are in this packet's history at
|
||
`620c0e89`. `files.txt` is unchanged: 13
|
||
files, +864/−73 against `edc5de71`. Round 3 changed four of them:
|
||
`sync.mjs`, `fake.mjs`, `tree.test.mjs` and the tasks `README.md`. The
|
||
gate applied the patch to `ae428a54`; no candidate file changed between
|
||
`edc5de71` and `ae428a54`.
|
||
|
||
### Blocking
|
||
|
||
**R2 (Filbert) and B1 (Dewey): a deleted kanban view looked like a
|
||
deleted project.** `outcome()` in `vikunja.mjs` maps every 404 except
|
||
code 4002 to `missing`, which is `tracker-not-found`. A board read on a
|
||
child whose kanban view was deleted answers 404 with code 13001, so the
|
||
round 2 tick dropped a child the sync bot still reads. `missing()` then
|
||
wrote `no-access` tombstones for its open tasks, and the next reconcile
|
||
read them back watch-only. Round 1 failed that tick. Round 2 wrote false
|
||
evidence to the ledger, which is worse.
|
||
|
||
I took Filbert's first fix. `leftTree()` is now async. After a
|
||
`tracker-forbidden` or `tracker-not-found` refusal on a child, the tick
|
||
reads `GET /projects/<id>` with the sync token and drops the child only
|
||
if that read answers 403 or 404. Any other answer rethrows the board or
|
||
walk refusal, so the tick fails as in round 1. Dewey's comment gives the
|
||
real instance's answers: 200 for a deleted view, 403 for a hidden
|
||
project, 404 for a deleted one, 200 for an archived one. The sync token
|
||
already has `projects read_one`, so nothing changes in the scopes.
|
||
|
||
I chose the confirm read over Filbert's second option, dropping only on
|
||
403 and on 404 code 3001. That option depends on which code each route
|
||
returns, and I haven't probed which one the board route gives for a
|
||
deleted project. The confirm read asks the question the drop depends
|
||
on. It costs one GET, and only on a tick where
|
||
a child refused.
|
||
|
||
The tradeoff: a child with a deleted kanban view fails every tick until
|
||
the next reconcile, as in round 1, and the poll timer's `notify` skips
|
||
while it does. The reconcile's `inspect()` finds no kanban view and
|
||
reads the child watch-only, or reads the new view if someone installed
|
||
one. I didn't make the tick read it watch-only on the spot. That would
|
||
put the reconcile's install check into the tick, and a deleted view is
|
||
an operator change the reconcile already handles.
|
||
|
||
`fake.mjs` gained `deleteView()`. It drops the view, its buckets and the
|
||
tasks' places in them. The project still reads, and a read of the view
|
||
answers 404 with code 13001, the code the fake's view routes already
|
||
return.
|
||
|
||
New test 18, "a child whose kanban view is deleted still refuses the
|
||
tick, and the reconcile reads it watch-only":
|
||
|
||
- An installed child's kanban view is deleted, and the root has an edit.
|
||
The tick reads `GET /projects/<child>`, fails with
|
||
`tracker-not-found`, and writes no event or snapshot, the root's edit
|
||
included. The business stays ready.
|
||
- A 403 on the board of a child the bot still reads fails the tick with
|
||
`tracker-forbidden`.
|
||
- The reconcile records nothing missing, gives the child's task bucket 0
|
||
and records the root's edit.
|
||
- Dewey's 401 case: a 401 on the child's task list after the root read
|
||
succeeded fails the tick with `tracker-unauthorized`, and the tick
|
||
makes no project read. A 404 queued on the project read stays unused,
|
||
so a 401 can't drop a child whatever that read would say.
|
||
- A 404 on the task list with a 503 on the project read fails the tick
|
||
with `tracker-not-found`, and the child stays. The next tick records
|
||
an edit on it.
|
||
|
||
Dewey asked that a root edit in the same tick be recorded. On the drop
|
||
path, test 10 already does that: a deleted child and a hidden one drop,
|
||
and the root's and a sibling's edits from that tick are recorded. On
|
||
the deleted-view path the tick fails closed, so it records nothing, and
|
||
test 18 shows the reconcile recording the root's edit instead. I didn't
|
||
take the watch-only path, so Dewey's second ask doesn't apply.
|
||
|
||
The README paragraph now says a child leaves the tree "only if `GET
|
||
/projects/<id>` then answers 403 or 404 too", that a deleted kanban view
|
||
keeps the child and fails the tick until the next reconcile reads it
|
||
again ("watch-only if it has no installed kanban view"), and that a
|
||
dropped child the sync bot can read again comes back at the next
|
||
reconcile.
|
||
|
||
### Non-blocking
|
||
|
||
**Filbert's N1: a dropped child comes back after a reconcile.** New
|
||
test 19. A child moved under a project the bot can't read drops at the
|
||
tick with `no-access`. Moved back under the root, the next tick still
|
||
sends no request under it, and the reconcile records the task again
|
||
with the child's installed bucket. The task's snapshots read bucket,
|
||
`no-access`, bucket. A tick after that records an edit. Filbert's probe
|
||
used a one-time fault on the board; with the confirm read a fault alone
|
||
no longer drops a child, so the test moves the project instead. The
|
||
test passes on round 2's code as well. It pins the return, it doesn't
|
||
fix anything.
|
||
|
||
**Dewey's `r2-unauthorized-drops`.** Killed now, by test 18's 401 case.
|
||
With the confirm read the mutant only matters when the project read
|
||
answers 403 or 404 after a 401, which is the case the test sets up.
|
||
|
||
**Unchanged survivors.** Filbert's round 1 `keep-negative`,
|
||
`no-parent-shape` and `no-sort`, and the two equivalent mutants, still
|
||
survive. They were non-blocking in round 1 and round 2, and I didn't
|
||
take them this round.
|
||
|
||
### Tests, round 3
|
||
|
||
`packages/tasks/tests/tree.test.mjs` has 19 tests (17 in round 2). Tests
|
||
18 and 19 are new and sit at the end, so round 2's numbers hold. Test 18
|
||
fails on round 2's `sync.mjs`; test 19 passes on it, as said above.
|
||
|
||
### Mutants, round 3
|
||
|
||
`out/mutants-round3.sh` uses the same method: edit one source file, run
|
||
the test file meant to fail it, restore the file from the worktree
|
||
index, which holds the round 3 candidate. The round 2 lines that edited
|
||
`leftTree()` are retargeted at the new function. Output is
|
||
`out/mutants-round3.txt`. All 23 fail at least one test:
|
||
|
||
| Mutant | Fails |
|
||
|---|---|
|
||
| round 1's five and round 2's seven non-R1 mutants | as in round 2; `no-watch-only-bucket` also fails 18 |
|
||
| `r2-child-refuses` | 9, 10, 18, 19 |
|
||
| `r2-skip-keeps-child` | 10, 19 |
|
||
| `r2-root-drops` | 10 |
|
||
| `r2-any-error-drops` | 18 |
|
||
| `r2-403-only` | 10, 18 |
|
||
| `r2-404-only` | 9, 10, 19 |
|
||
| `r3-no-confirm`: drop on the refusal alone, no project read | 18 |
|
||
| `r3-confirm-403-only`: drop only when the project read answers 403 | 10 |
|
||
| `r3-confirm-404-only`: drop only when it answers 404 | 9, 10, 19 |
|
||
| `r3-confirm-not-200`: drop on any answer but 200 | 18 |
|
||
| `r2-unauthorized-drops` (Dewey's name): a 401 drops a child too | 18 |
|
||
|
||
`r2-any-error-drops` failed only test 10 in round 2; now test 18 fails
|
||
it, because test 10's 503 sibling no longer reaches a drop: the project
|
||
read answers 200.
|
||
|
||
### Gate, round 3
|
||
|
||
The gate ran in a detached worktree of `ae428a54` with the round 3
|
||
`build.patch` applied and `node_modules` linked
|
||
(`out/gate-round3/gate.sh`). `out/gate-round3/apply.txt` has the
|
||
manifest check. The script now runs the webui suite too; round 2's
|
||
didn't. test-task ran with `DOCKER_HOST=unix:///nonexistent`, so its
|
||
Docker groups printed skip, as before. Each log is in
|
||
`out/gate-round3/`.
|
||
|
||
Every suite passed (`out/gate-round3/summary.txt`):
|
||
|
||
| Suite | rc | Result |
|
||
|---|---|---|
|
||
| `packages/tasks` | 0 | 71 passed, 0 failed (69 in round 2) |
|
||
| `packages/business` | 0 | 60 passed, 0 failed |
|
||
| `packages/bus` | 0 | 80 passed, 0 failed |
|
||
| `packages/cli` | 0 | 83 passed, 0 failed |
|
||
| `packages/webui` | 0 | 40 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 |
|
||
|
||
The bus count went from 74 to 80 because rows landed on the base
|
||
between the two gates, not because of this candidate. A `gate_default`
|
||
Docker network with no containers was left after the run, and I removed
|
||
it.
|
||
|
||
Candidate manifest sha256: `8b81e836c9d1363383dac8900dd881056d27e663a9c4aa32d2d1ee7639cf18ca`.
|