diff --git a/agents/darkwing/work/queue-62/BUILD.md b/agents/darkwing/work/queue-62/BUILD.md new file mode 100644 index 000000000..dd7080466 --- /dev/null +++ b/agents/darkwing/work/queue-62/BUILD.md @@ -0,0 +1,225 @@ +# 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. diff --git a/agents/darkwing/work/queue-62/base.txt b/agents/darkwing/work/queue-62/base.txt new file mode 100644 index 000000000..8beca1013 --- /dev/null +++ b/agents/darkwing/work/queue-62/base.txt @@ -0,0 +1 @@ +edc5de71663062e1e1d2dd750fd0c15e522208a5 diff --git a/agents/darkwing/work/queue-62/build.patch b/agents/darkwing/work/queue-62/build.patch new file mode 100644 index 000000000..2e174e635 --- /dev/null +++ b/agents/darkwing/work/queue-62/build.patch @@ -0,0 +1,1057 @@ +diff --git a/packages/business/src/vars.mjs b/packages/business/src/vars.mjs +index 9591ce52..69b6ea57 100644 +--- a/packages/business/src/vars.mjs ++++ b/packages/business/src/vars.mjs +@@ -64,6 +64,11 @@ function list(check, { nonEmpty = true } = {}) { + return (v, key) => requireDistinctList(v, `variable ${key}`, (item) => check(item, `${key} entry`), { nonEmpty }); + } + ++function boolean(v, key) { ++ if (typeof v !== "boolean") refuse(`variable ${key} must be true or false (got ${JSON.stringify(v)})`); ++ return v; ++} ++ + function absolutePath(v, key) { + if (typeof v !== "string" || !v.startsWith("/") || v.includes("\0")) refuse(`variable ${key} must be an absolute path`); + return v; +@@ -94,6 +99,8 @@ export const REGISTRY = Object.freeze({ + + // Project. + "tracker.project": def(["project"], (v, key) => requirePositiveInt(v, `variable ${key}`)), ++ // On: the tracker project is the root of a tree, and sync reads its descendants too. ++ "tracker.descendants": def(["project"], boolean, { default: false }), + "git.workingBranch": def(["project"], branch), + "git.protectedBranches": def(["project"], list(branch)), + suites: def(["project"], list(string(300))), +diff --git a/packages/business/tests/vars.test.mjs b/packages/business/tests/vars.test.mjs +index 251dbbd5..ab7d2b3c 100644 +--- a/packages/business/tests/vars.test.mjs ++++ b/packages/business/tests/vars.test.mjs +@@ -34,7 +34,7 @@ test("unknown keys and wrong layers refuse", () => { + test("types", () => { + const ok = (vars, layer) => assert.deepEqual({ ...checkVars(vars, layer, "v") }, vars); + ok({ "tracker.baseUrl": "https://tasks.example:3456", "tracker.reconcileMinutes": 1, "tracker.pollSeconds": 10, "human.discordUserId": "123456789012345678" }, "business"); +- ok({ "tracker.project": 3, "git.workingBranch": "refactor", "git.protectedBranches": ["main", "next"], suites: ["scripts/test-task.sh"], "issues.repo": "mosaicstack/stack" }, "project"); ++ ok({ "tracker.project": 3, "git.workingBranch": "refactor", "git.protectedBranches": ["main", "next"], suites: ["scripts/test-task.sh"], "issues.repo": "mosaicstack/stack", "tracker.descendants": true }, "project"); + ok({ harness: "claude-code", model: "claude-opus-5-5", thinking: "high", "limits.tools": [], "limits.network": "none", "limits.authority": ["message.send"] }, "agent"); + + const bad = [ +@@ -50,6 +50,9 @@ test("types", () => { + [{ "human.discordUserId": 123456789012345678 }, "business", /as a string/], + [{ "human.discordUserId": "0123456789012345678" }, "business", /Discord user id/], + [{ "tracker.project": 0 }, "project", /positive integer/], ++ [{ "tracker.descendants": "true" }, "project", /true or false/], ++ [{ "tracker.descendants": 1 }, "project", /true or false/], ++ [{ "tracker.descendants": true }, "business", /business layer \(allowed: project\)/], + [{ "git.workingBranch": "-x" }, "project", /git branch name/], + [{ "git.workingBranch": "a..b" }, "project", /git branch name/], + [{ "git.workingBranch": "x.lock" }, "project", /git branch name/], +@@ -78,6 +81,8 @@ test("merge: defaults, then the most specific layer wins", () => { + assert.equal(vars["tracker.reconcileMinutes"], 60); + assert.equal(provenance["tracker.reconcileMinutes"], "default"); + assert.equal(vars["tracker.kind"], "vikunja"); ++ assert.equal(vars["tracker.descendants"], false); ++ assert.equal(provenance["tracker.descendants"], "default"); + assert.equal(vars.harness, undefined); + assert.equal(provenance.harness, undefined); + }); +diff --git a/packages/cli/src/config.mjs b/packages/cli/src/config.mjs +index d7315d6a..e6373532 100644 +--- a/packages/cli/src/config.mjs ++++ b/packages/cli/src/config.mjs +@@ -78,6 +78,7 @@ function trackerFor(system, b, warn) { + project: v["tracker.project"], + pollSeconds: v["tracker.pollSeconds"], + reconcileMinutes: v["tracker.reconcileMinutes"], ++ descendants: v["tracker.descendants"], + }; + } + +diff --git a/packages/cli/tests/config.test.mjs b/packages/cli/tests/config.test.mjs +index cf384fc3..b28cedaa 100644 +--- a/packages/cli/tests/config.test.mjs ++++ b/packages/cli/tests/config.test.mjs +@@ -23,7 +23,11 @@ test("trackers come from the tracker.* variables of the one project that names a + const f = fixture(root); + writeJson(join(root, "project", ".mosaic", "project.json"), { projectVersion: 1, id: "stack", vars: { "tracker.project": 12 } }); + const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env }); +- assert.deepEqual(boot.trackers, { acme: { baseUrl: "http://127.0.0.1:3456", project: 12, pollSeconds: 60, reconcileMinutes: 60 } }); ++ assert.deepEqual(boot.trackers, { acme: { baseUrl: "http://127.0.0.1:3456", project: 12, pollSeconds: 60, reconcileMinutes: 60, descendants: false } }); ++ // Decision 87: the project's tracker.descendants reaches the adapter's boot entry. ++ writeJson(join(root, "project", ".mosaic", "project.json"), { projectVersion: 1, id: "stack", vars: { "tracker.project": 12, "tracker.descendants": true } }); ++ const tree = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env }); ++ assert.equal(tree.trackers.acme.descendants, true); + }); + + test("with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict", (t) => { +@@ -35,7 +39,7 @@ test("with two projects, the one that sets tracker.project supplies the tracker + writeJson(join(root, "project", ".mosaic", "project.json"), { projectVersion: 1, id: "stack", vars: { "tracker.project": 12 } }); + writeJson(join(root, "web", ".mosaic", "project.json"), { projectVersion: 1, id: "web", vars: {} }); + const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env }); +- assert.deepEqual(boot.trackers, { acme: { baseUrl: "http://127.0.0.1:3456", project: 12, pollSeconds: 60, reconcileMinutes: 60 } }); ++ assert.deepEqual(boot.trackers, { acme: { baseUrl: "http://127.0.0.1:3456", project: 12, pollSeconds: 60, reconcileMinutes: 60, descendants: false } }); + }); + + test("two projects that each name a tracker project refuse, since the boot shape holds one", (t) => { +diff --git a/packages/cli/tests/trackers-boot.test.mjs b/packages/cli/tests/trackers-boot.test.mjs +index b7710013..d7cef73a 100644 +--- a/packages/cli/tests/trackers-boot.test.mjs ++++ b/packages/cli/tests/trackers-boot.test.mjs +@@ -41,7 +41,7 @@ test("bootConfig trackers reach the S3 adapter in the real broker child, which g + + mkdirSync(f.dataRoot, { recursive: true, mode: 0o700 }); + const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env }); +- assert.deepEqual(boot.trackers, { acme: { baseUrl: srv.url, project, pollSeconds: 60, reconcileMinutes: 60 } }); ++ assert.deepEqual(boot.trackers, { acme: { baseUrl: srv.url, project, pollSeconds: 60, reconcileMinutes: 60, descendants: false } }); + const host = await startHost({ boot, business: "acme", log: () => {} }); + t.after(() => host.close(0)); + +diff --git a/packages/tasks/README.md b/packages/tasks/README.md +index 1b764e82..ae2789be 100644 +--- a/packages/tasks/README.md ++++ b/packages/tasks/README.md +@@ -1,7 +1,7 @@ + # Tasks adapter, slice 1 S3 + + This package connects the broker to Vikunja v2. It handles the eight task +-verbs, polls each business's project, and records what it reads as task ++verbs, polls each business's project (or project tree), and records what it reads as task + snapshots and events in `bus.sqlite`. It holds no token. Every call goes + through the broker's `credentials.use()`, so the token goes into the + Authorization header and nowhere else. No line it logs carries a token, +@@ -20,7 +20,7 @@ is present: + ```js + {op: 'boot', config: {dataRoot, businesses, readers, repoRoots, + trackers: {acme: {baseUrl: 'https://tasks.example.org', project: 12, +- pollSeconds: 30, reconcileMinutes: 60}}}} ++ pollSeconds: 30, reconcileMinutes: 60, descendants: false}}}} + ``` + + `baseUrl` is the origin only, for example `https://tasks.example.org`. The +@@ -28,7 +28,8 @@ adapter adds `/api/v2` and refuses a URL with a path with + `tracker-config`. S1's business validator accepts a path today, so a + wrong value fails at boot, not at validation. That's a follow-up for S1. + `pollSeconds` defaults to 30 and must be at least 10. `reconcileMinutes` +-defaults to 60 and must be at least 1. ++defaults to 60 and must be at least 1. `descendants` defaults to false ++and must be a boolean. See "Project trees". + + From the business definition (`busBusiness` output) the adapter needs: + +@@ -88,7 +89,9 @@ until it passes every verb for that business refuses with + sees a label only through its owner (probes.md, L), so `svc-$BIZ` + owns the labels. + +-Then it runs one reconcile, and the business is `ready`. ++Then it runs one reconcile, and the business is `ready`. With ++`descendants` on, that reconcile also reads the project tree, so a 401 on ++the project list refuses startup too. + + A startup refused with `tracker-unavailable`, `tracker-rate-limited` or + `service-failed` is tried again at the next poll. Any other refusal +@@ -239,6 +242,84 @@ mtime moved. + `close()` stops the timers and waits for the queue. A verb still queued + at that point refuses with `tracker-closed`. + ++## Project trees ++ ++`tracker.descendants` is a project-layer business variable, false by ++default. `packages/cli/src/config.mjs` passes it to the adapter as ++`descendants` (lead decision 87). Off, the adapter reads one project, ++exactly as before, and never reads the project list. ++ ++On, the tracker project is the root of a tree. The sync token then also ++needs `projects` `read_all`, for `GET /projects`. Without it startup ++refuses with `tracker-unauthorized`. At startup and at each reconcile the ++adapter reads that list, every page, and follows `parent_project_id` down ++from the root to every depth. It skips negative ids (saved filters and ++Favorites) and archived projects. Access in Vikunja inherits down the ++tree, so a share on the root is enough for the sync bot to read every ++child. The adapter writes no project, view or bucket, in the tree or out ++of it. ++ ++Startup checks the root's install as before. A root that fails refuses ++the business with `tracker-install`. Each child gets the same install ++checks at discovery: ++ ++- A child that passes is installed. The adapter reads it through its ++ board, like the root. ++- A child that fails is watch-only, and never refuses the business. The ++ adapter reads its open tasks from the task list and records them with ++ `bucket: 0` (`WATCH_ONLY`), because it has no board it trusts. Vikunja ++ bucket ids start at 1, so 0 is never a real bucket. Nothing maps 0 to ++ a bucket or a state. A move between a watch-only child's buckets ++ changes no recorded field. Closing or reopening the task still changes ++ `done`. ++- A child whose views or buckets can't be read (5xx, no answer) is ++ neither. That refuses with `tracker-unavailable`, which is transient. ++ At startup the adapter tries again at the next poll. At a later ++ reconcile the reconcile fails, the tree from the last one stays, and ++ the next reconcile tries again. ++ ++A task in a bucket outside the five, like a `Backlog` someone added, ++keeps that bucket's raw id in its snapshot. ++ ++Refs stay `vikunja:/`. Verbs act on the root only. A verb ++on a child's task refuses with `task-project`, as it does for any other ++project. ++ ++A task moved between two projects of the tree gets `task.missing` with ++reason `moved` and the new project under its old ref, and a first ++snapshot under its new ref. A task moved out of the tree gets the same ++tombstone and nothing after it. ++ ++A project leaves the tree when it is archived, moved under a parent ++outside the tree, or no longer shared with the sync bot. The next ++reconcile drops it, and each of its tasks the bus holds is read once: ++ ++- The sync bot can still read the task, in that project: `task.missing` ++ with `no-access`. The business no longer reaches it. ++- The read answers 403: `no-access`. 404: `not-found`. ++- The task moved to another project at the same time: `moved`. ++ ++The tombstone keeps the task's last `updated`. Nothing in the ledger is ++rewritten: the earlier snapshots stay and the tombstone is a new row. ++Ticks before that reconcile keep reading the project, and a new child ++joins at the next reconcile the same way. ++ ++Each tick reads per project. An installed project costs the board and ++the cursor walk, a watch-only one its open task list and the cursor ++walk. That is two GETs per project, plus one per extra page of 50. A root ++with three watch-only children, like the bus's #19, costs 8 GETs on a ++quiet tick. On top of that a tick reads comments once per task the cursor ++returned with a new `updated`, and reads each open task that left a ++board once. A reconcile adds `GET /projects` (one page per 50 projects), ++then each child's views and kanban buckets, then two GETs per project. ++`tests/tree.test.mjs` counts both for a three-level tree. ++ ++Vikunja v2.7.0 can also list tasks across every project the token reads ++in one walk, `GET /api/v2/tasks` with a `project in ...` filter ++(`agents/darkwing/work/queue-62/probes.md`). That would make a tick two ++GETs whatever the tree's size. The adapter doesn't use it yet: it waits ++on a live read with the sync key, and a switch is a follow-up row. ++ + ## Refusals + + | Code | Meaning | +@@ -277,6 +358,15 @@ every user or task any recording held, so a key one route adds, like + The shape test catches a fake that invents a field, not one that puts a + real field on the wrong route. + ++The fake also models project trees: `parent_project_id`, the project ++list with archived projects and saved filters, and access inherited from ++an ancestor's share (`project_access.go`: the nearest grant wins, admin ++anywhere wins). No recording holds a `GET /projects` list, so ++`shapes.test.mjs` checks its items against the recorded ++`GET /projects/{id}` object, which has `parent_project_id` and ++`is_archived`. ++`tests/tree.test.mjs` covers `descendants`. ++ + Nothing in these tests reaches a real Vikunja. The probes and the live + run use a scratch container on 127.0.0.1, never a shared instance. + +diff --git a/packages/tasks/live/README.md b/packages/tasks/live/README.md +index 186c4f2e..478de618 100644 +--- a/packages/tasks/live/README.md ++++ b/packages/tasks/live/README.md +@@ -77,6 +77,12 @@ It exits 0 when every step went as expected, 1 when one didn't or the + run stopped, and 2 on a bad command line or setup file. The task stays + in the project, done, with "Safe to delete" in its description. + ++## Project trees ++ ++The run reads the setup's one project, with `descendants` off. It never ++reads a child. A tree is checked with read-only GETs instead, listed in ++`agents/darkwing/work/queue-62/probes.md`. ++ + ## The log + + `out/live-run.txt`, mode 0600, holds verbs, refusal codes, counts and +diff --git a/packages/tasks/src/adapter.mjs b/packages/tasks/src/adapter.mjs +index d564c645..f0f8cacf 100644 +--- a/packages/tasks/src/adapter.mjs ++++ b/packages/tasks/src/adapter.mjs +@@ -5,7 +5,7 @@ import { startup } from './startup.mjs'; + import { tick, reconcile } from './sync.mjs'; + import { verbs } from './verbs.mjs'; + // S3's adapter for startBroker({tasks}). `trackers` is the host's plain-data boot config: +-// {: {baseUrl, project, pollSeconds, reconcileMinutes}}. A business with no entry has ++// {: {baseUrl, project, pollSeconds, reconcileMinutes, descendants}}. A business with no entry has + // no tracker, and its task verbs refuse. Startup runs in the background so the broker boots + // without waiting on Vikunja; verbs refuse with tracker-starting until it passes. + export const TIMEOUT = 60000; +@@ -18,8 +18,10 @@ function settings(name, t, b) { + apiRoot(t.baseUrl); + const pollSeconds = t.pollSeconds ?? 30, + reconcileMinutes = t.reconcileMinutes ?? 60; ++ const descendants = t.descendants ?? false; + if (!positive(t.project) || !Number.isSafeInteger(pollSeconds) || pollSeconds < 10 || !positive(reconcileMinutes)) + throw new BusError('tracker-config'); ++ if (typeof descendants !== 'boolean') throw new BusError('tracker-config'); + if (!positive(b.tracker?.sync?.botId)) throw new BusError('tracker-config'); + const roles = {}; + for (const [role, r] of Object.entries(b.roles ?? {})) +@@ -34,7 +36,7 @@ function settings(name, t, b) { + if (!positive(id)) throw new BusError('tracker-config'); + labels.set(id, title); + } +- return { business: name, baseUrl: t.baseUrl, project: t.project, pollSeconds, reconcileMinutes, roles, pm: pms[0], labels }; ++ return { business: name, baseUrl: t.baseUrl, project: t.project, pollSeconds, reconcileMinutes, descendants, roles, pm: pms[0], labels }; + } + // Files behind the business's Vikunja references, to notice a token rotated on disk. + function files(b) { +@@ -74,6 +76,7 @@ export function tasksAdapter({ + call, + per: 50, + view: null, ++ tree: null, + bots: new Set([businesses[name].tracker.sync.botId, ...Object.values(s.roles).map((r) => r.botId)]), + sync: use('@sync'), + as: (role, method, path, opts) => use(role)(method, path, opts), +diff --git a/packages/tasks/src/fake.mjs b/packages/tasks/src/fake.mjs +index fc55529a..2a9cfc14 100644 +--- a/packages/tasks/src/fake.mjs ++++ b/packages/tasks/src/fake.mjs +@@ -6,9 +6,13 @@ import { NULL_DATE } from './digest.mjs'; + // scope 401 before 404, bucket_id 0 outside the board and the move, `updated` reported to the + // second but filtered on the stored value, moves between open buckets not bumping `updated`, + // silent unassign, 403 on removing an absent label, and bot label visibility through the owner. ++// Project trees follow the v2.7.0 source (row 62 probes.md): a grant on a project reaches every ++// descendant, the nearest grant wins unless one on the path is admin, archiving a project archives ++// its descendants, and the project list leaves out archived projects unless asked for them. + const ROUTES = [ + ['GET', /^\/info$/, null, 'info'], + ['GET', /^\/labels$/, ['labels', 'read_all'], 'labels'], ++ ['GET', /^\/projects$/, ['projects', 'read_all'], 'projects'], + ['GET', /^\/projects\/(\d+)$/, ['projects', 'read_one'], 'project'], + ['GET', /^\/projects\/(\d+)\/views$/, ['projects_views', 'read_all'], 'views'], + ['GET', /^\/projects\/(\d+)\/views\/(\d+)\/buckets$/, ['projects', 'views_buckets'], 'buckets'], +@@ -80,6 +84,7 @@ export class FakeVikunja { + #tasks = new Map(); + #labels = new Map(); + #faults = []; ++ #pseudo = []; + constructor({ version = 'v2.7.0', now = () => Date.now() } = {}) { + this.version = version; + this.#now = now; +@@ -98,9 +103,9 @@ export class FakeVikunja { + revoke(token) { + this.#tokens.delete(token); + } +- project(title, owner) { ++ project(title, owner, { parent = 0 } = {}) { + const id = ++this.#seq.project; +- this.#projects.set(id, { id, title, owner, shares: new Map(), index: 0 }); ++ this.#projects.set(id, { id, title, owner, parent, archived: !!this.#projects.get(parent)?.archived, shares: new Map(), index: 0 }); + for (const [kind, position] of [ + ['list', 100], + ['gantt', 200], +@@ -115,6 +120,21 @@ export class FakeVikunja { + share(project, user, permission = 0) { + this.#projects.get(project).shares.set(user, permission); + } ++ unshare(project, user) { ++ this.#projects.get(project).shares.delete(user); ++ } ++ // Moves a project under another parent, or to the top level with 0. ++ reparent(project, parent) { ++ this.#projects.get(project).parent = parent; ++ } ++ // Vikunja writes is_archived down the tree, so a descendant reads as archived too. ++ archive(project, archived = true) { ++ for (const p of this.#projects.values()) if (p.id === project || this.#ancestors(p.id).includes(project)) p.archived = archived; ++ } ++ // A saved filter or Favorites: a negative id in the project list, as Project.ReadAll appends. ++ pseudoProject(user, id = -1, title = 'Favorites') { ++ this.#pseudo.push({ id, title, user }); ++ } + bucket(view, title) { + const id = ++this.#seq.bucket; + this.#buckets.set(id, { id, title, view, position: id * 100 }); +@@ -231,11 +251,39 @@ export class FakeVikunja { + }); + return id; + } ++ // Parents, nearest first. A cycle can't be made through the setup methods the tests use. ++ #ancestors(project) { ++ const out = []; ++ for (let p = this.#projects.get(project)?.parent; p && !out.includes(p); p = this.#projects.get(p)?.parent) out.push(p); ++ return out; ++ } ++ // project_access.go: owner or a grant on the project or any ancestor; admin anywhere wins, ++ // otherwise the nearest grant. + #access(user, project) { +- const p = this.#projects.get(project); +- if (!p) return null; +- if (p.owner === user) return 2; +- return p.shares.has(user) ? p.shares.get(user) : null; ++ if (!this.#projects.has(project)) return null; ++ let nearest = null; ++ for (const id of [project, ...this.#ancestors(project)]) { ++ const p = this.#projects.get(id); ++ if (!p) break; ++ if (p.owner === user) return 2; ++ const a = p.shares.has(user) ? p.shares.get(user) : null; ++ if (a === 2) return 2; ++ nearest ??= a; ++ } ++ return nearest; ++ } ++ #projectJson(p) { ++ return { ++ id: p.id, ++ title: p.title, ++ description: '', ++ owner: this.#userJson(p.owner), ++ parent_project_id: p.parent, ++ is_archived: p.archived, ++ position: p.id * 100, ++ created: second(0), ++ updated: second(0), ++ }; + } + #userJson(id) { + const u = this.#users.get(id); +@@ -378,7 +426,15 @@ export class FakeVikunja { + _project(user, [p]) { + const r = this.#projectFor(user, p); + if (r.error) return r.error; +- return { status: 200, body: { id: p, title: r.p.title, description: '', owner: this.#userJson(r.p.owner), is_archived: false } }; ++ return { status: 200, body: this.#projectJson(r.p) }; ++ } ++ _projects(user, _a, q) { ++ const archived = q.get('is_archived') === 'true'; ++ const list = [...this.#projects.values()] ++ .filter((p) => this.#access(user, p.id) !== null && (archived || !p.archived)) ++ .map((p) => this.#projectJson(p)); ++ for (const x of this.#pseudo) if (x.user === user) list.push({ id: x.id, title: x.title, description: '', owner: this.#userJson(user), parent_project_id: 0, is_archived: false }); ++ return { status: 200, body: page(list, q) }; + } + _views(user, [p], q) { + const r = this.#projectFor(user, p); +diff --git a/packages/tasks/src/startup.mjs b/packages/tasks/src/startup.mjs +index 9a2c5bc5..f3bdc3d5 100644 +--- a/packages/tasks/src/startup.mjs ++++ b/packages/tasks/src/startup.mjs +@@ -35,7 +35,8 @@ export async function startup(ctx) { + if (mine.some((s) => s.state === 'expired')) refuse('credential-expired'); + // Sync identity: the control must pass and the write probe must be refused by scope. + expect(await ctx.sync('GET', `/projects/${ctx.project}`), 200, 'tracker-project'); +- await install(ctx); ++ ctx.view = await inspect(ctx, ctx.project); ++ ctx.tree = new Map([[ctx.project, ctx.view]]); + const probe = await ctx.sync('PATCH', `/tasks/${MISSING}`, { body: {} }); + if (probe.status === 404 || probe.status === 403 || outcome(probe) === 'ok') refuse('scope-too-broad'); + expect(probe, 401, 'tracker-unexpected'); +@@ -62,13 +63,14 @@ export async function startup(ctx) { + } + } + // Install checks: one manual kanban view, the five buckets once each, done and default wired. +-async function install(ctx) { +- const views = await all(ctx.syncCall, null, `/projects/${ctx.project}/views`, {}, ctx.per); ++// Returns the view, or refuses tracker-install. A failed read refuses tracker-unavailable. ++async function inspect(ctx, project) { ++ const views = await all(ctx.syncCall, null, `/projects/${project}/views`, {}, ctx.per); + if (!views.ok) refuse('tracker-unavailable'); + const kanban = views.items.filter((x) => x?.view_kind === 'kanban'); + if (kanban.length !== 1 || kanban[0].bucket_configuration_mode !== 'manual') refuse('tracker-install'); + const k = kanban[0]; +- const buckets = await all(ctx.syncCall, null, `/projects/${ctx.project}/views/${k.id}/buckets`, {}, ctx.per); ++ const buckets = await all(ctx.syncCall, null, `/projects/${project}/views/${k.id}/buckets`, {}, ctx.per); + if (!buckets.ok) refuse('tracker-unavailable'); + const ids = {}; + for (const title of BUCKETS) { +@@ -77,7 +79,7 @@ async function install(ctx) { + ids[title] = hits[0].id; + } + if (k.done_bucket_id !== ids.done || k.default_bucket_id !== ids.todo) refuse('tracker-install'); +- ctx.view = { ++ return { + id: k.id, + done: ids.done, + todo: ids.todo, +@@ -85,3 +87,32 @@ async function install(ctx) { + titles: Object.fromEntries(Object.entries(ids).map(([t, id]) => [id, t])), + }; + } ++// With tracker.descendants on, the tracker project is the root of a tree: the root plus every ++// project the sync bot's project list shows under it by parent_project_id, archived ones left out. ++// The first reconcile and each one after it run this. A child that passes the install checks is ++// read through its board; one that fails is watch-only (null) and never refuses the business. ++// ctx.tree maps each project to its view; the root keeps the view startup checked. ++export async function discover(ctx) { ++ const r = await all(ctx.syncCall, null, '/projects', {}, ctx.per); ++ if (!r.ok) refuse(r.r.status === 401 ? 'tracker-unauthorized' : 'tracker-unavailable'); ++ const children = new Map(); ++ for (const p of r.items) { ++ if (!p || typeof p !== 'object' || !Number.isSafeInteger(p.id)) refuse('tracker-shape'); ++ // Saved filters and Favorites come back with negative ids. ++ if (p.id <= 0) continue; ++ if (!Number.isSafeInteger(p.parent_project_id) || p.parent_project_id < 0 || typeof p.is_archived !== 'boolean') refuse('tracker-shape'); ++ if (p.is_archived || p.id === ctx.project) continue; ++ if (!children.has(p.parent_project_id)) children.set(p.parent_project_id, []); ++ children.get(p.parent_project_id).push(p.id); ++ } ++ const tree = new Map([[ctx.project, ctx.view]]); ++ const next = [ctx.project]; ++ while (next.length) ++ for (const id of (children.get(next.shift()) ?? []).sort((a, b) => a - b)) { ++ if (tree.has(id)) continue; ++ tree.set(id, await inspect(ctx, id).catch((e) => (e instanceof BusError && e.code === 'tracker-install' ? null : Promise.reject(e)))); ++ next.push(id); ++ } ++ ctx.tree = tree; ++ return tree; ++} +diff --git a/packages/tasks/src/sync.mjs b/packages/tasks/src/sync.mjs +index 811fb683..118f348e 100644 +--- a/packages/tasks/src/sync.mjs ++++ b/packages/tasks/src/sync.mjs +@@ -1,10 +1,12 @@ + import { BusError } from '../../bus/src/broker.mjs'; + import { outcome, refusal, all } from './vikunja.mjs'; +-import { ref, checkTask, taskFields, digest, tombstone } from './digest.mjs'; ++import { ref, parseRef, checkTask, taskFields, digest, tombstone } from './digest.mjs'; ++import { discover } from './startup.mjs'; + // The sync bot's reads (addendum B sections 3 and 5). A tick reads the whole open board, because a + // move between open buckets doesn't bump `updated`, then the cursor: every task updated since the + // previous tick's start minus WINDOW_MS. The window covers the async bump lag and the one-second + // rounding (probes.md, U and Paging). Overlap is deduped by digest against task_current. ++// With tracker.descendants on, each tick and reconcile does this once per project in ctx.tree. + export const WINDOW_MS = 60000; + const iso = (ms) => new Date(ms).toISOString(); + const floorSecond = (ms) => iso(Math.floor(ms / 1000) * 1000); +@@ -14,11 +16,16 @@ export const normal = (updated) => iso(Date.parse(updated)); + const shape = () => { + throw new BusError('tracker-shape'); + }; ++// A watch-only project has no board the adapter trusts, so its snapshots carry bucket 0: no ++// bucket state. Vikunja bucket ids start at 1, and nothing here reads 0 back as a bucket. ++export const WATCH_ONLY = 0; ++const fieldsFor = (task, bucket) => (bucket === WATCH_ONLY ? { ...taskFields(task, 1), bucket } : taskFields(task, bucket)); ++const doneBucket = (view) => (view ? view.done : WATCH_ONLY); + // The open board, every page. A bucket's tasks are null when it is empty; `count` is the bucket's total. +-export async function board(ctx) { ++export async function board(ctx, project = ctx.project, view = ctx.view) { + const seen = new Map(); + for (let page = 1; page <= 1000; page++) { +- const r = await ctx.sync('GET', `/projects/${ctx.project}/views/${ctx.view.id}/buckets/tasks`, { ++ const r = await ctx.sync('GET', `/projects/${project}/views/${view.id}/buckets/tasks`, { + query: { filter: 'done = false', per_page: ctx.per, page }, + }); + if (outcome(r) !== 'ok') throw new BusError(refusal(r)); +@@ -37,12 +44,17 @@ export async function board(ctx) { + } + throw new BusError('tracker-paging'); + } ++// A watch-only project's open tasks, from its task list. ++async function openTasks(ctx, project, view) { ++ if (view) return board(ctx, project, view); ++ return new Map((await walk(ctx, 'done = false', {}, project)).map((t) => [t.id, { task: t, bucket: WATCH_ONLY }])); ++} + // A list walk keyed by id, so a write landing mid-walk can't shift a page under it. +-export async function walk(ctx, filter, query = {}) { ++export async function walk(ctx, filter, query = {}, project = ctx.project) { + const items = []; + let last = 0; + for (let i = 0; i < 100000; i++) { +- const r = await ctx.sync('GET', `/projects/${ctx.project}/tasks`, { ++ const r = await ctx.sync('GET', `/projects/${project}/tasks`, { + query: { + ...query, + filter: [filter, `id > ${last}`].filter(Boolean).join(' && '), +@@ -56,7 +68,7 @@ export async function walk(ctx, filter, query = {}) { + if (!Array.isArray(r.json?.items)) shape(); + for (const t of r.json.items) { + checkTask(t); +- if (t.id <= last || t.project_id !== ctx.project) shape(); ++ if (t.id <= last || t.project_id !== project) shape(); + last = t.id; + items.push(t); + } +@@ -87,9 +99,9 @@ async function foreignComments(ctx, id, sinceMs) { + } + // One task read. Records nothing that a newer self snapshot already covers, and nothing that + // matches task_current; a comment count alone is an event without a snapshot. +-function consider(ctx, current, { task, bucket, via, readAt, comments = 0 }, out) { +- const r = ref(ctx.project, task.id); +- const fields = taskFields(task, bucket); ++function consider(ctx, current, project, { task, bucket, via, readAt, comments = 0 }, out) { ++ const r = ref(project, task.id); ++ const fields = fieldsFor(task, bucket); + const d = digest(fields); + const c = current.get(r); + const stale = c?.source === 'self' && readAt <= c.at; +@@ -109,21 +121,27 @@ function consider(ctx, current, { task, bucket, via, readAt, comments = 0 }, out + body: { via, digest: d, previous: c?.digest ?? null, changed: changed(c?.fields, fields), ...(comments ? { comments } : {}) }, + }); + } +-// Open tasks the board no longer shows: done, moved, deleted or no longer shared. ++// Open tasks the board no longer shows: done, moved, deleted or no longer shared. A task whose ++// project left the tree (archived, moved out from under the root, or no longer shared) is out of ++// the business's reach: no-access, or moved if the task itself changed project. + async function missing(ctx, current, refs, out) { + for (const r of refs) { + const c = current.get(r); +- const id = Number(r.slice(r.indexOf('/') + 1)); ++ const { project, task: id } = parseRef(r); + const readAt = iso(ctx.clock()); + const x = await ctx.sync('GET', `/tasks/${id}`); + let fields, updated, event; + if (x.status === 200) { + checkTask(x.json); + updated = normal(x.json.updated); +- if (x.json.project_id !== ctx.project) { ++ if (x.json.project_id !== project) { + fields = tombstone('moved', x.json.project_id); + event = { reason: 'moved', project: x.json.project_id }; +- } else if (x.json.done) fields = taskFields(x.json, ctx.view.done); ++ } else if (!ctx.tree.has(project)) { ++ updated = c.updated; ++ fields = tombstone('no-access'); ++ event = { reason: 'no-access' }; ++ } else if (x.json.done) fields = fieldsFor(x.json, doneBucket(ctx.tree.get(project))); + else continue; // still open in the project: the board read raced a move, the next tick sees it + } else if (outcome(x) === 'not-found' || x.status === 403) { + updated = c.updated; +@@ -151,35 +169,44 @@ export async function tick(ctx) { + const startMs = ctx.clock(); + const readAt = iso(startMs); + const sinceMs = Math.floor(((ctx.lastStart ?? startMs) - WINDOW_MS) / 1000) * 1000; +- const open = await board(ctx); +- const hits = new Map((await walk(ctx, `updated > ${floorSecond(sinceMs)}`)).map((t) => [t.id, t])); ++ const reads = []; ++ for (const [project, view] of ctx.tree) { ++ const open = await openTasks(ctx, project, view); ++ const hits = new Map((await walk(ctx, `updated > ${floorSecond(sinceMs)}`, {}, project)).map((t) => [t.id, t])); ++ reads.push({ project, view, open, hits }); ++ } + const current = new Map(ctx.broker.taskView(ctx.business, 'current').map((x) => [x.task_ref, x])); + const out = { snapshots: [], events: [] }; +- const comments = new Map(); +- for (const [id, t] of hits) { +- const key = normal(t.updated); +- if (ctx.commentsAt.get(id) === key) continue; +- comments.set(id, await foreignComments(ctx, id, sinceMs)); +- ctx.commentsAt.set(id, key); +- } +- for (const [id, { task, bucket }] of open) { +- const hit = hits.get(id); +- const use = hit && Date.parse(hit.updated) >= Date.parse(task.updated) ? hit : task; +- const b = use.done ? ctx.view.done : bucket; +- consider(ctx, current, { task: use, bucket: b, via: hit ? 'cursor' : 'board', readAt, comments: comments.get(id) }, out); +- } +- const explained = new Set(); +- for (const [id, t] of hits) { +- if (open.has(id)) continue; +- // An open task the board didn't show raced a move; the next board read places it. +- if (!t.done) continue; +- explained.add(ref(ctx.project, id)); +- consider(ctx, current, { task: t, bucket: ctx.view.done, via: 'cursor', readAt, comments: comments.get(id) }, out); ++ // Refs some read this tick placed. Keyed by ref, not id: a task moved between two projects of the ++ // tree shows on the new one's board under the same id, and the old ref still needs its tombstone. ++ const placed = new Set(); ++ for (const { project, view, open, hits } of reads) { ++ const comments = new Map(); ++ for (const [id, t] of hits) { ++ const key = normal(t.updated); ++ if (ctx.commentsAt.get(id) === key) continue; ++ comments.set(id, await foreignComments(ctx, id, sinceMs)); ++ ctx.commentsAt.set(id, key); ++ } ++ for (const [id, { task, bucket }] of open) { ++ const hit = hits.get(id); ++ const use = hit && Date.parse(hit.updated) >= Date.parse(task.updated) ? hit : task; ++ const b = use.done ? doneBucket(view) : bucket; ++ placed.add(ref(project, id)); ++ consider(ctx, current, project, { task: use, bucket: b, via: hit ? 'cursor' : 'board', readAt, comments: comments.get(id) }, out); ++ } ++ for (const [id, t] of hits) { ++ if (open.has(id)) continue; ++ // An open task the board didn't show raced a move; the next board read places it. ++ if (!t.done) continue; ++ placed.add(ref(project, id)); ++ consider(ctx, current, project, { task: t, bucket: doneBucket(view), via: 'cursor', readAt, comments: comments.get(id) }, out); ++ } + } + const gone = ctx.broker + .taskView(ctx.business, 'open') + .map((x) => x.task_ref) +- .filter((r) => !explained.has(r) && !open.has(Number(r.slice(r.indexOf('/') + 1)))); ++ .filter((r) => !placed.has(r)); + await missing(ctx, current, gone, out); + record(ctx, out); + ctx.lastStart = startMs; +@@ -187,27 +214,32 @@ export async function tick(ctx) { + } + // The hourly full read, and the first read at startup. comment_count catches comments on tasks the + // cursor window missed; a changed count is checked against the comment list before it is reported. ++// With tracker.descendants on it reads the project list first, so a new child joins the tree here ++// and a child that left it has its tasks read one by one through missing(). + export async function reconcile(ctx) { + const startMs = ctx.clock(); + const readAt = iso(startMs); + const sinceMs = ctx.lastReconcile ?? startMs; +- const open = await board(ctx); +- const list = await walk(ctx, '', { expand: 'comment_count' }); ++ if (ctx.descendants) await discover(ctx); ++ const reads = []; ++ for (const [project, view] of ctx.tree) ++ reads.push({ project, view, open: await openTasks(ctx, project, view), list: await walk(ctx, '', { expand: 'comment_count' }, project) }); + const current = new Map(ctx.broker.taskView(ctx.business, 'current').map((x) => [x.task_ref, x])); + const out = { snapshots: [], events: [] }; + const listed = new Set(); +- for (const t of list) { +- listed.add(ref(ctx.project, t.id)); +- const bucket = t.done ? ctx.view.done : open.get(t.id)?.bucket; +- if (!bucket) continue; +- let comments = 0; +- const count = t.comment_count; +- if (count !== undefined && !Number.isSafeInteger(count)) shape(); +- const before = ctx.commentCount.get(t.id); +- if (count !== undefined) ctx.commentCount.set(t.id, count); +- if (before !== undefined && count !== before) comments = await foreignComments(ctx, t.id, sinceMs); +- consider(ctx, current, { task: t, bucket, via: 'reconcile', readAt, comments }, out); +- } ++ for (const { project, view, open, list } of reads) ++ for (const t of list) { ++ listed.add(ref(project, t.id)); ++ const bucket = t.done ? doneBucket(view) : open.get(t.id)?.bucket; ++ if (bucket === undefined) continue; ++ let comments = 0; ++ const count = t.comment_count; ++ if (count !== undefined && !Number.isSafeInteger(count)) shape(); ++ const before = ctx.commentCount.get(t.id); ++ if (count !== undefined) ctx.commentCount.set(t.id, count); ++ if (before !== undefined && count !== before) comments = await foreignComments(ctx, t.id, sinceMs); ++ consider(ctx, current, project, { task: t, bucket, via: 'reconcile', readAt, comments }, out); ++ } + const gone = [...current.values()] + .filter((x) => x.fields.gone === undefined && !listed.has(x.task_ref)) + .map((x) => x.task_ref); +diff --git a/packages/tasks/tests/shapes.test.mjs b/packages/tasks/tests/shapes.test.mjs +index 3859dd22..c16f1f44 100644 +--- a/packages/tasks/tests/shapes.test.mjs ++++ b/packages/tasks/tests/shapes.test.mjs +@@ -1,7 +1,7 @@ + import test from 'node:test'; + import assert from 'node:assert/strict'; + import { readFileSync } from 'node:fs'; +-import { world, sleep } from './world.mjs'; ++import { world, sleep, SCOPES } from './world.mjs'; + import { taskFields } from '../src/digest.mjs'; + import { FakeVikunja } from '../src/fake.mjs'; + const recorded = JSON.parse(readFileSync(new URL('./fixtures/v2-shapes.json', import.meta.url))); +@@ -154,6 +154,30 @@ test('the fake answers each route with the statuses and shapes Vikunja v2.7.0 se + for (const key of new Set(Object.values(KEY))) + if (!UNPRODUCED.has(key)) assert.ok(covered.has(key), `the scenario never produced ${key}`); + }); ++// No recording holds a GET /projects list. Its envelope is the one every list sent, and each item ++// is checked against the recorded GET /projects/{p} object, which has parent_project_id and is_archived. ++test('the project list holds recorded project objects in the recorded list envelope', async (t) => { ++ const w = await world(t, { scopes: { sync: { ...SCOPES.sync, projects: [...SCOPES.sync.projects, 'read_all'] } } }); ++ const child = w.fake.project('Child', w.owner, { parent: w.project }); ++ w.fake.pseudoProject(w.bots.sync); ++ const r = await w.fake.fetch('http://vikunja.test/api/v2/projects?per_page=50&page=1', { ++ headers: { authorization: 'Bearer ' + w.tokens.sync }, ++ }); ++ assert.equal(r.status, 200); ++ const body = await r.json(); ++ const problems = []; ++ compare({ ...body, items: [] }, samples['views 200'], 'projects', problems); ++ for (const p of body.items) compare(p, samples['project 200'], 'projects[]', problems); ++ assert.deepEqual(problems, []); ++ assert.deepEqual( ++ body.items.map((p) => [p.id, p.parent_project_id, p.is_archived]), ++ [ ++ [w.project, 0, false], ++ [child, w.project, false], ++ [-1, 0, false], ++ ], ++ ); ++}); + test('the recorded task bodies pass the checks S3 applies to every read', () => { + const tasks = []; + for (const [name, r] of Object.entries(recorded)) { +diff --git a/packages/tasks/tests/tree.test.mjs b/packages/tasks/tests/tree.test.mjs +new file mode 100644 +index 00000000..9410779b +--- /dev/null ++++ b/packages/tasks/tests/tree.test.mjs +@@ -0,0 +1,276 @@ ++import test from 'node:test'; ++import assert from 'node:assert/strict'; ++import { readFileSync } from 'node:fs'; ++import { world, sleep, SCOPES } from './world.mjs'; ++import { tasksAdapter } from '../src/index.mjs'; ++import { WATCH_ONLY } from '../src/sync.mjs'; ++// tracker.descendants: the tracker project is the root of a tree, read with its descendants. ++// The sync token also needs the project list, projects read_all. ++const TREE = { ++ tracker: { descendants: true }, ++ scopes: { sync: { ...SCOPES.sync, projects: [...SCOPES.sync.projects, 'read_all'] } }, ++}; ++const behind = { now: () => Date.now() - 3600000 }; ++const state = (w) => w.adapter.status()[0]; ++const ready = async (w) => { ++ await w.adapter.start('demo'); ++ assert.equal(state(w).state, 'ready', state(w).refused); ++ return w; ++}; ++const ref = (project, task) => `vikunja:${project}/${task}`; ++const add = async (w, project, title) => (await w.ui('POST', `/projects/${project}/tasks`, { title })).json.id; ++const move = (w, project, bucket, task) => ++ w.ui('PUT', `/projects/${project}/views/${w.fake.kanban(project).id}/buckets/${bucket}/tasks`, { task_id: task }); ++const open = (w) => w.broker.taskView('demo', 'open').map((r) => ({ ...r })); ++const missing = (w) => w.events('task.missing').map((e) => [e.subject, e.body]); ++// The requests one call makes, as "METHOD path". ++const counted = async (w, fn) => { ++ const from = w.fake.requests.length; ++ await fn(); ++ return w.fake.requests.slice(from).map((r) => `${r.method} ${r.path}`); ++}; ++test('with descendants off the adapter reads no project list and no child, as in v0', async (t) => { ++ const w = await world(t); ++ const child = w.fake.project('Child', w.owner, { parent: w.project }); ++ w.fake.install(child); ++ const task = await add(w, child, 'in the child'); ++ await ready(w); ++ await sleep(20); ++ await w.adapter.tick('demo'); ++ await w.adapter.reconcile('demo'); ++ // GETs only: the POST that made the child's task is the test's own. ++ const paths = w.fake.requests.filter((r) => r.method === 'GET').map((r) => r.path); ++ assert.ok(!paths.includes('/projects')); ++ assert.ok(!paths.some((p) => p.startsWith(`/projects/${child}/`))); ++ assert.equal(w.snapshots(ref(child, task)).length, 0); ++}); ++test('a three-level tree is read per project, and a project outside it is not', async (t) => { ++ const w = await world(t, { ...TREE, fake: behind }); ++ const child = w.fake.project('Child', w.owner, { parent: w.project }); ++ const childBoard = w.fake.install(child); ++ // The grandchild keeps Vikunja's default buckets, so it is watch-only. ++ const grandchild = w.fake.project('Grandchild', w.owner, { parent: child }); ++ const outside = w.fake.project('Outside', w.owner); ++ w.fake.share(outside, w.bots.sync, 0); ++ const tasks = {}; ++ for (const [name, p] of Object.entries({ root: w.project, child, grandchild, outside })) tasks[name] = await add(w, p, name); ++ await ready(w); ++ assert.deepEqual(w.snapshots(ref(w.project, tasks.root)).at(-1).fields.bucket, w.installed.buckets.todo); ++ assert.deepEqual(w.snapshots(ref(child, tasks.child)).at(-1).fields.bucket, childBoard.buckets.todo); ++ assert.deepEqual(w.snapshots(ref(grandchild, tasks.grandchild)).at(-1).fields.bucket, WATCH_ONLY); ++ assert.equal(w.snapshots(ref(outside, tasks.outside)).length, 0); ++ assert.deepEqual( ++ open(w).map((r) => r.task_ref), ++ [ref(w.project, tasks.root), ref(child, tasks.child), ref(grandchild, tasks.grandchild)].sort(), ++ ); ++ // Access to the children comes from the root's share alone: the sync bot has no grant on them. ++ // A quiet tick costs two reads per project: the board (or the open list) and the cursor walk. ++ const tick = await counted(w, () => w.adapter.tick('demo')); ++ assert.equal(tick.length, 6); ++ assert.deepEqual( ++ tick.map((r) => r.replace(/\/views\/\d+\/buckets\/tasks$/, ' board').replace(/\/tasks$/, ' list')), ++ [ ++ `GET /projects/${w.project} board`, ++ `GET /projects/${w.project} list`, ++ `GET /projects/${child} board`, ++ `GET /projects/${child} list`, ++ `GET /projects/${grandchild} list`, ++ `GET /projects/${grandchild} list`, ++ ], ++ ); ++ // A reconcile adds the project list and each child's views and buckets. ++ const reconcile = await counted(w, () => w.adapter.reconcile('demo')); ++ assert.equal(reconcile[0], 'GET /projects'); ++ assert.equal(reconcile.length, 1 + 2 * 2 + 3 * 2); ++}); ++test('a watch-only child keeps the business ready, and a failed child read refuses as transient', async (t) => { ++ const w = await world(t, TREE); ++ const child = w.fake.project('Child', w.owner, { parent: w.project }); ++ w.fake.install(child); ++ // A second kanban view fails the install checks. ++ w.fake.addView(child, 'kanban'); ++ const task = await add(w, child, 'watched'); ++ await ready(w); ++ assert.equal(w.snapshots(ref(child, task)).at(-1).fields.bucket, WATCH_ONLY); ++ const down = await world(t, TREE); ++ const other = down.fake.project('Child', down.owner, { parent: down.project }); ++ down.fake.fault('GET', new RegExp(`^/projects/${other}/views$`), 503); ++ await down.adapter.start('demo'); ++ assert.equal(state(down).refused, 'tracker-unavailable'); ++}); ++test('a root without the installed buckets refuses with descendants on', async (t) => { ++ const w = await world(t, { ...TREE, install: false }); ++ const child = w.fake.project('Child', w.owner, { parent: w.project }); ++ w.fake.install(child); ++ await w.adapter.start('demo'); ++ assert.equal(state(w).refused, 'tracker-install'); ++}); ++test('the project list refuses without read_all, and skips archived projects and saved filters', async (t) => { ++ const w = await world(t, { tracker: { descendants: true } }); ++ await w.adapter.start('demo'); ++ assert.equal(state(w).refused, 'tracker-unauthorized'); ++ // Vikunja leaves archived projects out of the list; this fetch asks for them too, so the ++ // adapter's own is_archived check is what skips them. ++ let fake; ++ const v = await world(t, { ++ ...TREE, ++ fetch: (url, init) => fake.fetch(/\/api\/v2\/projects\?/.test(url) ? url + '&is_archived=true' : url, init), ++ }); ++ fake = v.fake; ++ const archived = v.fake.project('Archived', v.owner, { parent: v.project }); ++ v.fake.install(archived); ++ const task = await add(v, archived, 'archived'); ++ v.fake.archive(archived); ++ v.fake.pseudoProject(v.bots.sync); ++ await ready(v); ++ assert.equal(v.snapshots(ref(archived, task)).length, 0); ++ assert.ok(!v.fake.requests.some((r) => r.method === 'GET' && r.path.startsWith(`/projects/${archived}/`) || r.path.startsWith('/projects/-'))); ++}); ++test('descendants must be true or false', async (t) => { ++ const w = await world(t); ++ await assert.rejects( ++ tasksAdapter({ trackers: { demo: { baseUrl: 'http://vikunja.test', project: w.project, descendants: 'yes' } }, autostart: false })({ ++ broker: w.broker, ++ credentials: w.credentials, ++ businesses: w.businesses, ++ }), ++ (e) => e.code === 'tracker-config', ++ ); ++}); ++test('a move inside the tree is a moved tombstone plus a snapshot under the new ref', async (t) => { ++ const w = await world(t, TREE); ++ const a = w.fake.project('A', w.owner, { parent: w.project }); ++ const boardA = w.fake.install(a); ++ const b = w.fake.project('B', w.owner, { parent: a }); ++ const outside = w.fake.project('Outside', w.owner); ++ w.fake.share(outside, w.bots.sync, 0); ++ const one = await add(w, w.project, 'root to child'); ++ const two = await add(w, a, 'child to child'); ++ const three = await add(w, a, 'child to outside'); ++ await ready(w); ++ w.fake.moveToProject(one, a); ++ w.fake.moveToProject(two, b); ++ w.fake.moveToProject(three, outside); ++ await sleep(20); ++ await w.adapter.tick('demo'); ++ assert.deepEqual(missing(w), [ ++ [ref(w.project, one), { reason: 'moved', project: a }], ++ [ref(a, two), { reason: 'moved', project: b }], ++ [ref(a, three), { reason: 'moved', project: outside }], ++ ]); ++ assert.deepEqual(w.snapshots(ref(w.project, one)).at(-1).fields, { gone: 'moved', project: a }); ++ assert.equal(w.snapshots(ref(a, one)).at(-1).fields.bucket, boardA.buckets.todo); ++ assert.equal(w.snapshots(ref(b, two)).at(-1).fields.bucket, WATCH_ONLY); ++ assert.equal(w.snapshots(ref(outside, three)).length, 0); ++ assert.deepEqual( ++ open(w).map((r) => r.task_ref), ++ [ref(a, one), ref(b, two)].sort(), ++ ); ++ await sleep(20); ++ assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 }); ++ assert.deepEqual(await w.adapter.reconcile('demo'), { snapshots: 0, events: 0 }); ++}); ++test('a child made after startup joins the tree at the next reconcile', async (t) => { ++ const w = await ready(await world(t, TREE)); ++ const child = w.fake.project('Late', w.owner, { parent: w.project }); ++ const task = await add(w, child, 'late'); ++ await sleep(20); ++ await w.adapter.tick('demo'); ++ assert.equal(w.snapshots(ref(child, task)).length, 0); ++ await w.adapter.reconcile('demo'); ++ assert.equal(w.snapshots(ref(child, task)).at(-1).via, 'reconcile'); ++ await w.ui('PATCH', `/tasks/${task}`, { title: 'renamed' }); ++ await sleep(20); ++ await w.adapter.tick('demo'); ++ assert.equal(w.snapshots(ref(child, task)).at(-1).fields.title, 'renamed'); ++}); ++test('a child that leaves the tree has its tasks recorded missing, and the ledger keeps their history', async (t) => { ++ const w = await world(t, TREE); ++ const children = {}; ++ for (const name of ['archived', 'reparented', 'hidden']) { ++ children[name] = w.fake.project(name, w.owner, { parent: w.project }); ++ w.fake.install(children[name]); ++ } ++ const launchpad = w.fake.project('Launchpad', w.owner); ++ const tasks = {}; ++ for (const name of Object.keys(children)) tasks[name] = await add(w, children[name], name); ++ await ready(w); ++ // Archived: Vikunja leaves it out of the list. Reparented: shared with the sync bot directly, ++ // now a top-level project outside the tree. Hidden: moved under a project the bot can't read. ++ w.fake.archive(children.archived); ++ w.fake.share(children.reparented, w.bots.sync, 0); ++ w.fake.reparent(children.reparented, 0); ++ w.fake.reparent(children.hidden, launchpad); ++ await sleep(20); ++ // A tick reads the tree it has; the project list is read at reconcile. ++ assert.deepEqual(missing(w), []); ++ await w.adapter.reconcile('demo'); ++ const refs = Object.keys(children).map((name) => ref(children[name], tasks[name])); ++ assert.deepEqual( ++ missing(w), ++ refs.map((r) => [r, { reason: 'no-access' }]), ++ ); ++ for (const r of refs) { ++ const s = w.snapshots(r); ++ assert.deepEqual( ++ s.map((x) => x.fields.gone ?? 'open'), ++ ['open', 'no-access'], ++ ); ++ assert.equal(s[1].updated, s[0].updated); ++ } ++ assert.ok(!JSON.stringify(w.store.all('SELECT * FROM events')).includes('Launchpad')); ++ assert.deepEqual(open(w), []); ++ await sleep(20); ++ assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 }); ++ assert.deepEqual(await w.adapter.reconcile('demo'), { snapshots: 0, events: 0 }); ++}); ++test('a task in a bucket outside the five keeps its raw bucket id', async (t) => { ++ const w = await ready(await world(t, TREE)); ++ const backlog = w.fake.bucket(w.installed.view, 'Backlog'); ++ const task = await add(w, w.project, 'parked'); ++ await move(w, w.project, backlog, task); ++ await sleep(20); ++ await w.adapter.tick('demo'); ++ assert.equal(w.snapshots(ref(w.project, task)).at(-1).fields.bucket, backlog); ++ assert.deepEqual(open(w), [{ task_ref: ref(w.project, task), bucket: backlog }]); ++}); ++// Decision 87: bucket 0 on a watch-only snapshot. Nothing in sync.mjs maps 0 to a bucket or a state. ++test('bucket 0 on a watch-only task never becomes a bucket or a state', async (t) => { ++ const w = await world(t, TREE); ++ const child = w.fake.project('Child', w.owner, { parent: w.project }); ++ const buckets = w.fake.bucketIds(w.fake.kanban(child).id); ++ const task = await add(w, child, 'watched'); ++ await ready(w); ++ const r = ref(child, task); ++ // A move between the child's open buckets changes nothing the adapter records. ++ await move(w, child, buckets.Doing, task); ++ await sleep(20); ++ assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 }); ++ // Closed through its done bucket, then reopened: done changes, the bucket stays 0. ++ await move(w, child, buckets.Done, task); ++ await sleep(20); ++ await w.adapter.tick('demo'); ++ assert.deepEqual(open(w), []); ++ await move(w, child, buckets['To-Do'], task); ++ await sleep(20); ++ await w.adapter.tick('demo'); ++ assert.deepEqual(open(w), [{ task_ref: r, bucket: WATCH_ONLY }]); ++ const changes = w.events('task.changed.external').filter((e) => e.subject === r); ++ assert.equal(changes[0].body.via, 'reconcile'); ++ assert.deepEqual( ++ changes.slice(1).map((e) => e.body.changed), ++ [['done'], ['done']], ++ ); ++ assert.ok(w.snapshots(r).every((s) => s.fields.bucket === WATCH_ONLY)); ++ assert.deepEqual(w.events('task.state'), []); ++ assert.ok(changes.every((e) => !('state' in e.body))); ++ // A verb on a child task refuses as it does in v0. ++ await assert.rejects( ++ w.call(w.agent('pm'), 'task.assign', { task_ref: r, role: 'coder' }), ++ (e) => e.code === 'task-project', ++ ); ++ // The source has no bucket-to-state table and no write path. ++ const source = readFileSync(new URL('../src/sync.mjs', import.meta.url), 'utf8'); ++ assert.ok(!/\.titles\b|\.ids\[/.test(source)); ++ assert.ok(!/'(PUT|POST|PATCH|DELETE)'/.test(source)); ++}); diff --git a/agents/darkwing/work/queue-62/candidate-manifest.sha256 b/agents/darkwing/work/queue-62/candidate-manifest.sha256 new file mode 100644 index 000000000..c6264616c --- /dev/null +++ b/agents/darkwing/work/queue-62/candidate-manifest.sha256 @@ -0,0 +1,13 @@ +a15c22de43c3ca4ea80fa6141fb2b8173f5a1eb31d939bad5137e36fc131c89d packages/business/src/vars.mjs +43ebec98d7cb474c4d65ff533c103250e10d1d5bf5530f23f3424ab5c265f73c packages/business/tests/vars.test.mjs +fcc95afc418823416c87475de0f6fcad33d312588d22e9d6fd5784d9e2478f0e packages/cli/src/config.mjs +1737237bbf062f83eeffeb7fb1a604bc6676be9f9db1e6b91844587f77491a1c packages/cli/tests/config.test.mjs +4ada6f215c8fb42bc95d46121e7c61c2477f7b1b1d7120675443b91cc4574fdc packages/cli/tests/trackers-boot.test.mjs +aad24a5db15943eba6c0bf5fd93add3f4f7b265ff5868c46e405e976bc26ce22 packages/tasks/live/README.md +d31be986f6f5f972bb6166eee64b9ad2e3ad202c946b92af39ab69339d6225ac packages/tasks/README.md +02a802a9bb1306bf5f82b8e56ca2064c308f1b7574eebba0f47932cdea7f41d1 packages/tasks/src/adapter.mjs +aeb65af54d716a36bd7e790c41cad3c4ca516ab8384b288e23ecd04d82cdd366 packages/tasks/src/fake.mjs +e0c754aacf7e82c2fe18fb0548674044ac949ffc94435b353e4bd7aebcf797c2 packages/tasks/src/startup.mjs +4a329bc76c4909490ac064332ccd0083365a4f7fcbffcd69ef17c327a288fc82 packages/tasks/src/sync.mjs +e8798609ec1bf7d4901f7f94b6346b37c7ea4504ee2eb781b07c634001e3bf72 packages/tasks/tests/shapes.test.mjs +82f6a673a59dc214a65d20af597096c6c3f86ad2c4d39bb25d617ad2515a9e4b packages/tasks/tests/tree.test.mjs diff --git a/agents/darkwing/work/queue-62/files.txt b/agents/darkwing/work/queue-62/files.txt new file mode 100644 index 000000000..47adba82e --- /dev/null +++ b/agents/darkwing/work/queue-62/files.txt @@ -0,0 +1,13 @@ +packages/business/src/vars.mjs +packages/business/tests/vars.test.mjs +packages/cli/src/config.mjs +packages/cli/tests/config.test.mjs +packages/cli/tests/trackers-boot.test.mjs +packages/tasks/live/README.md +packages/tasks/README.md +packages/tasks/src/adapter.mjs +packages/tasks/src/fake.mjs +packages/tasks/src/startup.mjs +packages/tasks/src/sync.mjs +packages/tasks/tests/shapes.test.mjs +packages/tasks/tests/tree.test.mjs diff --git a/agents/darkwing/work/queue-62/out/gate/apply.txt b/agents/darkwing/work/queue-62/out/gate/apply.txt new file mode 100644 index 000000000..6e5e1395e --- /dev/null +++ b/agents/darkwing/work/queue-62/out/gate/apply.txt @@ -0,0 +1,14 @@ +applied +packages/business/src/vars.mjs: OK +packages/business/tests/vars.test.mjs: OK +packages/cli/src/config.mjs: OK +packages/cli/tests/config.test.mjs: OK +packages/cli/tests/trackers-boot.test.mjs: OK +packages/tasks/live/README.md: OK +packages/tasks/README.md: OK +packages/tasks/src/adapter.mjs: OK +packages/tasks/src/fake.mjs: OK +packages/tasks/src/startup.mjs: OK +packages/tasks/src/sync.mjs: OK +packages/tasks/tests/shapes.test.mjs: OK +packages/tasks/tests/tree.test.mjs: OK diff --git a/agents/darkwing/work/queue-62/out/gate/bus-tests.log b/agents/darkwing/work/queue-62/out/gate/bus-tests.log new file mode 100644 index 000000000..3294ff63a --- /dev/null +++ b/agents/darkwing/work/queue-62/out/gate/bus-tests.log @@ -0,0 +1,82 @@ +✔ launch identity is stamped, payload identity is refused and stale holder cannot send (189.205781ms) +✔ decision classes route from policy; gated resolution is human-only, choice and target must match (438.844355ms) +✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (382.921188ms) +✔ launch events require a human CLI capability; generic emit cannot forge authority events (389.459955ms) +✔ within-role decisions close atomically and invalid options or blocking omissions refuse (292.529916ms) +✔ observer capabilities read human inbox but cannot mutate or forge launch identity (219.077084ms) +✔ task action subjects and linked decision trail are complete and ordered (390.586456ms) +✔ launch binding is durable and reconnecting requires the identical trusted record (134.628246ms) +✔ business isolation includes inherited object names and cross-business message references (215.705092ms) +✔ authority never transfers between action, run, target, unresolved or replaced role holder (309.853682ms) +✔ task projection uses schema current view, skipping earlier and equal-start polls (133.355659ms) +✔ revocation permanently bars the old run from reclaiming first, including after broker restart (481.213654ms) +✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (180.256758ms) +✔ both arbiters require human resolution when their cross-role route is themselves (241.16013ms) +✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (3.251996ms) +✔ only validated broker references load; returned data and exceptions cannot expose a known token (7.071814ms) +✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (6.492665ms) +✔ expiry refuses use and env references never become client data (1.740994ms) +✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (7.820923ms) +✔ opaque tokens shorter than 16 characters refuse before use (0.700696ms) +✔ endLaunch writes session.ended, releases the run claim and kills its capabilities (202.757684ms) +✔ endLaunch refuses an unknown run, a second end and a rebind of the ended run (234.939664ms) +✔ a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound (279.024904ms) +✔ endLaunch leaves a claim another run took alone (253.496157ms) +✔ refuse records action.refused against the caller with the code only (335.224505ms) +✔ launches off refuses role.launch with launch-revoked until launches on (315.632998ms) +✔ broker process: launch ops authorize role.launch, record refusals and end runs (429.799892ms) +✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (4.279809ms) +✔ process reader gets own kernel identity without exposing environment values (0.893228ms) +✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (1.337363ms) +✔ real pid 1 remains inspectable when its environment is protected (0.519345ms) +✔ within-role sends cite an open gated launch decision without spending it or naming it in grants (277.997725ms) +✔ missing and foreign-business citations refuse and roll back message and grant (344.747497ms) +✔ cross-role sends still need a matching resolved decision and consume it once (353.002017ms) +✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (273.37842ms) +✔ startup token refusal returns safe code without value or partial listening broker (60.632816ms) +✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (356.371003ms) +✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (256.867864ms) +✔ trusted host registers later launches; socket clients never have a registration verb (385.853343ms) +✔ runtime excludes declared project roots even when host supplies no repoRoots (55.296127ms) +✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (253.530798ms) +✔ v3b prototype refusals, views and append-only mutations (1766.320602ms) +✔ gated approval authorizes once, survives store reopen, and fresh approval works (386.057154ms) +✔ another run cannot consume an approval; a failed check leaves it usable (402.945256ms) +✔ two scheduled callers have exactly one grant and one consumed refusal (274.267909ms) +✔ failed commit rolls consumption back; cross-role consumes and within-role stays reusable (600.084673ms) +✔ class drift gated to cross-role refuses before consumption (391.791192ms) +✔ class drift cross-role to gated refuses before consumption (340.330132ms) +✔ class drift gated to within-role refuses before consumption (227.877646ms) +✔ class drift cross-role to within-role refuses before consumption (282.209668ms) +✔ class drift within-role to gated refuses before consumption (198.278025ms) +✔ class drift within-role to cross-role refuses before consumption (481.680122ms) +✔ message.send consumes approval and prevents a later send or authorize (242.199767ms) +✔ role.revoke consumes approval and prevents a later revoke or authorize (208.064707ms) +✔ creates private WAL store and excludes a second writer until explicit close (170.30296ms) +✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (294.171606ms) +✔ existing empty database and symlink runtime directory refuse, never initialize over damage (324.314159ms) +✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (266.274964ms) +✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (283.014865ms) +✔ async transactions refuse before invoking their function (186.302733ms) +✔ recordTask keeps sync reads and a role write apart (248.021791ms) +✔ read_at must be one canonical UTC format, so the projection compares strings safely (183.061837ms) +✔ a bad entry refuses the whole record (203.123255ms) +✔ taskView reads the projection for one business (187.279126ms) +✔ requestTask hands only a holder and a task verb to the handler, and records refusals (506.610296ms) +✔ the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous (511.4966ms) +✔ without an adapter the server refuses every task verb (427.506996ms) +✔ the runtime refuses an invalid adapter and closes a valid one (281.415721ms) +✔ the process loads the S3 adapter from plain-data trackers (301.335615ms) +✔ socket capability stamps launch identity; shared views use wire, no SQL client (221.529429ms) +✔ two wire claims serialize; a lost reply never automatically retries (338.606324ms) +✔ malformed, oversized and identity-forging envelopes refuse without echoing input (163.296596ms) +✔ client preserves UTF-8 when a response divides a multibyte character (16.065369ms) +✔ committed mutation followed by dropped reply reports unknown and is never retried (193.337359ms) +ℹ tests 74 +ℹ suites 0 +ℹ pass 74 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 4202.497194 diff --git a/agents/darkwing/work/queue-62/out/gate/business-tests.log b/agents/darkwing/work/queue-62/out/gate/business-tests.log new file mode 100644 index 000000000..3fdf56c73 --- /dev/null +++ b/agents/darkwing/work/queue-62/out/gate/business-tests.log @@ -0,0 +1,68 @@ +✔ config directory and file path follow MOSAIC_CONFIG (2.848351ms) +✔ the fixture business validates and comes back frozen (38.438289ms) +✔ two instances may share a definition (4.985988ms) +✔ top-level refusals (15.573259ms) +✔ arbiters and projects (14.939897ms) +✔ role instances (9.446596ms) +✔ Vikunja bots (12.111196ms) +✔ a role without Vikunja takes no tracker block (5.556326ms) +✔ credential references match the definition's services (18.578185ms) +✔ launch (40.298298ms) +✔ loadBusiness: file checks (17.280922ms) +✔ loadBusiness: not a regular file (151.91495ms) +✔ loading writes nothing (4.18165ms) +✔ names that are Object.prototype properties don't count as declared (9.614905ms) +✔ the shipped example refuses as written and validates once filled in (1.581714ms) +✔ usage errors exit 4 (685.533424ms) +✔ validate: a good business exits 0 and prints instance digests (100.237294ms) +✔ validate: project files (510.346462ms) +✔ validate: missing files and a broken system config (408.670972ms) +✔ validate: credential reference problems exit 2 and name each one (108.704355ms) +✔ validate: a token file inside the repository is refused (108.455448ms) +✔ validate: role definitions come from MOSAIC_ROLES_DIR (317.775665ms) +✔ resolve: prints one instance's record (323.99734ms) +✔ resolve: refusals (597.461021ms) +✔ parse: exactly one of file or env, plus the service's date (4.801613ms) +✔ check: a good file has no problems (14.046213ms) +✔ check never opens the file: a write-only token passes (0.660342ms) +✔ check: file problems (1.637196ms) +✔ check: token files can't live in the repository or dataRoot, even through a linked directory (6.311826ms) +✔ check: dates and environment references (0.767415ms) +✔ path and load (30.032437ms) +✔ refusals (14.888956ms) +✔ systemVars flattens the validated config (3.618812ms) +✔ precedence: system, business, project, project role, agent (8.824697ms) +✔ limits narrow the definition and never widen it (4.797614ms) +✔ role.launch stays within-role only for the instance the launch block names (15.636871ms) +✔ limits.authority without role.launch leaves the launcher with no launch block (6.532983ms) +✔ limits.authority narrows cross-role actions too (3.042241ms) +✔ classify (3.151387ms) +✔ the record carries what the broker and launcher need (2.556793ms) +✔ digest: key order doesn't matter, any value change does (67.721665ms) +✔ refusals (42.31728ms) +✔ the four shipped version 2 roles load (5.044342ms) +✔ shipped role scopes match addendum B section 2 and the SR runbook (2.371647ms) +✔ shipped authority follows the note's table (0.895367ms) +✔ version 1 files keep loading with no authority (3.271035ms) +✔ the conductor policy isn't a role (0.459604ms) +✔ a missing role file is exit 4, a symbolic link too (0.9243ms) +✔ version 2 refusals (2.45245ms) +✔ authority: closed vocabulary, no gated-only action, no overlap (4.053667ms) +✔ credentials: Gitea scopes (4.751198ms) +✔ credentials: Vikunja scopes are a group-to-verbs map from the grantable list (2.514347ms) +✔ credentials: services (1.082643ms) +✔ contract: a non-empty regular Markdown file beside the role file (1.531355ms) +✔ every key names known layers and a merge rule (2.114061ms) +✔ unknown keys and wrong layers refuse (1.394641ms) +✔ types (3.523914ms) +✔ merge: defaults, then the most specific layer wins (0.571531ms) +✔ merge: limits only narrow, and provenance lists each source (0.556813ms) +✔ merge doesn't change its inputs (0.682966ms) +ℹ tests 60 +ℹ suites 0 +ℹ pass 60 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 3285.96556 diff --git a/agents/darkwing/work/queue-62/out/gate/cli-tests.log b/agents/darkwing/work/queue-62/out/gate/cli-tests.log new file mode 100644 index 000000000..c6d182aaf --- /dev/null +++ b/agents/darkwing/work/queue-62/out/gate/cli-tests.log @@ -0,0 +1,93 @@ +✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (243.295827ms) +✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (189.071888ms) +✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (146.01894ms) +✔ decide prints a declining choice as declining (139.95998ms) +✔ an unknown outcome is reported once and never resent (111.046439ms) +✔ a decision closed before the answer arrives exits 2 and points at its trail (121.224944ms) +✔ a prefix that matches two open decisions exits 2 and resolves neither (140.654613ms) +✔ without --business a command uses the live host's business, and a stale host.json is not a host (102.510429ms) +✔ every human command refuses inside an agent run before it touches the bus (81.10258ms) +✔ usage errors exit 4; no business and no host is a usage error (117.127277ms) +✔ agents and tasks print through the broker (216.831611ms) +✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (5.142126ms) +✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (90.196977ms) +✔ trackers come from the tracker.* variables of the one project that names a tracker project (112.216406ms) +✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (46.353189ms) +✔ two projects that each name a tracker project refuse, since the boot shape holds one (44.703145ms) +✔ a business without tracker.baseUrl gets no trackers entry (45.759211ms) +✔ an unknown business and a broken system config refuse with exit 3 (97.641512ms) +✔ empty views say so (1.390106ms) +✔ the trail keeps the broker's order and names a decision's task without its rows (1.518535ms) +✔ tasks print the tracker fields the snapshot carries (0.322287ms) +✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (1080.334885ms) +✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (277.169937ms) +✔ a second host for the same data root refuses with exit 3 while the first runs (321.92585ms) +✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1348.009528ms) +✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (394.60645ms) +✔ a broker reply with no request waiting breaks the channel and the host exits 1 (226.991929ms) +✔ a notifier that refuses stops the broker and the host refuses with exit 3 (292.036695ms) +✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (239.157285ms) +✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (139.568023ms) +✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (208.017712ms) +✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (189.031877ms) +✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (225.30536ms) +✔ watchChildren reports a child that died before it was called, and one that dies later (28.891377ms) +✔ bus stop refuses to signal a live pid that is not a bus host (203.296479ms) +✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (317.654195ms) +✔ bus start refuses with exit 3 without a notifier config (122.953483ms) +✔ bus start runs until bus stop; status reports it while it runs (836.163088ms) +✔ bus-service.sh renders the unit and installs it into a given directory (45.822113ms) +✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1400.86653ms) +✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (1247.605114ms) +✔ a runner that stops at once ends its launch with the runner's reason (248.884842ms) +✔ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (1002.975701ms) +✔ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (3815.570834ms) +✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (214.061284ms) +✔ an over-long launch request is refused at once, not at the 10 s idle timeout (150.366959ms) +✔ a launch client that never closes its side doesn't hold the host's close (164.53907ms) +✔ a runner that ignores SIGTERM is killed when the host closes (1317.405262ms) +✔ zoned uses the IANA zone across DST (41.143292ms) +✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (259.674743ms) +✔ two blocking decisions get two DMs with different nonces (155.486762ms) +✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.38307ms) +✔ a failed DM is journaled, backs off, and is retried until it lands (146.199866ms) +✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (143.132605ms) +✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (124.186841ms) +✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (147.572487ms) +✔ polled every POLL_MS against a permanent 403, a DM is sent at 0, 30, 60, 90 and 120 min and gives up only then (214.561955ms) +✔ a restart after the second refusal does not send before that refusal's 30 min are up (125.378767ms) +✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (301.025846ms) +✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (243.748228ms) +✔ an inbox read failure is logged and the next poll retries (1.354297ms) +✔ no Discord id reaches the journal or the log (223.052008ms) +✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (43.903741ms) +✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (87.99139ms) +✔ the journal: a whole file that is one torn line truncates to empty (134.022562ms) +✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (6.709958ms) +✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (1.707582ms) +✔ the journal: a line with a wrong type refuses with exit 3 and names the field (50.099977ms) +✔ the journal: a symlinked directory refuses and says it is a link (0.658324ms) +✔ the journal: a dangling directory link, a parent that is a file and a journal that is a directory each refuse with exit 3 (0.821595ms) +✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.649684ms) +✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.770337ms) +✔ digest content stays within Discord's 2000 characters (0.5096ms) +✔ runLoop never overlaps ticks and stops after the one in flight (110.894961ms) +task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200 +task.close on a missing task answered: task-not-found; it made GET /tasks/999 404 +✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (547.545108ms) +✔ the transport writes {business, verb, args} to the child and reads its JSON (72.833567ms) +✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2235.327071ms) +✔ busExit and refuseInsideAgent (0.656759ms) +✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (355.542292ms) +✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1156.500378ms) +✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (226.331905ms) +✔ launches off and on go to the broker and change the business's launch state (276.154196ms) +✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (190.755404ms) +ℹ tests 83 +ℹ suites 0 +ℹ pass 83 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 9694.655934 diff --git a/agents/darkwing/work/queue-62/out/gate/gate.sh b/agents/darkwing/work/queue-62/out/gate/gate.sh new file mode 100755 index 000000000..efd408f45 --- /dev/null +++ b/agents/darkwing/work/queue-62/out/gate/gate.sh @@ -0,0 +1,14 @@ +#!/bin/bash +export TMPDIR=~/darkwing-scratch/r62/tmp +G=~/darkwing-scratch/r62/gate; O=~/darkwing-scratch/r62/out; mkdir -p $O; rm -f $O/summary.txt; cd $G +# node_modules is a link to the main checkout's, as in the row 55 gate; test-discord needs node_modules/.bin/pi. +[ -x node_modules/.bin/pi ] || { echo "node_modules not linked" >> $O/summary.txt; exit 1; } +for s in tasks business bus cli; do + env -u NODE_TEST_CONTEXT node --test "packages/$s/tests/*.test.mjs" > $O/$s-tests.log 2>&1; echo "$s rc=$?" >> $O/summary.txt +done +for s in scripts/test-*.sh; do + n=$(basename $s .sh) + if [ $n = test-task ]; then DOCKER_HOST=unix:///nonexistent bash $s > $O/$n.log 2>&1; else bash $s > $O/$n.log 2>&1; fi + echo "$n rc=$?" >> $O/summary.txt +done +echo DONE >> $O/summary.txt diff --git a/agents/darkwing/work/queue-62/out/gate/summary.txt b/agents/darkwing/work/queue-62/out/gate/summary.txt new file mode 100644 index 000000000..833676adf --- /dev/null +++ b/agents/darkwing/work/queue-62/out/gate/summary.txt @@ -0,0 +1,14 @@ +tasks rc=0 +business rc=0 +bus rc=0 +cli rc=0 +test-auth rc=0 +test-conductor rc=0 +test-config rc=0 +test-discord rc=0 +test-extension-package rc=0 +test-foundation rc=0 +test-queue rc=0 +test-release rc=0 +test-task rc=0 +DONE diff --git a/agents/darkwing/work/queue-62/out/gate/tasks-tests.log b/agents/darkwing/work/queue-62/out/gate/tasks-tests.log new file mode 100644 index 000000000..d91fae3b3 --- /dev/null +++ b/agents/darkwing/work/queue-62/out/gate/tasks-tests.log @@ -0,0 +1,71 @@ +✔ the boot config is checked before anything starts (104.416076ms) +✔ a business with no tracker entry refuses task verbs (130.611086ms) +✔ credential.expiring and .expired are recorded once per instance (279.889027ms) +✔ a token file that changes on disk records credential.changed (208.764269ms) +✔ autostart polls, reconciles and retries a startup the tracker was down for (246.807203ms) +✔ a refusal a restart must clear is not retried by the poll (114.25003ms) +✔ a poll that fires while two are queued is dropped (139.221689ms) +✔ close waits for a running verb and refuses one that has not started (288.167564ms) +✔ the bundled Vikunja is the pinned upstream image the runbook names (1.586814ms) +✔ every published port is on 127.0.0.1, and no secret is in the file (0.644421ms) +✔ the fake answers each route with the statuses and shapes Vikunja v2.7.0 sent (409.424478ms) +✔ the project list holds recorded project objects in the recorded list envelope (157.698384ms) +✔ the recorded task bodies pass the checks S3 applies to every read (0.763433ms) +✔ the client works against the fake over real HTTP with the platform fetch (537.56183ms) +✔ a correct install starts, and the first reconcile records tasks that already exist (120.574047ms) +✔ verbs refuse while a business is starting and after startup refused it (145.811034ms) +✔ startup refuses a token that can do more than its role needs (632.395388ms) +✔ startup refuses an unsupported version and flags an untested one (368.034205ms) +✔ startup refuses a board that the runbook did not install (499.056967ms) +✔ startup refuses a project the sync bot cannot read (155.060339ms) +✔ startup refuses a configured label the pm bot cannot see (227.668874ms) +✔ startup refuses an expired credential and a missing sync credential (365.899809ms) +✔ an unreachable tracker refuses with tracker-unavailable (246.733826ms) +✔ an edit in the UI is recorded once, with the fields that changed (239.895408ms) +✔ a move between open buckets is seen on the board, though updated does not change (213.821318ms) +✔ a person's comment is counted and a bot's is not (483.335126ms) +✔ the hourly reconcile catches a comment through comment_count (264.005482ms) +✔ a task closed in the UI leaves the open view with its done bucket (570.154722ms) +✔ a task that leaves the board is recorded as deleted, moved or out of reach (536.585697ms) +✔ a poll that read before a verb wrote does not overwrite the verb (435.391707ms) +✔ a tracker fault during a tick is reported and the next tick catches up (597.848081ms) +✔ a malformed answer refuses the tick with tracker-shape (556.19897ms) +✔ no token value reaches the database, the log or a refusal (876.126984ms) +✔ the first look at a task counts only comments inside the window (607.031588ms) +✔ with descendants off the adapter reads no project list and no child, as in v0 (155.493456ms) +✔ a three-level tree is read per project, and a project outside it is not (150.614393ms) +✔ a watch-only child keeps the business ready, and a failed child read refuses as transient (315.51776ms) +✔ a root without the installed buckets refuses with descendants on (207.162752ms) +✔ the project list refuses without read_all, and skips archived projects and saved filters (270.37371ms) +✔ descendants must be true or false (128.628273ms) +✔ a move inside the tree is a moved tombstone plus a snapshot under the new ref (246.375569ms) +✔ a child made after startup joins the tree at the next reconcile (233.25224ms) +✔ a child that leaves the tree has its tasks recorded missing, and the ledger keeps their history (254.204244ms) +✔ a task in a bucket outside the five keeps its raw bucket id (295.854549ms) +✔ bucket 0 on a watch-only task never becomes a bucket or a state (488.248057ms) +✔ task.create needs a recorded human request and a requirement id (240.304486ms) +✔ only labels named in the business file can be written (290.929905ms) +✔ task.schedule sets and clears a due date and relations (517.445508ms) +✔ assign and reassign move the role bots and record task.assigned (426.774157ms) +✔ task.update.assigned is for the assignee and records task.state (416.845053ms) +✔ a wrong expected digest records task.conflict and writes nothing (443.969427ms) +✔ a cross-role verb needs a resolved decision, used once (873.662032ms) +✔ task.close needs a verdict; after it every verb refuses with task-done (1012.441292ms) +✔ a lost answer is settled by a re-read and never retried (877.307177ms) +✔ a create whose answer is lost is reported uncertain, and the poll finds the task (2200.018372ms) +✔ a task the sync bot cannot read refuses and records nothing (2699.391581ms) +✔ verbs and polls for one business run one at a time (1530.611798ms) +✔ a due date with milliseconds is written to the second (2348.684349ms) +✔ every write landed and the final read failed: the verb succeeds and records what it wrote (1049.370857ms) +✔ some writes landed and the final read failed: write-uncertain, and nothing is recorded (1037.216419ms) +✔ a create whose final read fails succeeds and records task.created (791.827816ms) +✔ an edit between the last write and the final read shows as external on the next poll (591.740537ms) +✔ task.created is recorded when a later label write fails (311.053548ms) +ℹ tests 63 +ℹ suites 0 +ℹ pass 63 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 17824.523183 diff --git a/agents/darkwing/work/queue-62/out/gate/test-auth.log b/agents/darkwing/work/queue-62/out/gate/test-auth.log new file mode 100644 index 000000000..ac2287606 --- /dev/null +++ b/agents/darkwing/work/queue-62/out/gate/test-auth.log @@ -0,0 +1,17 @@ +OK status with missing harness credential exits 3 and still lists accounts +OK status reports harness credential (read-only) + mosaic accounts +OK api key material never reaches output +OK oauth token material never reaches output +OK unparseable credential file exits 2 +OK symlinked credential file exits 4 +OK env-side credential names reported +OK env var values never reach output +OK accounts without an accounts dir reports none and creates nothing +OK accounts lists files and marks the active one +OK loose account perms flagged in listing +OK agent --auth with missing account file refuses (exit 4) +OK agent --auth with non-0600 account file refuses +OK agent --auth with invalid account name refuses +OK auth.sh without valid config refuses + +selftest: 15 passed, 0 failed diff --git a/agents/darkwing/work/queue-62/out/gate/test-conductor.log b/agents/darkwing/work/queue-62/out/gate/test-conductor.log new file mode 100644 index 000000000..c2fa1c63a --- /dev/null +++ b/agents/darkwing/work/queue-62/out/gate/test-conductor.log @@ -0,0 +1,55 @@ +Note: switching to '6ae8b844231244830b8b09e0a2ef4b325644ffca'. + +You are in 'detached HEAD' state. You can look around, make experimental +changes and commit them, and you can discard any commits you make in this +state without impacting any branches by switching back to a branch. + +If you want to create a new branch to retain commits you create, you may +do so (now or later) by using -c with the switch command. Example: + + git switch -c + +Or undo this operation with: + + git switch - + +Turn off this advice by setting config variable advice.detachedHead to false + +Not currently on any branch. +nothing to commit, working tree clean +Note: switching to '6ae8b844231244830b8b09e0a2ef4b325644ffca'. + +You are in 'detached HEAD' state. You can look around, make experimental +changes and commit them, and you can discard any commits you make in this +state without impacting any branches by switching back to a branch. + +If you want to create a new branch to retain commits you create, you may +do so (now or later) by using -c with the switch command. Example: + + git switch -c + +Or undo this operation with: + + git switch - + +Turn off this advice by setting config variable advice.detachedHead to false + +OK dry-run: allowed change, exit 0, nothing committed (exit 0) +OK dry-run committed nothing +OK apply: allowed change exits 0 (exit 0) +OK apply: attribution in commit subject +OK apply: target tree clean after commit +OK disallowed path refused (exit 1) +OK disallowed path: target untouched +OK syntax gate refused broken .mjs (exit 1) +OK syntax gate: target untouched +OK suite failure refused (exit 1) +OK suite failure: target reverted to clean +OK disabled policy refused (exit 2) +OK disabled policy: target untouched +OK failed run refused (exit 1) +OK failed run: target untouched +OK missing run exits 4 (exit 4) +OK invalid policy exits 2 (exit 2) + +selftest: 17 passed, 0 failed diff --git a/agents/darkwing/work/queue-62/out/gate/test-config.log b/agents/darkwing/work/queue-62/out/gate/test-config.log new file mode 100644 index 000000000..76c05ef74 --- /dev/null +++ b/agents/darkwing/work/queue-62/out/gate/test-config.log @@ -0,0 +1,26 @@ +OK absent adapter defaults to pi +OK adapter mock validates (exit 0) +OK unsupported adapter exits 2 (exit 2) +OK env exports adapter +OK bootstrap creates default when absent (exit 0) +OK bootstrap wrote config file +OK bootstrap is idempotent on existing config (exit 0) +OK bootstrap did not rewrite existing config +OK validate missing config exits 3 (exit 3) +OK malformed JSON exits 2 (exit 2) +OK unsupported configVersion exits 2 (exit 2) +OK unknown top-level key exits 2 (exit 2) +OK unknown execution key exits 2 (exit 2) +OK unsupported backend exits 2 (exit 2) +OK unsupported environment exits 2 (exit 2) +OK relative dataRoot exits 2 (exit 2) +OK non-canonical dataRoot exits 2 (exit 2) +OK filesystem root dataRoot exits 2 (exit 2) +OK home directory dataRoot exits 2 (exit 2) +OK dataRoot containing config dir exits 2 (exit 2) +OK control character in provider exits 2 (exit 2) +OK symlinked config file exits 2 (exit 2) +OK env exports resolve correctly +OK failed validation modified nothing + +selftest: 24 passed, 0 failed diff --git a/agents/darkwing/work/queue-62/out/gate/test-discord.log b/agents/darkwing/work/queue-62/out/gate/test-discord.log new file mode 100644 index 000000000..b013f9ffa --- /dev/null +++ b/agents/darkwing/work/queue-62/out/gate/test-discord.log @@ -0,0 +1,70 @@ +toolchain: node v26.8.1 + +OK syntax: packages/discord/src/approvals.mjs +OK syntax: packages/discord/src/authorize.mjs +OK syntax: packages/discord/src/binding.mjs +OK syntax: packages/discord/src/cli.mjs +OK syntax: packages/discord/src/connector.mjs +OK syntax: packages/discord/src/context.mjs +OK syntax: packages/discord/src/engine-pi.mjs +OK syntax: packages/discord/src/errors.mjs +OK syntax: packages/discord/src/gateway.mjs +OK syntax: packages/discord/src/git.mjs +OK syntax: packages/discord/src/journal.mjs +OK syntax: packages/discord/src/notify.mjs +OK syntax: packages/discord/src/rest.mjs +OK syntax: packages/discord/src/setspark.mjs +OK syntax: packages/discord/src/tools.mjs +OK syntax: packages/discord/src/web.mjs +OK syntax: packages/discord/bin/git-credential.mjs +OK syntax: packages/discord/extension/tools.mjs +OK syntax: packages/discord/tests/approvals.test.mjs +OK syntax: packages/discord/tests/authorize.test.mjs +OK syntax: packages/discord/tests/binding.test.mjs +OK syntax: packages/discord/tests/connector.test.mjs +OK syntax: packages/discord/tests/context.test.mjs +OK syntax: packages/discord/tests/engine.test.mjs +OK syntax: packages/discord/tests/fake-pi.mjs +OK syntax: packages/discord/tests/gateway.test.mjs +OK syntax: packages/discord/tests/git.test.mjs +OK syntax: packages/discord/tests/helpers.mjs +OK syntax: packages/discord/tests/journal.test.mjs +OK syntax: packages/discord/tests/notify.test.mjs +OK syntax: packages/discord/tests/recover.test.mjs +OK syntax: packages/discord/tests/rest.test.mjs +OK syntax: packages/discord/tests/setspark.test.mjs +OK syntax: packages/discord/tests/tools.test.mjs +OK syntax: packages/discord/tests/web.test.mjs +OK syntax: packages/discord/fixtures/claim-worker.mjs +OK syntax: packages/discord/fixtures/legacy-owner-worker.mjs +OK syntax: scripts/discord.sh +OK syntax: scripts/discord-service.sh +OK packages/discord declares no dependencies +OK no bot-token-shaped string in packages/discord +OK fixture binding uses placeholder ids only +OK fixture binding validates +OK real pi with the extension exposes exactly list_dir, read_file, search and no built-in tool +OK real pi with a writable root exposes exactly the three reads plus write_file and edit_file, and writes nothing at start +OK real pi with a web key exposes the three reads plus web_fetch and web_search, and no write tool without a writable root +OK real pi with a git root exposes the reads, writes and the four git verbs, commits nothing at start, and never shows the token +OK real pi with protocol vault adds reserve_id to the git verbs +OK real pi with a setspark key exposes the reads and the eight record verbs, no counters, and never shows the key +OK real pi refuses a git key on a read-only root (fail closed) +OK real pi with the pilot flags (--no-tools) exposes no tool at all +OK real pi exits non-zero without MOSAIC_DISCORD_TOOLS: no session, no tools (fail closed) +OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT +OK node --test packages/discord/tests/ (ℹ pass 178) +OK scripts/discord.sh --help exits 0 +OK scripts/discord.sh check without a binding exits 4 +OK scripts/discord.sh recover without a binding exits 4 +OK scripts/discord.sh reload without a binding exits 4 +OK scripts/discord-service.sh without a command exits 4 +OK service unit renders with the repository path, a supervised run as the main process, exit 3 never retried, and reload as SIGHUP +OK service install writes the rendered unit (0644) and leaves no temp file +OK service install a second time reports unchanged +OK systemd-analyze verify accepts the rendered unit +OK service uninstall removes the unit file +OK service install with an unknown flag exits 4 +OK service install with USER unset finishes and names the account for lingering + +discord suite: 66 passed, 0 failed diff --git a/agents/darkwing/work/queue-62/out/gate/test-extension-package.log b/agents/darkwing/work/queue-62/out/gate/test-extension-package.log new file mode 100644 index 000000000..96f9a4c75 --- /dev/null +++ b/agents/darkwing/work/queue-62/out/gate/test-extension-package.log @@ -0,0 +1,21 @@ +OK initial ordinary-file install +OK installed tree matches canonical source +OK installed tree has no symlinks +OK check detects installation drift +OK sync refuses to overwrite installation drift +OK check detects an extra destination file +OK check detects an extra destination directory +OK check rejects a destination symlink +OK sync accepts a canonical source update +OK updated installation matches canonical source +scripts/test-extension-package.sh: line 14: 2151932 Killed "$@" > /dev/null 2>&1 +OK forced interruption kills the replacing process +OK next invocation recovers old consistent installation +OK interrupted replacement rolled back +OK sync succeeds after interruption recovery +OK unlocked stale lock file does not block +OK active lock refuses a concurrent sync +OK source symlink fails closed +OK nested second entrypoint fails closed + +extension package selftest: 18 passed, 0 failed diff --git a/agents/darkwing/work/queue-62/out/gate/test-foundation.log b/agents/darkwing/work/queue-62/out/gate/test-foundation.log new file mode 100644 index 000000000..6338e1e6d --- /dev/null +++ b/agents/darkwing/work/queue-62/out/gate/test-foundation.log @@ -0,0 +1,53 @@ +toolchain: node v26.8.1, python 3.12.8, jsonschema 4.26.0 + +OK syntax: scripts/foundation-inspect.mjs +OK syntax: scripts/foundation/strict-json.mjs +OK syntax: scripts/foundation/canonical.mjs +OK syntax: scripts/foundation/resolve.mjs +OK syntax: scripts/foundation/validate-record.mjs +OK syntax: scripts/foundation/fixtures/build-fixtures.mjs +OK syntax: scripts/foundation/canonical.test.mjs +OK syntax: scripts/foundation/cli.test.mjs +OK syntax: scripts/foundation/fixtures.test.mjs +OK syntax: scripts/foundation/resolve.test.mjs +OK syntax: scripts/foundation/strict-json.test.mjs +OK syntax: scripts/foundation/verify-schema.py (ast only; no bytecode written) +OK fixture generator runs +OK checked-in fixtures/bundles equal a fresh generation +OK checked-in fixtures/raw equal a fresh generation +OK checked-in fixtures/index.json equal a fresh generation +OK checked-in demo bundles equal a fresh generation +OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT +OK node --test scripts/foundation/ (ℹ pass 80) +OK differential schema oracle: PASS: differential schema oracle (finite corpus; compatibility evidence, not equivalence proof) + platform witness: strftime('%Y') for year 999 -> '999' (pinned checker refuses years 0001..0999) + node v26.8.1; corpus 1568 records (38 pinned fixtures, 478 unique bundle records, 1052 typeCase/mutation/lexical cases) + schema column: agree-valid 540, agree-invalid 991, DISAGREEMENTS 0; strict-only (parser-bound) cases: 27; unsupported-kind records not schema-assessed by the inspector: 10 + profile column (schema-valid records only): profile-valid 510, profile-invalid 30 + profile refusals asserted: 30 schema-agreed-valid records refused only by the strict typed-string profile (rule profile-pattern-mismatch), 12 declared by name; 73 named probes verified against declared schema/profile columns +OK oracle: zero schema-column disagreements with the pinned checker +OK oracle: strict-only profile refusals are counted and asserted +OK demo: permitted read preview exits 0 (exit 0) +OK demo: permitted file.change preview exits 0 (exit 0) +OK demo: assignment.change proposal is unresolved (exit 3) (exit 3) +OK demo: revoked registration is refused (exit 3) (exit 3) +OK demo: message is not authority (exit 3) (exit 3) +OK usage: no arguments exits 2 (exit 2) +OK io: missing file exits 4 (exit 4) +OK io: directory exits 4 (exit 4) +OK io: symlink exits 4 (O_NOFOLLOW) (exit 4) +OK bound: oversize fixture exits 2 (exit 2) +OK profile: one final LF in a typed selection id is refused before admission (exit 2) (exit 2) +OK profile: two final LFs fail the schema pattern itself (exit 2) (exit 2) +OK profile: escaped newlines in free-form text stay allowed (exit 0) (exit 0) +OK profile refusal is invalid-request/profile-pattern-mismatch with selection and operation withheld, value not echoed +OK text output starts with the disclaimer +OK json output is valid JSON with result allowed and exactly the charter §7 fields +OK json golden matches byte-for-byte +OK sandboxed bundle run (env -i, PATH=/nonexistent) produced the unresolved proposal +OK sandbox inventory (path/type/size/mode/uid/gid/inode/mtime/sha256) unchanged by runs +OK canary never printed (bundle run and credential-file run) +OK a non-bundle JSON file is refused at the shape gate, not read into output +OK no field of the non-bundle file is echoed + +selftest: 44 passed, 0 failed diff --git a/agents/darkwing/work/queue-62/out/gate/test-queue.log b/agents/darkwing/work/queue-62/out/gate/test-queue.log new file mode 100644 index 000000000..5db3f314e --- /dev/null +++ b/agents/darkwing/work/queue-62/out/gate/test-queue.log @@ -0,0 +1,35 @@ +toolchain: node v26.8.1, git version 2.55.0 + +OK syntax: packages/queue/src/cli.mjs +OK syntax: packages/queue/src/errors.mjs +OK syntax: packages/queue/src/io.mjs +OK syntax: packages/queue/src/lock.mjs +OK syntax: packages/queue/src/queue.mjs +OK syntax: packages/queue/src/review.mjs +OK syntax: packages/queue/src/store.mjs +OK syntax: packages/queue/tests/commit.test.mjs +OK syntax: packages/queue/tests/data.test.mjs +OK syntax: packages/queue/tests/dispatch.test.mjs +OK syntax: packages/queue/tests/helpers.mjs +OK syntax: packages/queue/tests/lock.test.mjs +OK syntax: packages/queue/tests/migration.test.mjs +OK syntax: packages/queue/tests/review.test.mjs +OK syntax: packages/queue/tests/store.test.mjs +OK syntax: packages/queue/tests/write.test.mjs +OK syntax: packages/queue/tests/fixtures/fake-gitea.mjs +OK syntax: packages/queue/tests/fixtures/kill-at.mjs +OK syntax: packages/queue/tests/fixtures/lock-child.mjs +OK syntax: packages/queue/tests/fixtures/mosaic-pre-a2.sh +OK syntax: scripts/queue-commit.sh +OK syntax: scripts/git-hooks/pre-commit +OK syntax: scripts/mosaic +OK queue-commit.sh, the guard and scripts/mosaic are executable +OK packages/queue declares no dependencies +ℹ tests 149 +ℹ pass 149 +ℹ fail 0 +OK node --test packages/queue/tests/ +OK scripts/mosaic queue help +skip queue verify and render --check: this checkout (/home/jwoltje/darkwing-scratch/r62/gate) is not the queue's canonical root (/mnt/storage/src/mosaic-stack) + +queue suite: 27 passed, 0 failed diff --git a/agents/darkwing/work/queue-62/out/gate/test-release.log b/agents/darkwing/work/queue-62/out/gate/test-release.log new file mode 100644 index 000000000..7202b85d4 --- /dev/null +++ b/agents/darkwing/work/queue-62/out/gate/test-release.log @@ -0,0 +1,16 @@ +OK valid RELEASE resolves (exit 0) +OK invalid RELEASE exits 1 (exit 1) +OK missing RELEASE exits 1 (exit 1) +OK valid RELEASE leaves image tag consistent with version +OK status safe on empty state (exit 0) +OK status created no pointer +OK fault-injected activation refuses (exit 1) +OK refused activation wrote no pointer +OK refusal logged exactly once with valid fields +OK healthy activation succeeds (exit 0) +OK pointer written with valid fields +OK repeat activation succeeds (log grows) (exit 0) +OK log is append-only across activations +OK rollback without previous refuses (exit 1) + +selftest: 14 passed, 0 failed diff --git a/agents/darkwing/work/queue-62/out/gate/test-task.log b/agents/darkwing/work/queue-62/out/gate/test-task.log new file mode 100644 index 000000000..cd89fe9b2 --- /dev/null +++ b/agents/darkwing/work/queue-62/out/gate/test-task.log @@ -0,0 +1,32 @@ +OK valid task validates (exit 0) +OK unknown task key exits 2 (exit 2) +OK unsupported taskVersion exits 2 (exit 2) +OK invalid task id exits 2 (exit 2) +OK empty prompt exits 2 (exit 2) +OK NUL in expectExact exits 2 (exit 2) +OK out-of-range timeout exits 2 (exit 2) +OK missing mission file exits 4 (exit 4) +OK task with valid mission validates (exit 0) +OK invalid mission exits 2 (exit 2) +OK validate missing task exits 4 (exit 4) +OK validation does not modify the task file +OK prune dry-run exits 0 (exit 0) +OK dry-run deleted nothing +OK prune --keep=2 --yes removes oldest (exit 0) +OK kept exactly 2 newest runs +OK newest run kept, oldest pruned +OK append-only receipt written (3 entries) +OK sessions/workspaces untouched by prune +OK prune with invalid keep exits 4 (exit 4) +skip adapter seam cases (docker daemon unavailable) +skip workspace/capability cases (docker daemon unavailable) +skip live task cases (docker unavailable) +OK onboard without name exits 4 (non-interactive) (exit 4) +OK onboard --name renders profile (exit 0) +OK profile written +OK canon structure: required filled, optional placeholdered +OK canon sections present +skip live recall pair: user recall run, recalled user name (docker daemon unavailable) +OK no agent identity on headless run + +selftest: 26 passed, 0 failed diff --git a/agents/darkwing/work/queue-62/out/mutants.sh b/agents/darkwing/work/queue-62/out/mutants.sh new file mode 100644 index 000000000..4addf224e --- /dev/null +++ b/agents/darkwing/work/queue-62/out/mutants.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Each mutant edits one source file in the build worktree, runs the tasks tests, then restores +# the file from the worktree index (the candidate). Expect every mutant to fail at least one test. +# The tasks tests run for each mutant; the cli tests too for the config.mjs one. +export TMPDIR=~/darkwing-scratch/r62/tmp +cd ~/darkwing-scratch/r62/build +S=packages/tasks/src/sync.mjs; U=packages/tasks/src/startup.mjs +run() { # name file sed-expr [test glob] + sed -i "$3" "$2" + if git diff --quiet -- "$2"; then echo "$1: MUTATION DID NOT APPLY"; return; fi + node --test "${4:-packages/tasks/tests/*.test.mjs}" > ~/darkwing-scratch/r62/tmp/mut-$1.log 2>&1 + echo "$1: rc=$? $(grep -E '^ℹ (pass|fail) ' ~/darkwing-scratch/r62/tmp/mut-$1.log | tr '\n' ' ')" + grep -E '^not ok|^# Subtest|✖ ' ~/darkwing-scratch/r62/tmp/mut-$1.log | grep -v '^ℹ' | sed 's/^/ /' | sort -u | head -8 + git checkout -q -- "$2" +} +run no-leave-tree-check $S 's/} else if (!ctx.tree.has(project)) {/} else if (false) {/' +run no-watch-only-bucket $S 's/^const fieldsFor = .*/const fieldsFor = (task, bucket) => taskFields(task, bucket);/' +run placed-by-id $S 's/placed.add(ref(project, id));/placed.add(id);/; s/.filter((r) => !placed.has(r));/.filter((r) => !placed.has(parseRef(r).task));/' +run keep-archived $U 's/if (p.is_archived || p.id === ctx.project) continue;/if (p.id === ctx.project) continue;/' +run config-drops-descendants packages/cli/src/config.mjs 's/ descendants: v\["tracker.descendants"\],/ descendants: false,/' 'packages/cli/tests/*.test.mjs' +git diff --quiet -- $S $U packages/cli/src/config.mjs && echo "restored: sources match the candidate index" diff --git a/agents/darkwing/work/queue-62/out/mutants.txt b/agents/darkwing/work/queue-62/out/mutants.txt new file mode 100644 index 000000000..1b30ebdbe --- /dev/null +++ b/agents/darkwing/work/queue-62/out/mutants.txt @@ -0,0 +1,20 @@ +no-leave-tree-check: rc=1 ℹ pass 62 ℹ fail 1 + ✖ a child that leaves the tree has its tasks recorded missing, and the ledger keeps their history (160.370577ms) + ✖ failing tests: +no-watch-only-bucket: rc=1 ℹ pass 58 ℹ fail 5 + ✖ a child made after startup joins the tree at the next reconcile (149.827337ms) + ✖ a move inside the tree is a moved tombstone plus a snapshot under the new ref (162.487755ms) + ✖ a three-level tree is read per project, and a project outside it is not (149.484335ms) + ✖ a watch-only child keeps the business ready, and a failed child read refuses as transient (162.164514ms) + ✖ bucket 0 on a watch-only task never becomes a bucket or a state (133.187732ms) + ✖ failing tests: +placed-by-id: rc=1 ℹ pass 62 ℹ fail 1 + ✖ a move inside the tree is a moved tombstone plus a snapshot under the new ref (163.981091ms) + ✖ failing tests: +keep-archived: rc=1 ℹ pass 62 ℹ fail 1 + ✖ failing tests: + ✖ the project list refuses without read_all, and skips archived projects and saved filters (434.524613ms) +config-drops-descendants: rc=1 ℹ pass 82 ℹ fail 1 + ✖ failing tests: + ✖ trackers come from the tracker.* variables of the one project that names a tracker project (178.137587ms) +restored: sources match the candidate index diff --git a/agents/darkwing/work/queue-62/probes.md b/agents/darkwing/work/queue-62/probes.md new file mode 100644 index 000000000..d90582303 --- /dev/null +++ b/agents/darkwing/work/queue-62/probes.md @@ -0,0 +1,165 @@ +# Row 62 probes: reading a Vikunja project tree + +Row 62 (#1555) has the tasks adapter read a project and its descendants. +No probe in this row touched a live Vikunja. Sage told me not to touch any +board or project, live or scratch, during the build. The evidence below is +the v2.7.0 source, Sage's live records, and the fake. Sage runs the reads +in "Gate read" with the sync key at the gate. + +## Source + +Vikunja tag `v2.7.0`, commit `a16be96aa454671fdf213b0fbe411dd38a098418`, +read from a local checkout. Paths are relative to the repository root. + +### P1. The project list + +`pkg/routes/api/v2/projects.go:39-46` registers `GET /projects` +(`projects-list`). Its description: "Returns the projects the +authenticated user has access to (owned plus shared, with child projects +of accessible parents), paginated. Archived projects are excluded unless +is_archived=true." The adapter reads it with the standard envelope +(`items`, `total`, `page`, `per_page`, `total_pages`), every page. + +`pkg/models/project.go:620-651`: `getArchived` false adds +`is_archived = false` to the query. +`pkg/models/project.go:154-158`: pseudo-project ids are negative. -1 is +Favorites, -2 and below are saved filters. The adapter skips any id at or +below 0. + +The recorded `GET /projects/{p}` body in +`packages/tasks/tests/fixtures/v2-shapes.json` has `parent_project_id` +and `is_archived`, so the field names and types come from a v2.7.0 +answer, not only from source. No recording holds a `GET /projects` list. +`shapes.test.mjs` checks the fake's list items against the recorded +project object and its envelope against the recorded views list. + +### P2. Archive state reaches every descendant + +`pkg/models/project.go:884-888`: "is_archived is materialized down the +tree". `pkg/models/project.go:1216-1217` calls +`SetArchiveStateForProjectDescendants` when the flag changes, and +`:1634` is the update that writes it. An archived parent's children come +back with `is_archived: true` too, so skipping archived items drops the +whole subtree. The adapter also checks the flag itself, in case a list +ever includes archived projects. + +### P3. Access inherits down the tree + +`pkg/models/project_access.go:31-75`. `projectAccess` holds every project +a user can reach. The query joins each grant to `project_ancestors`, so +a share on a parent reaches every descendant. An inherited admin wins +anywhere in the chain. Below admin, the nearest grant wins. The sync bot +needs one share on the root. The fake's `#access` follows the same rule. + +### P4. One task read across projects + +`pkg/routes/api/v2/task_collection.go:134-141` registers `GET /tasks` +(`tasks-list`): "Returns the tasks the authenticated user can see across +every project they have access to, paginated and flat." + +`pkg/models/api_routes.go:495-501`, in `tokenAuthorizesRoute`: a token +with `tasks` `read_all` may call `GET /api/v2/tasks` as well as +`/api/v2/projects/:project/tasks`. The sync key already has that scope. + +`pkg/models/task_collection.go:220-230`: with no project id, the project +set is `getRawProjectsForUser` with the default options, so archived +projects are left out (P1). + +`pkg/models/task_collection_filter.go:157-158` maps a filter field +`project` to `project_id`, so `project in 19, 18, 20, 21` filters by +project. + +The adapter doesn't use this read. It reads per project, as the brief +says, until Sage's live read shows the cross-project read works for the +sync key. If it does, a tick is two GETs whatever the tree's size, and a +switch is a follow-up row. + +### P5. Writes on buckets and views + +The adapter writes no project, view or bucket. Sage's first install +attempt on #19 +(`agents/sage/work/vikunja-setup/2026-10-11_install-19-attempt1.json`) +found that on v2.7.0 bucket and view writes answer 405 under `/api/v2` and work only +under `/api/v1`. Nothing in this row depends on that. It matters for the +runbook, which installs boards by hand. + +## Live facts used + +From Sage, with the records under `agents/sage/work/vikunja-setup/`: + +- The bus is on project #19. Its kanban view is 76. The five buckets are + todo 159, in-progress 161, in-review 248, blocked 162, done 57. +- View 76 has four more buckets: 55, 56, 158 (`Backlog`) and 160 + (`Pending`). The install check ignores extra buckets. A task in one is + recorded with that raw bucket id (`tree.test.mjs`, "a task in a bucket + outside the five keeps its raw bucket id"). +- #19's children are 18, 20 and 21. None has the five buckets, so all + three are watch-only. +- Sync key 9 has `projects` `read_all` and lists 29 projects + (`2026-10-11_shares-read.json`). + +## Fake + +`packages/tasks/src/fake.mjs` models: + +- `parent_project_id` on every project, set by `project(..., {parent})` + and changed by `reparent()` +- `GET /projects` with `projects` `read_all`, archived projects left out + unless `is_archived=true`, saved filters and Favorites as negative ids + (`pseudoProject()`) +- `archive()`, which marks the project and every descendant +- access through an ancestor's share, as in P3, and `unshare()` + +`packages/tasks/tests/tree.test.mjs` runs the adapter against it. The +three-level test counts requests. A quiet tick on a root, one installed +child and one watch-only grandchild is 6 GETs, two per project. A +reconcile is 11: the project list, views and buckets for each of the two +children, and two per project. For #19 (root plus three watch-only +children) a quiet tick is 8 GETs, plus one per extra page of 50 tasks. + +## Gate read + +Two GETs, sync key 9, against the live instance. Record the status, the +item count on the page and `total`. Record no titles. Spaces in a filter +go as `%20`. + +1. The tree. + + ``` + GET /api/v2/projects + query: per_page=50&page=1 + key: sync key 9 + ``` + + Expect 200 and `total` 29 (Sage's earlier read). Also record, for ids + 19, 18, 20 and 21 only, `parent_project_id` and `is_archived`. Expect + 18, 20 and 21 to have parent 19, and all four `is_archived: false`. + This is the read `discover()` makes at startup and at each reconcile. + +2. One task read across the tree. + + ``` + GET /api/v2/tasks + query: filter=project in 19, 18, 20, 21&sort_by=id&order_by=asc&per_page=50&page=1 + key: sync key 9 + ``` + + A 200 means the cross-project read works for the sync key, and a + follow-up row can move a tick to it. 401 or 403 means the scope check + in P4 doesn't hold for this key, and the adapter keeps reading per + project. For comparison, the per-project open counts are what the + adapter reads today, one per project: + + ``` + GET /api/v2/projects/{19,18,20,21}/tasks + query: filter=done = false&sort_by=id&order_by=asc&per_page=50&page=1 + key: sync key 9 + ``` + + These four are optional. They let the gate compare `total` from read 2 + with the sum the adapter would see. Read 2 has no `done` filter, so + its `total` counts closed tasks too. + +The host config with `tracker.descendants` set reaches the adapter +through `packages/cli/src/config.mjs` (lead decision 87), so the gate +needs no packet script.