docs(queue-62): row 62 round 2 candidate packet, R1 and six test gaps (darkwing)
Filbert's R1 (27279) and Dewey's matching blocking item (27280): a child
that answers 403 or 404 in a tick leaves the tree, and the root still
refuses. Tests for Filbert's N1-N3 and Dewey's three gaps. 18 mutants
killed; all 13 suites green at 122b1223. Manifest sha256 55316d88.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -107,7 +107,7 @@ index b7710013..d7cef73a 100644
|
||||
t.after(() => host.close(0));
|
||||
|
||||
diff --git a/packages/tasks/README.md b/packages/tasks/README.md
|
||||
index 1b764e82..ae2789be 100644
|
||||
index 1b764e82..2417ac48 100644
|
||||
--- a/packages/tasks/README.md
|
||||
+++ b/packages/tasks/README.md
|
||||
@@ -1,7 +1,7 @@
|
||||
@@ -149,7 +149,7 @@ index 1b764e82..ae2789be 100644
|
||||
|
||||
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.
|
||||
@@ -239,6 +242,90 @@ mtime moved.
|
||||
`close()` stops the timers and waits for the queue. A verb still queued
|
||||
at that point refuses with `tracker-closed`.
|
||||
|
||||
@@ -202,8 +202,9 @@ index 1b764e82..ae2789be 100644
|
||||
+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:
|
||||
+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.
|
||||
@@ -212,8 +213,13 @@ index 1b764e82..ae2789be 100644
|
||||
+
|
||||
+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.
|
||||
+A tick doesn't read the project list, so it keeps reading an archived
|
||||
+child, or one moved out with a share of its own, until that reconcile.
|
||||
+A child that answers 403 or 404 in a tick (no longer shared, moved under
|
||||
+a project the sync bot can't read, or deleted) leaves the tree at that
|
||||
+tick, and its tasks get the same one-by-one read. A 403 or 404 on the
|
||||
+root still fails the tick, and so does any other failure on a child. A
|
||||
+new child joins at the next reconcile.
|
||||
+
|
||||
+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
|
||||
@@ -234,7 +240,7 @@ index 1b764e82..ae2789be 100644
|
||||
## Refusals
|
||||
|
||||
| Code | Meaning |
|
||||
@@ -277,6 +358,15 @@ every user or task any recording held, so a key one route adds, like
|
||||
@@ -277,6 +364,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.
|
||||
|
||||
@@ -309,7 +315,7 @@ index d564c645..f0f8cacf 100644
|
||||
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
|
||||
index fc55529a..30d9179e 100644
|
||||
--- a/packages/tasks/src/fake.mjs
|
||||
+++ b/packages/tasks/src/fake.mjs
|
||||
@@ -6,9 +6,13 @@ import { NULL_DATE } from './digest.mjs';
|
||||
@@ -346,7 +352,7 @@ index fc55529a..2a9cfc14 100644
|
||||
for (const [kind, position] of [
|
||||
['list', 100],
|
||||
['gantt', 200],
|
||||
@@ -115,6 +120,21 @@ export class FakeVikunja {
|
||||
@@ -115,6 +120,27 @@ export class FakeVikunja {
|
||||
share(project, user, permission = 0) {
|
||||
this.#projects.get(project).shares.set(user, permission);
|
||||
}
|
||||
@@ -361,6 +367,12 @@ index fc55529a..2a9cfc14 100644
|
||||
+ 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);
|
||||
+ }
|
||||
+ // 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 });
|
||||
@@ -368,7 +380,7 @@ index fc55529a..2a9cfc14 100644
|
||||
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 {
|
||||
@@ -231,11 +257,39 @@ export class FakeVikunja {
|
||||
});
|
||||
return id;
|
||||
}
|
||||
@@ -412,7 +424,7 @@ index fc55529a..2a9cfc14 100644
|
||||
}
|
||||
#userJson(id) {
|
||||
const u = this.#users.get(id);
|
||||
@@ -378,7 +426,15 @@ export class FakeVikunja {
|
||||
@@ -378,7 +432,15 @@ export class FakeVikunja {
|
||||
_project(user, [p]) {
|
||||
const r = this.#projectFor(user, p);
|
||||
if (r.error) return r.error;
|
||||
@@ -504,7 +516,7 @@ index 9a2c5bc5..f3bdc3d5 100644
|
||||
+ return tree;
|
||||
+}
|
||||
diff --git a/packages/tasks/src/sync.mjs b/packages/tasks/src/sync.mjs
|
||||
index 811fb683..118f348e 100644
|
||||
index 811fb683..0afd1c49 100644
|
||||
--- a/packages/tasks/src/sync.mjs
|
||||
+++ b/packages/tasks/src/sync.mjs
|
||||
@@ -1,10 +1,12 @@
|
||||
@@ -614,7 +626,16 @@ index 811fb683..118f348e 100644
|
||||
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) {
|
||||
@@ -147,39 +165,58 @@ 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 left the tree after the last reconcile: no longer shared, moved
|
||||
+// under a project the bot can't read, or deleted. The tick drops it from ctx.tree, and missing()
|
||||
+// reads its open tasks one by one, as after a reconcile. The root still refuses.
|
||||
+const leftTree = (ctx, project, e) =>
|
||||
+ project !== ctx.project && e instanceof BusError && (e.code === 'tracker-forbidden' || e.code === 'tracker-not-found');
|
||||
export async function tick(ctx) {
|
||||
const startMs = ctx.clock();
|
||||
const readAt = iso(startMs);
|
||||
const sinceMs = Math.floor(((ctx.lastStart ?? startMs) - WINDOW_MS) / 1000) * 1000;
|
||||
@@ -622,9 +643,14 @@ index 811fb683..118f348e 100644
|
||||
- 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 });
|
||||
+ 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 (!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: [] };
|
||||
@@ -682,7 +708,7 @@ index 811fb683..118f348e 100644
|
||||
await missing(ctx, current, gone, out);
|
||||
record(ctx, out);
|
||||
ctx.lastStart = startMs;
|
||||
@@ -187,27 +214,32 @@ export async function tick(ctx) {
|
||||
@@ -187,27 +224,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.
|
||||
@@ -775,10 +801,10 @@ index 3859dd22..c16f1f44 100644
|
||||
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
|
||||
index 00000000..f03de85b
|
||||
--- /dev/null
|
||||
+++ b/packages/tasks/tests/tree.test.mjs
|
||||
@@ -0,0 +1,276 @@
|
||||
@@ -0,0 +1,424 @@
|
||||
+import test from 'node:test';
|
||||
+import assert from 'node:assert/strict';
|
||||
+import { readFileSync } from 'node:fs';
|
||||
@@ -966,7 +992,9 @@ index 00000000..9410779b
|
||||
+ 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);
|
||||
+ // 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 });
|
||||
@@ -983,13 +1011,20 @@ index 00000000..9410779b
|
||||
+ 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]));
|
||||
+ 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),
|
||||
+ refs.map((r) => [r, { reason: 'no-access' }]),
|
||||
+ [hidden, archived, reparented].map((r) => [r, { reason: 'no-access' }]),
|
||||
+ );
|
||||
+ for (const r of refs) {
|
||||
+ const s = w.snapshots(r);
|
||||
@@ -1005,6 +1040,145 @@ index 00000000..9410779b
|
||||
+ 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');
|
||||
|
||||
Reference in New Issue
Block a user