B1 truncates due dates to the second; B2 records what landed when the
final read fails; tests for V9, V21 and S5. Candidate manifest
e10e30e3, 28 files over c9ef7ee4. Mutants 46/63. Live provider cases
in test-release and test-task failed on a zai usage limit (429).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
336 lines
16 KiB
Diff
336 lines
16 KiB
Diff
diff -ruN r1tree/packages/tasks/README.md gate2/packages/tasks/README.md
|
|
--- r1tree/packages/tasks/README.md 2026-10-08 19:13:37.288575219 -0500
|
|
+++ gate2/packages/tasks/README.md 2026-10-08 19:13:37.275629637 -0500
|
|
@@ -122,6 +122,24 @@
|
|
create can't be checked that way, so an uncertain create is reported as
|
|
`write-uncertain` and a person checks the board.
|
|
|
|
+On success the self snapshot holds the fields the verb set, not what the
|
|
+final read saw. If a person edits the task between the last write and
|
|
+that read, the next poll still records the edit as theirs.
|
|
+
|
|
+The final read can fail after the writes landed. Its refusal would hide
|
|
+them, so it isn't passed on:
|
|
+
|
|
+- Every write landed: the verb succeeds and records its event and a
|
|
+ snapshot of the fields it set. The snapshot's `updated` is the
|
|
+ compare-read's, since nothing newer was read.
|
|
+- Some landed and a later one failed: `write-uncertain`, and nothing is
|
|
+ recorded. The poll records what the tracker holds.
|
|
+- None landed: the failed write's own code.
|
|
+- A create: the create's answer, plus the labels it wrote, in the
|
|
+ default bucket. `task.created` is recorded and the verb succeeds. A
|
|
+ refusal here would read as "nothing happened" and invite a second
|
|
+ create.
|
|
+
|
|
| Verb | Who | Arguments | Event |
|
|
|---|---|---|---|
|
|
| `task.create` | pm | `title`, `request`, `requirement`; optional `description`, `due_date`, `priority`, `labels`, `relations` | `task.created` |
|
|
@@ -137,6 +155,13 @@
|
|
`vikunja:<project>/<task>`, and a ref in another project refuses with
|
|
`task-project`.
|
|
|
|
+Due dates have one-second precision. Vikunja v2.7.0 stores `due_date`
|
|
+to the second, though a write's answer echoes the milliseconds it was
|
|
+sent. The adapter drops the milliseconds before it writes, so
|
|
+`2026-12-01T09:00:00.456Z` is stored, recorded and digested as
|
|
+`2026-12-01T09:00:00.000Z`. Without that, the poll would read the bot's
|
|
+own write back as a person's edit.
|
|
+
|
|
Details that aren't obvious from the table:
|
|
|
|
- `task.create` needs `request`, the id of a `human.input` event of this
|
|
diff -ruN r1tree/packages/tasks/src/fake.mjs gate2/packages/tasks/src/fake.mjs
|
|
--- r1tree/packages/tasks/src/fake.mjs 2026-10-08 19:13:37.288781975 -0500
|
|
+++ gate2/packages/tasks/src/fake.mjs 2026-10-08 19:13:37.282873539 -0500
|
|
@@ -49,6 +49,11 @@
|
|
const bad = (detail, code) => ({ title: 'Bad Request', status: 400, detail, ...(code ? { code } : {}) });
|
|
const unprocessable = (detail) => ({ title: 'Unprocessable Entity', status: 422, detail });
|
|
const second = (ms) => new Date(Math.floor(ms / 1000) * 1000).toISOString().replace('.000Z', 'Z');
|
|
+// Vikunja v2.7.0 keeps a due date to the second; a write's answer echoes what was sent, in Go's form
|
|
+// (review r1, r1-vk-due-probe.txt). The probe can't tell floor from rounding; the adapter sends
|
|
+// whole seconds, so it doesn't matter to it.
|
|
+const dueIn = (x) => (x && x !== NULL_DATE ? second(Date.parse(x)) : null);
|
|
+const dueEcho = (x) => (x && x !== NULL_DATE ? new Date(x).toISOString().replace(/\.?0+Z$/, 'Z') : NULL_DATE);
|
|
const fine = (ms) => new Date(ms).toISOString();
|
|
const page = (list, q) => {
|
|
const per = Math.min(Math.max(Number(q.get('per_page')) || 50, 1), 50),
|
|
@@ -480,7 +485,7 @@
|
|
description: body.description ?? '',
|
|
done: false,
|
|
doneAt: null,
|
|
- due: body.due_date && body.due_date !== NULL_DATE ? body.due_date : null,
|
|
+ due: dueIn(body.due_date),
|
|
priority: body.priority ?? 0,
|
|
percent: body.percent_done ?? 0,
|
|
labels: [],
|
|
@@ -497,6 +502,7 @@
|
|
this.#tasks.set(t.id, t);
|
|
const out = this.#taskJson(t, { empty: [] });
|
|
out.related_tasks = null;
|
|
+ if ('due_date' in body) out.due_date = dueEcho(body.due_date);
|
|
out.created = fine(now);
|
|
out.updated = fine(now);
|
|
return { status: 201, body: out };
|
|
@@ -516,7 +522,7 @@
|
|
before = JSON.stringify(this.#taskJson(t));
|
|
if ('title' in body) t.title = body.title;
|
|
if ('description' in body) t.description = body.description;
|
|
- if ('due_date' in body) t.due = body.due_date && body.due_date !== NULL_DATE ? body.due_date : null;
|
|
+ if ('due_date' in body) t.due = dueIn(body.due_date);
|
|
if ('priority' in body) t.priority = body.priority;
|
|
if ('percent_done' in body) t.percent = body.percent_done;
|
|
if ('done' in body && body.done !== t.done) {
|
|
@@ -529,7 +535,9 @@
|
|
// bucket_id is echoed and not applied (probes.md, "Writes and moves").
|
|
if (JSON.stringify(this.#taskJson(t)) === before && !('bucket_id' in body)) return { status: 304 };
|
|
if (JSON.stringify(this.#taskJson(t)) !== before) this.#touch(t);
|
|
- return { status: 200, body: this.#taskJson(t, { empty: [], bucket: body.bucket_id ?? 0 }) };
|
|
+ const out = this.#taskJson(t, { empty: [], bucket: body.bucket_id ?? 0 });
|
|
+ if ('due_date' in body) out.due_date = dueEcho(body.due_date);
|
|
+ return { status: 200, body: out };
|
|
}
|
|
_remove(user, [id]) {
|
|
const x = this.#taskFor(user, id, true);
|
|
diff -ruN r1tree/packages/tasks/src/verbs.mjs gate2/packages/tasks/src/verbs.mjs
|
|
--- r1tree/packages/tasks/src/verbs.mjs 2026-10-08 19:13:37.288829710 -0500
|
|
+++ gate2/packages/tasks/src/verbs.mjs 2026-10-08 19:13:37.282945050 -0500
|
|
@@ -39,9 +39,11 @@
|
|
/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(x) &&
|
|
Number.isFinite(Date.parse(x)) &&
|
|
new Date(x).toISOString() === x;
|
|
+// Vikunja keeps due dates to the second (review r1, B1), so the milliseconds are dropped before the
|
|
+// write. The snapshot, the event and an uncertain write's re-read then all hold what Vikunja holds.
|
|
const due = (x) => {
|
|
if (x !== null && !canonical(x)) refuse('invalid-request');
|
|
- return x;
|
|
+ return x === null ? null : `${x.slice(0, 19)}.000Z`;
|
|
};
|
|
const priority = (x) => {
|
|
if (!Number.isSafeInteger(x) || x < 0 || x > 5) refuse('invalid-request');
|
|
@@ -133,29 +135,36 @@
|
|
const work = plan.check(before, id);
|
|
ctx.broker.authorize(cap, verb, { decision: plan.decision, target: args.task_ref });
|
|
let failure = null;
|
|
+ let landed = 0;
|
|
try {
|
|
- for (const [method, path, body, done] of work.steps) await step(role, method, path, body, id, done);
|
|
+ for (const [method, path, body, done] of work.steps) {
|
|
+ await step(role, method, path, body, id, done);
|
|
+ landed++;
|
|
+ }
|
|
} catch (e) {
|
|
failure = e instanceof BusError ? e : new BusError('adapter-failed');
|
|
}
|
|
- let after;
|
|
+ let after = null;
|
|
try {
|
|
after = await read(id);
|
|
- } catch (e) {
|
|
- if (failure) throw failure;
|
|
- throw e instanceof BusError ? e : new BusError('adapter-failed');
|
|
+ } catch {
|
|
+ // handled below: a failed final read must not hide a write that landed (review r1, B2)
|
|
}
|
|
- // Expected fields on success, so a concurrent edit still shows as external on the next poll.
|
|
- // On a failure the snapshot is what the tracker holds now.
|
|
+ // The final read failed after some writes and not others: what the tracker holds isn't known.
|
|
+ if (!after && failure) throw landed ? new BusError('write-uncertain') : failure;
|
|
+ // Expected fields on success, so a concurrent edit still shows as external on the next poll. When
|
|
+ // every write landed but the final read failed, that is still what the writes set, and `updated`
|
|
+ // is the compare-read's. On a failure the snapshot is what the tracker holds now.
|
|
const fields = failure ? after.fields : work.expect(before.fields);
|
|
+ const seen = after ?? before;
|
|
ctx.broker.recordTask({
|
|
cap,
|
|
business: ctx.business,
|
|
- snapshots: [{ task_ref: args.task_ref, updated: after.updated, etag: after.etag ?? null, digest: digest(fields), fields }],
|
|
+ snapshots: [{ task_ref: args.task_ref, updated: seen.updated, etag: after?.etag ?? null, digest: digest(fields), fields }],
|
|
events: failure || !work.event ? [] : [{ ...work.event, subject: args.task_ref }],
|
|
});
|
|
if (failure) throw failure;
|
|
- return { task_ref: args.task_ref, digest: digest(fields), updated: after.updated };
|
|
+ return { task_ref: args.task_ref, digest: digest(fields), updated: seen.updated };
|
|
};
|
|
function prepare(verb, args, role, mine) {
|
|
const common = ['task_ref', 'decision', 'expect'];
|
|
@@ -362,8 +371,14 @@
|
|
let after = null;
|
|
try {
|
|
after = await read(id);
|
|
- } catch (e) {
|
|
- failure ??= e instanceof BusError ? e : new BusError('adapter-failed');
|
|
+ } catch {
|
|
+ // handled below, as in handle()
|
|
+ }
|
|
+ // Every write landed but the final read failed: the create's answer plus the labels written, in
|
|
+ // the default bucket. A refusal here would read as "nothing happened" and invite a second create.
|
|
+ if (!after && !failure) {
|
|
+ const fields = { ...taskFields(r.json, ctx.view.ids.todo), labels: sorted(plan.labels) };
|
|
+ after = { fields, digest: digest(fields), etag: null, updated: normal(r.json.updated) };
|
|
}
|
|
// task.created is recorded even when a later label or relation write failed: the task exists.
|
|
ctx.broker.recordTask({
|
|
diff -ruN r1tree/packages/tasks/tests/sync.test.mjs gate2/packages/tasks/tests/sync.test.mjs
|
|
--- r1tree/packages/tasks/tests/sync.test.mjs 2026-10-08 19:13:37.288953627 -0500
|
|
+++ gate2/packages/tasks/tests/sync.test.mjs 2026-10-08 19:13:37.283110988 -0500
|
|
@@ -196,3 +196,17 @@
|
|
(e) => e.code !== undefined && !e.message.includes(w.tokens.coder),
|
|
);
|
|
});
|
|
+// The first look at a task counts only comments inside the window, so a restart doesn't replay
|
|
+// every comment ever made as new.
|
|
+test('the first look at a task counts only comments inside the window', async (t) => {
|
|
+ let shift = -2 * 3600000;
|
|
+ const w = await ready(t, { fake: { now: () => Date.now() + shift } });
|
|
+ const { task_ref } = await create(w);
|
|
+ await w.ui('POST', '/tasks/1/comments', { comment: 'two hours ago' });
|
|
+ shift = 0;
|
|
+ await w.ui('POST', '/tasks/1/comments', { comment: 'just now' });
|
|
+ await sleep(20);
|
|
+ await w.adapter.tick('demo');
|
|
+ const [e] = external(w, task_ref);
|
|
+ assert.equal(e.body.comments, 1);
|
|
+});
|
|
diff -ruN r1tree/packages/tasks/tests/verbs.test.mjs gate2/packages/tasks/tests/verbs.test.mjs
|
|
--- r1tree/packages/tasks/tests/verbs.test.mjs 2026-10-08 19:13:37.288964639 -0500
|
|
+++ gate2/packages/tasks/tests/verbs.test.mjs 2026-10-08 19:13:37.283122508 -0500
|
|
@@ -1,6 +1,7 @@
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { world, sleep } from './world.mjs';
|
|
+import { FakeVikunja } from '../src/fake.mjs';
|
|
const ready = async (t, edit) => {
|
|
const w = await world(t, edit);
|
|
await w.adapter.start('demo');
|
|
@@ -280,3 +281,129 @@
|
|
assert.deepEqual(w.fake.task(1).labels, [w.label]);
|
|
assert.deepEqual(w.fake.task(1).assignees, [w.bots.coder]);
|
|
});
|
|
+// Vikunja keeps due dates to the second (review r1, B1). The adapter drops the milliseconds before it
|
|
+// writes, so its own snapshot, the digest it returns and the next poll all agree.
|
|
+test('a due date with milliseconds is written to the second', async (t) => {
|
|
+ const w = await ready(t);
|
|
+ const pm = w.agent('pm');
|
|
+ const a = await task(w, { due_date: '2026-11-01T00:00:00.789Z' });
|
|
+ assert.equal(w.fake.task(1).due, '2026-11-01T00:00:00Z');
|
|
+ assert.equal(w.snapshots(a.task_ref).at(-1).fields.due_date, '2026-11-01T00:00:00.000Z');
|
|
+ const r = await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-01T09:00:00.456Z' });
|
|
+ assert.equal(w.fake.task(1).due, '2026-12-01T09:00:00Z');
|
|
+ const s = w.snapshots(a.task_ref).at(-1);
|
|
+ assert.equal(s.fields.due_date, '2026-12-01T09:00:00.000Z');
|
|
+ assert.equal(s.digest, r.digest);
|
|
+ await sleep(20);
|
|
+ assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 });
|
|
+ const patches = () => w.fake.requests.filter((x) => x.method === 'PATCH').length;
|
|
+ const n = patches();
|
|
+ await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-01T09:00:00.456Z' });
|
|
+ assert.equal(patches(), n);
|
|
+ // The digest handed back is the one the next verb has to name.
|
|
+ await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-02T09:00:00.456Z', expect: r.digest });
|
|
+ // A landed PATCH whose answer was lost settles on the re-read.
|
|
+ w.fake.fault('PATCH', /^\/tasks\/1$/, 500, { apply: true });
|
|
+ await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-03T09:00:00.456Z' });
|
|
+ assert.equal(w.fake.task(1).due, '2026-12-03T09:00:00Z');
|
|
+ await sleep(20);
|
|
+ assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 });
|
|
+});
|
|
+// Arms a fault on the final read: after `trigger` answers, the next GET of task 1 fails.
|
|
+const thenFailRead = (trigger) => {
|
|
+ const fake = new FakeVikunja();
|
|
+ let armed = false;
|
|
+ const fetch = async (url, init = {}) => {
|
|
+ const res = await fake.fetch(url, init);
|
|
+ if (armed && trigger(init.method ?? 'GET', new URL(url).pathname)) {
|
|
+ armed = false;
|
|
+ fake.fault('GET', /^\/tasks\/1$/, 0);
|
|
+ }
|
|
+ return res;
|
|
+ };
|
|
+ return { edit: { fake, fetch }, arm: () => (armed = true) };
|
|
+};
|
|
+test('every write landed and the final read failed: the verb succeeds and records what it wrote', async (t) => {
|
|
+ const f = thenFailRead((m, p) => m === 'POST' && p.endsWith('/tasks/1/assignees'));
|
|
+ const w = await ready(t, f.edit);
|
|
+ const pm = w.agent('pm');
|
|
+ const { task_ref } = await task(w);
|
|
+ f.arm();
|
|
+ const r = await w.call(pm, 'task.assign', { task_ref, role: 'coder' });
|
|
+ assert.deepEqual(w.fake.task(1).assignees, [w.bots.coder]);
|
|
+ assert.equal(w.events('task.assigned').length, 1);
|
|
+ const s = w.snapshots(task_ref).at(-1);
|
|
+ assert.equal(s.source, 'self');
|
|
+ assert.deepEqual([...s.fields.assignees], [w.bots.coder]);
|
|
+ assert.equal(s.digest, r.digest);
|
|
+ await sleep(20);
|
|
+ await w.adapter.tick('demo');
|
|
+ assert.equal(w.events('task.changed.external').length, 0);
|
|
+ await assert.rejects(w.call(pm, 'task.assign', { task_ref, role: 'coder' }), refusedWith('task-assigned'));
|
|
+});
|
|
+test('some writes landed and the final read failed: write-uncertain, and nothing is recorded', async (t) => {
|
|
+ const f = thenFailRead((m, p) => m === 'POST' && p.endsWith('/tasks/1/labels'));
|
|
+ const w = await ready(t, f.edit);
|
|
+ const pm = w.agent('pm');
|
|
+ const { task_ref } = await task(w);
|
|
+ const n = w.snapshots(task_ref).length;
|
|
+ w.fake.fault('POST', /^\/tasks\/1\/labels$/, 403);
|
|
+ f.arm();
|
|
+ await assert.rejects(
|
|
+ w.call(pm, 'task.schedule', { task_ref, due_date: '2026-12-01T09:00:00.000Z', labels: { add: [w.label] } }),
|
|
+ refusedWith('write-uncertain'),
|
|
+ );
|
|
+ assert.equal(w.fake.task(1).due, '2026-12-01T09:00:00Z');
|
|
+ assert.equal(w.snapshots(task_ref).length, n);
|
|
+});
|
|
+test('a create whose final read fails succeeds and records task.created', async (t) => {
|
|
+ const f = thenFailRead((m, p) => m === 'POST' && /\/projects\/\d+\/tasks$/.test(p));
|
|
+ const w = await ready(t, f.edit);
|
|
+ w.agent('pm');
|
|
+ f.arm();
|
|
+ const r = await task(w, { due_date: '2026-11-01T00:00:00.000Z' });
|
|
+ assert.equal(w.events('task.created').length, 1);
|
|
+ const [s] = w.snapshots(r.task_ref);
|
|
+ assert.equal(s.source, 'self');
|
|
+ assert.equal(s.digest, r.digest);
|
|
+ await sleep(20);
|
|
+ await w.adapter.tick('demo');
|
|
+ assert.equal(w.events('task.changed.external').length, 0);
|
|
+});
|
|
+// The success snapshot holds what the verb wrote, not what the final read saw, so an edit that lands
|
|
+// between the last write and that read is still a person's edit on the next poll.
|
|
+test('an edit between the last write and the final read shows as external on the next poll', async (t) => {
|
|
+ const fake = new FakeVikunja();
|
|
+ let w,
|
|
+ armed = false;
|
|
+ w = await ready(t, {
|
|
+ fake,
|
|
+ fetch: async (url, init = {}) => {
|
|
+ const res = await fake.fetch(url, init);
|
|
+ if (armed && init.method === 'POST' && new URL(url).pathname.endsWith('/tasks/1/assignees')) {
|
|
+ armed = false;
|
|
+ await w.ui('PATCH', '/tasks/1', { title: 'renamed in the ui' });
|
|
+ }
|
|
+ return res;
|
|
+ },
|
|
+ });
|
|
+ const pm = w.agent('pm');
|
|
+ const { task_ref } = await task(w);
|
|
+ armed = true;
|
|
+ await w.call(pm, 'task.assign', { task_ref, role: 'coder' });
|
|
+ assert.equal(w.snapshots(task_ref).at(-1).fields.title, 'a task');
|
|
+ await sleep(20);
|
|
+ await w.adapter.tick('demo');
|
|
+ const ext = w.events('task.changed.external');
|
|
+ assert.equal(ext.length, 1);
|
|
+ assert.deepEqual(ext[0].body.changed, ['title']);
|
|
+});
|
|
+test('task.created is recorded when a later label write fails', async (t) => {
|
|
+ const w = await ready(t);
|
|
+ w.agent('pm');
|
|
+ w.fake.fault('POST', /^\/tasks\/\d+\/labels$/, 500);
|
|
+ await assert.rejects(task(w, { labels: [w.label] }), refusedWith('write-uncertain'));
|
|
+ assert.notEqual(w.fake.task(1), undefined);
|
|
+ assert.deepEqual(w.fake.task(1).labels, []);
|
|
+ assert.equal(w.events('task.created').length, 1);
|
|
+});
|