Files
stack/agents/darkwing/work/queue-62/build.patch
T
jason.woltjeandClaude Opus 5.5 90e568624f row 62 (#1555) round 3 packet: confirm read before a child drops, candidate 8b81e836
R2/B1: after a forbidden or not-found refusal on a child, the tick reads
GET /projects/<id> and drops the child only on 403 or 404; a deleted
kanban view (project read 200) fails the tick as in round 1. Tests 18
and 19, 23/23 mutants killed, gate on ae428a54 green (14 suites).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:54:19 -05:00

1318 lines
69 KiB
Diff

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..61b36664 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,95 @@ 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:<project>/<task>`. 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, no longer shared with the sync bot, or deleted. The
+next reconcile drops it, or a tick before that (below), 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.
+A tick doesn't read the project list, so it keeps reading an archived
+child, or one moved out with a share of its own, until that reconcile.
+A child that answers 403 or 404 in a tick (no longer shared, moved under
+a project the sync bot can't read, or deleted) leaves the tree at that
+tick only if `GET /projects/<id>` then answers 403 or 404 too, and its
+tasks get the same one-by-one read. A deleted kanban view answers 404
+while its project still reads 200, so the child stays and the tick fails
+until the next reconcile reads the child again, watch-only if it has no
+installed kanban view. A 403 or 404 on the root
+still fails the tick, and so does any other failure on a child. A new
+child joins at the next reconcile, and so does a dropped one the sync
+bot can read again.
+
+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 +369,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:
-// {<business>: {baseUrl, project, pollSeconds, reconcileMinutes}}. A business with no entry has
+// {<business>: {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..633224db 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,34 @@ 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;
+ }
+ // Vikunja deletes a project's descendants and every task in them with it.
+ deleteProject(project) {
+ const gone = [...this.#projects.keys()].filter((id) => id === project || this.#ancestors(id).includes(project));
+ for (const t of [...this.#tasks.values()]) if (gone.includes(t.project)) this.#tasks.delete(t.id);
+ for (const id of gone) this.#projects.delete(id);
+ }
+ // Drops a view, its buckets and the tasks' places in them. The project still reads, and a read
+ // of the view answers 404 with code 13001.
+ deleteView(view) {
+ for (const [id, b] of this.#buckets) if (b.view === view) this.#buckets.delete(id);
+ for (const t of this.#tasks.values()) t.buckets.delete(view);
+ this.#views.delete(view);
+ }
+ // 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 +264,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 +439,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..852d2b2c 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;
@@ -147,39 +165,63 @@ async function missing(ctx, current, refs, out) {
const record = (ctx, out) => {
if (out.snapshots.length || out.events.length) ctx.broker.recordTask({ business: ctx.business, ...out });
};
+// A child that answers 403 or 404 may have left the tree after the last reconcile: no longer
+// shared, moved under a project the bot can't read, or deleted. A 404 can also be the child's kanban
+// view deleted while the project still reads, so the tick reads the project itself and drops the
+// child only on a 403 or 404 there. Then missing() reads its open tasks one by one, as after a
+// reconcile. Anything else fails the tick, and so does a 403 or 404 on the root.
+async function leftTree(ctx, project, e) {
+ if (project === ctx.project || !(e instanceof BusError) || (e.code !== 'tracker-forbidden' && e.code !== 'tracker-not-found')) return false;
+ const r = await ctx.sync('GET', `/projects/${project}`);
+ return r.status === 403 || r.status === 404;
+}
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) {
+ try {
+ 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 });
+ } catch (e) {
+ if (!(await leftTree(ctx, project, e))) throw e;
+ ctx.tree.delete(project);
+ }
+ }
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 +229,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..e6851468
--- /dev/null
+++ b/packages/tasks/tests/tree.test.mjs
@@ -0,0 +1,493 @@
+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) => {
+ // The fake's clock runs ahead on demand, so an edit gets a new `updated` second.
+ let ahead = 0;
+ const w = await world(t, { ...TREE, fake: { now: () => Date.now() + ahead } });
+ 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);
+ const refs = Object.keys(children).map((name) => ref(children[name], tasks[name]));
+ const [archived, reparented, hidden] = refs;
+ // A tick reads the tree it has, and the project list is read at reconcile. The hidden child's
+ // board answers 403, so the tick drops it at once; the other two still read until the reconcile.
+ await w.adapter.tick('demo');
+ assert.deepEqual(missing(w), [[hidden, { reason: 'no-access' }]]);
+ // An edit the reconcile reads after the project left keeps the tombstone's `updated` the last one
+ // the bus recorded.
+ ahead = 2000;
+ await w.ui('PATCH', `/tasks/${tasks.reparented}`, { title: 'edited outside' });
+ await w.adapter.reconcile('demo');
+ assert.deepEqual(
+ missing(w),
+ [hidden, archived, reparented].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 child deleted or hidden between reconciles drops out at the next tick, and the root still refuses', async (t) => {
+ const w = await world(t, TREE);
+ const deleted = w.fake.project('Deleted', w.owner, { parent: w.project });
+ w.fake.install(deleted);
+ // Watch-only, so its tick read is the task list, not a board.
+ const hidden = w.fake.project('Hidden', w.owner, { parent: w.project });
+ const kept = w.fake.project('Kept', w.owner, { parent: w.project });
+ const launchpad = w.fake.project('Launchpad', w.owner);
+ const tasks = {};
+ for (const [name, p] of Object.entries({ root: w.project, deleted, hidden, kept })) tasks[name] = await add(w, p, name);
+ await ready(w);
+ w.fake.deleteProject(deleted);
+ w.fake.reparent(hidden, launchpad);
+ await w.ui('PATCH', `/tasks/${tasks.root}`, { title: 'root renamed' });
+ await w.ui('PATCH', `/tasks/${tasks.kept}`, { title: 'kept renamed' });
+ await sleep(20);
+ await w.adapter.tick('demo');
+ assert.equal(state(w).state, 'ready');
+ assert.deepEqual(missing(w).sort(), [
+ [ref(deleted, tasks.deleted), { reason: 'not-found' }],
+ [ref(hidden, tasks.hidden), { reason: 'no-access' }],
+ ]);
+ // The root and the sibling changed in the same tick, and both are recorded.
+ assert.equal(w.snapshots(ref(w.project, tasks.root)).at(-1).fields.title, 'root renamed');
+ assert.equal(w.snapshots(ref(kept, tasks.kept)).at(-1).fields.title, 'kept renamed');
+ // The next tick reads neither, and the reconcile finds neither.
+ const reads = await counted(w, () => w.adapter.tick('demo'));
+ assert.ok(!reads.some((r) => r.includes(`/projects/${deleted}/`) || r.includes(`/projects/${hidden}/`)), reads.join('\n'));
+ assert.deepEqual(await w.adapter.reconcile('demo'), { snapshots: 0, events: 0 });
+ // Any other failure on a child still refuses the tick, and the child stays.
+ w.fake.fault('GET', new RegExp(`^/projects/${kept}/tasks$`), 503);
+ await assert.rejects(w.adapter.tick('demo'), { code: 'tracker-unavailable' });
+ await w.ui('PATCH', `/tasks/${tasks.kept}`, { title: 'kept again' });
+ await sleep(20);
+ await w.adapter.tick('demo');
+ assert.equal(w.snapshots(ref(kept, tasks.kept)).at(-1).fields.title, 'kept again');
+ // The root answering 403 or 404 still refuses the tick, and stays in the tree.
+ w.fake.unshare(w.project, w.bots.sync);
+ await assert.rejects(w.adapter.tick('demo'), { code: 'tracker-forbidden' });
+ w.fake.share(w.project, w.bots.sync, 0);
+ await w.ui('PATCH', `/tasks/${tasks.root}`, { title: 'root again' });
+ await sleep(20);
+ await w.adapter.tick('demo');
+ assert.equal(w.snapshots(ref(w.project, tasks.root)).at(-1).fields.title, 'root again');
+ w.fake.deleteProject(w.project);
+ await assert.rejects(w.adapter.tick('demo'), { code: 'tracker-not-found' });
+});
+test('a reconcile that fails keeps the previous tree', async (t) => {
+ const w = await world(t, TREE);
+ const first = w.fake.project('First', w.owner, { parent: w.project });
+ const second = w.fake.project('Second', w.owner, { parent: w.project });
+ w.fake.install(second);
+ const task = await add(w, second, 'second');
+ await ready(w);
+ // The first child's install check fails, so the walk stops before it reaches the second.
+ w.fake.fault('GET', new RegExp(`^/projects/${first}/views$`), 503);
+ await assert.rejects(w.adapter.reconcile('demo'), { code: 'tracker-unavailable' });
+ await w.ui('PATCH', `/tasks/${task}`, { title: 'renamed' });
+ await sleep(20);
+ await w.adapter.tick('demo');
+ assert.equal(w.snapshots(ref(second, task)).at(-1).fields.title, 'renamed');
+ assert.deepEqual(missing(w), []);
+});
+test("a closed task the cursor missed takes its own project's done bucket", async (t) => {
+ // An hour behind, so the cursor never returns the close and missing() reads the task.
+ 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);
+ const watched = w.fake.project('Watched', w.owner, { parent: w.project });
+ const tasks = { child: await add(w, child, 'child'), watched: await add(w, watched, 'watched') };
+ await ready(w);
+ for (const id of Object.values(tasks)) await w.ui('PATCH', `/tasks/${id}`, { done: true });
+ await sleep(20);
+ await w.adapter.tick('demo');
+ const child1 = w.snapshots(ref(child, tasks.child)).at(-1);
+ const watched1 = w.snapshots(ref(watched, tasks.watched)).at(-1);
+ assert.deepEqual([child1.via, child1.fields.done, child1.fields.bucket], ['task', true, childBoard.buckets.done]);
+ assert.deepEqual([watched1.via, watched1.fields.done, watched1.fields.bucket], ['task', true, WATCH_ONLY]);
+ assert.notEqual(childBoard.buckets.done, w.installed.buckets.done);
+});
+test("closed tasks read at startup take their own project's done bucket", async (t) => {
+ const w = await world(t, TREE);
+ const child = w.fake.project('Child', w.owner, { parent: w.project });
+ const childBoard = w.fake.install(child);
+ const watched = w.fake.project('Watched', w.owner, { parent: w.project });
+ const tasks = { child: await add(w, child, 'child'), watched: await add(w, watched, 'watched') };
+ for (const id of Object.values(tasks)) await w.ui('PATCH', `/tasks/${id}`, { done: true });
+ await ready(w);
+ const child1 = w.snapshots(ref(child, tasks.child)).at(-1);
+ const watched1 = w.snapshots(ref(watched, tasks.watched)).at(-1);
+ assert.deepEqual([child1.via, child1.fields.done, child1.fields.bucket], ['reconcile', true, childBoard.buckets.done]);
+ assert.deepEqual([watched1.via, watched1.fields.done, watched1.fields.bucket], ['reconcile', true, WATCH_ONLY]);
+});
+test("a closed task the cursor returns takes its own project's done bucket", async (t) => {
+ // Two closes per project: one before the tick, so the open read leaves it out, and one between the
+ // open read and the cursor walk, so both reads return it. `race` closes the second kind.
+ const race = new Map();
+ const w = await world(t, {
+ ...TREE,
+ fetch: async (url, init) => {
+ const r = await w.fake.fetch(url, init);
+ for (const [project, id] of race)
+ if (new URL(url).pathname.startsWith(`/api/v2/projects/${project}/`)) {
+ race.delete(project);
+ await w.ui('PATCH', `/tasks/${id}`, { done: true });
+ }
+ return r;
+ },
+ });
+ const child = w.fake.project('Child', w.owner, { parent: w.project });
+ const childBoard = w.fake.install(child);
+ const watched = w.fake.project('Watched', w.owner, { parent: w.project });
+ const before = { [child]: await add(w, child, 'child'), [watched]: await add(w, watched, 'watched') };
+ const during = { [child]: await add(w, child, 'child raced'), [watched]: await add(w, watched, 'watched raced') };
+ await ready(w);
+ for (const id of Object.values(before)) await w.ui('PATCH', `/tasks/${id}`, { done: true });
+ for (const [project, id] of Object.entries(during)) race.set(+project, id);
+ await sleep(20);
+ await w.adapter.tick('demo');
+ assert.equal(race.size, 0);
+ for (const [project, bucket] of [[child, childBoard.buckets.done], [watched, WATCH_ONLY]])
+ for (const id of [before[project], during[project]]) {
+ const s = w.snapshots(ref(project, id)).at(-1);
+ assert.deepEqual([s.via, s.fields.done, s.fields.bucket], ['cursor', true, bucket]);
+ }
+});
+test('a task moved out of a child that leaves the tree at the same time is moved, not no-access', async (t) => {
+ const w = await world(t, TREE);
+ const child = w.fake.project('Child', w.owner, { parent: w.project });
+ w.fake.install(child);
+ const task = await add(w, child, 'leaving');
+ await ready(w);
+ w.fake.archive(child);
+ w.fake.moveToProject(task, w.project);
+ await sleep(20);
+ await w.adapter.reconcile('demo');
+ assert.deepEqual(missing(w), [[ref(child, task), { reason: 'moved', project: w.project }]]);
+ assert.equal(w.snapshots(ref(w.project, task)).at(-1).fields.title, 'leaving');
+});
+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));
+});
+test('a child whose kanban view is deleted still refuses the tick, and the reconcile reads it watch-only', async (t) => {
+ const w = await world(t, TREE);
+ const child = w.fake.project('Child', w.owner, { parent: w.project });
+ w.fake.install(child);
+ const task = await add(w, child, 'child');
+ const rootTask = await add(w, w.project, 'root');
+ await ready(w);
+ const rows = () => w.store.get('SELECT (SELECT count(*) FROM events) + (SELECT count(*) FROM task_snapshots) n').n;
+ // The board answers 404 with code 13001, and the project still reads, so the child hasn't left
+ // the tree. The tick reads the project, fails, and writes nothing, not even the root's edit.
+ w.fake.deleteView(w.fake.kanban(child).id);
+ await w.ui('PATCH', `/tasks/${rootTask}`, { title: 'root renamed' });
+ await sleep(20);
+ const n = rows();
+ const reads = await counted(w, () => assert.rejects(w.adapter.tick('demo'), { code: 'tracker-not-found' }));
+ assert.ok(reads.includes(`GET /projects/${child}`), reads.join('\n'));
+ assert.equal(rows(), n);
+ assert.equal(state(w).state, 'ready');
+ // A 403 on a child the bot still reads fails the tick too, and the child stays.
+ w.fake.fault('GET', new RegExp(`^/projects/${child}/views/\\d+/buckets/tasks$`), 403);
+ await assert.rejects(w.adapter.tick('demo'), { code: 'tracker-forbidden' });
+ // The reconcile finds no kanban view and reads the child watch-only. Nothing is missing.
+ await w.adapter.reconcile('demo');
+ assert.deepEqual(missing(w), []);
+ assert.equal(w.snapshots(ref(child, task)).at(-1).fields.bucket, WATCH_ONLY);
+ assert.equal(w.snapshots(ref(w.project, rootTask)).at(-1).fields.title, 'root renamed');
+ // A 401 on a child read after the root read succeeds fails the tick, whatever the project read says.
+ w.fake.fault('GET', new RegExp(`^/projects/${child}/tasks$`), 401);
+ w.fake.fault('GET', new RegExp(`^/projects/${child}$`), 404);
+ const unauthorized = await counted(w, () => assert.rejects(w.adapter.tick('demo'), { code: 'tracker-unauthorized' }));
+ assert.ok(!unauthorized.includes(`GET /projects/${child}`), unauthorized.join('\n'));
+ // The owner's read takes the 404 the tick didn't use.
+ assert.equal((await w.ui('GET', `/projects/${child}`)).status, 404);
+ // A 404 on its task list whose project read fails as well keeps the child: the tick refuses.
+ w.fake.fault('GET', new RegExp(`^/projects/${child}/tasks$`), 404);
+ w.fake.fault('GET', new RegExp(`^/projects/${child}$`), 503);
+ await assert.rejects(w.adapter.tick('demo'), { code: 'tracker-not-found' });
+ await w.ui('PATCH', `/tasks/${task}`, { title: 'child renamed' });
+ await sleep(20);
+ await w.adapter.tick('demo');
+ assert.equal(w.snapshots(ref(child, task)).at(-1).fields.title, 'child renamed');
+ assert.deepEqual(missing(w), []);
+});
+test('a child dropped at a tick comes back at the next reconcile once the bot reads it again', async (t) => {
+ const w = await world(t, TREE);
+ const child = w.fake.project('Child', w.owner, { parent: w.project });
+ w.fake.install(child);
+ const launchpad = w.fake.project('Launchpad', w.owner);
+ const task = await add(w, child, 'child');
+ await ready(w);
+ const bucket = w.snapshots(ref(child, task)).at(-1).fields.bucket;
+ w.fake.reparent(child, launchpad);
+ await w.adapter.tick('demo');
+ assert.deepEqual(missing(w), [[ref(child, task), { reason: 'no-access' }]]);
+ // Back under the root: the tick still leaves it out, and the reconcile finds it again.
+ w.fake.reparent(child, w.project);
+ const reads = await counted(w, () => w.adapter.tick('demo'));
+ assert.ok(!reads.some((r) => r.includes(`/projects/${child}`)), reads.join('\n'));
+ await w.adapter.reconcile('demo');
+ assert.deepEqual(
+ w.snapshots(ref(child, task)).map((x) => x.fields.gone ?? x.fields.bucket),
+ [bucket, 'no-access', bucket],
+ );
+ assert.ok(open(w).some((r) => r.task_ref === ref(child, task)));
+ 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');
+});