diff --git a/agents/darkwing/work/queue-63/r2/build.md b/agents/darkwing/work/queue-63/r2/build.md new file mode 100644 index 000000000..6abd07dec --- /dev/null +++ b/agents/darkwing/work/queue-63/r2/build.md @@ -0,0 +1,107 @@ +# Row 63, task verbs across the tree (row B), round 2 build (Darkwing) + +Issue #1556. Round 1 packet: `agents/darkwing/work/queue-63/` (candidate +manifest `ec28d736…`). Round 1 verdicts: Filbert approved with notes +(comment 27846), Dewey asked for changes (comment 27847). Sage's word for +round 2: add Dewey's five proof tests and Filbert's N1 child-path tests, +code stays as is, both build.md choices stand. + +Base `cca5f23e`. Round 1 was built on `08941d8b`. Row 89 landed at +`ce104b27` after that and edits `packages/harness/src/tools.mjs` and +`packages/harness/tests/tools.test.mjs`, two of the six paths. It adds +`list_tasks` arguments and a `get_task` tool; none of them is named +`project`, so round 1's assertion that only `task_create` takes `project` +still holds. I applied the full diff to a detached worktree of `cca5f23e` +(it applied cleanly) and ran the mutants and the gate there. My added and +removed lines in both harness files are the same as round 1's; only the +context around them moved. No other commit since `08941d8b` touches the +six paths or `packages/tasks/tests/fake.mjs`. + +`build.patch` is `git diff --cached --binary cca5f23e` of the six paths. +Apply it with `git apply --index` to a detached worktree of `cca5f23e`, and +`sha256sum -c candidate-manifest.sha256` then gives 6/6 OK (checked on a +fresh worktree). + +## What changed since round 1 + +`verbs.mjs` and `tree.test.mjs` are byte for byte round 1's. In +`tools.mjs` and `tools.test.mjs` my lines are round 1's, on row 89's base. +Two files moved: + +- `packages/tasks/tests/verbs.test.mjs`: four tests, and two helpers + gain a parameter. `thenFailRead(trigger, id = 1)` faults the final read + of task `id`, not always task 1. `tree(t, edit = {})` passes `edit` to + `world()` with `TREE`, so a tree world can take `thenFailRead`'s fetch. +- `packages/tasks/README.md`: the `task.create` paragraph is rewrapped. + Its line 194 was 118 characters (Dewey's non-blocking note). The words + are the same. + +## The four tests + +Each is a child version of a root path the round 1 suite already covered. + +| Test | What it pins | From | +|---|---|---| +| a verb and a create in the child record the final read of the child's board | the final read after a create and after a verb on a child task has an etag, so it read the child's project and view and didn't fall back | Dewey's test 1 | +| a create in the child whose final read fails records the child's todo bucket | the fallback snapshot's bucket is the child's `todo`, its etag is null, and the next tick finds no external change on the new task | Dewey's test 2 and Filbert's Q2 | +| a lost answer in the child is settled by a re-read of the child's board | a state move and a close that each landed but answered 500 succeed, with the child's `in-progress` and then `done` bucket in the snapshot, and nothing is retried | Dewey's test 3 and Filbert's Q1 | +| every related task is read before authority, not only the first | a schedule whose second relation names the wrong project refuses `task-moved` and writes nothing | Dewey's test 4 | + +Two of Dewey's and Filbert's tests asked for the same thing, so I merged +each pair into one test. Sage's message said five proof tests. +`proof-tests.mjs` holds four, which kill Dewey's five survivors. The +four here kill those five and Filbert's four bucket survivors, two of +which are the same sites as Dewey's. + +One change from the proof tests: `tree()` makes a task in the +grandchild before the start, and the start's reconcile records it as +`task.changed.external`. Filbert's Q2 expected no external change after +the tick, so the test asserts the only one is that grandchild task's, +not the count. Dewey's `treeWith` helper isn't needed: `tree(t, edit)` +does the same with the grandchild in place. + +## Mutants + +`mutants.sh` here is round 1's 18, then 7 more: one for each survivor in +Dewey's and Filbert's round 1 runs, each sending a child path back to the +root's project or view, or cutting the relation read to the first +relation. It ran with a copy of +`agents/darkwing/work/queue-59-review/r1/mut.mjs` (sha256 `a1ed6bca…`, +round 1's helper) on the rebased tree, 22:35:31Z to 22:37:06Z, after a +check that exits on another seat's gate or mutant run. **25 of 25 +killed** (`mutants.txt`). Every file was restored and still matches the +manifest. + +Each of the 7 dies in the test written for it, and in no other: + +| Mutant | Killed by | +|---|---| +| `located-first-only` | every related task is read before authority, not only the first | +| `step-reread-root` | a lost answer in the child is settled by a re-read of the child's board | +| `handle-final-root` | a verb and a create in the child record the final read of the child's board | +| `create-final-root` | a verb and a create in the child record the final read of the child's board | +| `create-fallback-root-todo` | a create in the child whose final read fails records the child's todo bucket | +| `read-done-root` | a lost answer in the child is settled by a re-read of the child's board | +| `close-done-root` | a lost answer in the child is settled by a re-read of the child's board | + +Round 1's 18 are still killed, some now by more tests than before. + +## Gate + +`gate.sh`, run sequentially in `~/darkwing-scratch/r63/wt2` (`cca5f23e` +plus this patch, `node_modules` symlinked), 22:37:42Z to 22:40:57Z, after +the same busy check (`gate-summary.txt`). All 15 suites exited 0, 0 +skipped: + +- node: tasks 84 (round 1's 80 plus the four tests), harness 65, business + 60, bus 108, cli 120, webui 86. Round 1 ran tasks, harness, business and + bus. I added cli and webui because both import from `packages/tasks`. + Harness and bus counts are row 89's and the rows before it; the harness + count doesn't move for this row, since its assertions sit inside an + existing test; +- scripts: auth 15, conductor 17, config 24, discord 66, + extension-package 18, foundation 44, queue 27, release 14, task 26 + (`DOCKER_HOST` at a dead socket). + +test-release left an empty `wt2_default` network of mine, and I removed +it. diff --git a/agents/darkwing/work/queue-63/r2/build.patch b/agents/darkwing/work/queue-63/r2/build.patch new file mode 100644 index 000000000..5b9e874a4 --- /dev/null +++ b/agents/darkwing/work/queue-63/r2/build.patch @@ -0,0 +1,677 @@ +diff --git a/packages/harness/src/tools.mjs b/packages/harness/src/tools.mjs +index f0011d30..eed0818a 100644 +--- a/packages/harness/src/tools.mjs ++++ b/packages/harness/src/tools.mjs +@@ -38,6 +38,7 @@ const PRIORITY = { type: "integer", minimum: 0, maximum: 5, description: "Priori + const TASK_TOOLS = { + "task.create": ["task_create", "Create a tracker task. Every task cites the request it came from and a requirement id.", { + title: str("Task title, one line"), ++ project: { type: "integer", minimum: 1, description: "Tracker project id, an installed project in the business's tree; the root project when left out" }, + description: str("Task description"), + due_date: DUE, + priority: PRIORITY, +diff --git a/packages/harness/tests/tools.test.mjs b/packages/harness/tests/tools.test.mjs +index 045f11c1..f6fca279 100644 +--- a/packages/harness/tests/tools.test.mjs ++++ b/packages/harness/tests/tools.test.mjs +@@ -19,6 +19,11 @@ test("the PM gets launch, its task verbs and the reads", (t) => { + assert.equal(launch.verb, "launch"); + assert.equal(launch.action, "role.launch"); + assert.deepEqual(launch.parameters.properties.instance.enum, ["coder", "reviewer"]); ++ // Row B: the project is optional, a positive integer, and only task_create takes it. ++ const create = tools.find((x) => x.name === "task_create"); ++ assert.deepEqual({ ...create.parameters.properties.project, description: undefined }, { type: "integer", minimum: 1, description: undefined }); ++ assert.deepEqual(create.parameters.required, ["title", "request", "requirement"]); ++ assert.deepEqual(tools.filter((x) => x.parameters.properties.project).map((x) => x.name), ["task_create"]); + for (const tool of tools) { + assert.equal(tool.parameters.type, "object"); + assert.equal(tool.parameters.additionalProperties, false); +diff --git a/packages/tasks/README.md b/packages/tasks/README.md +index ff675343..5b658bc2 100644 +--- a/packages/tasks/README.md ++++ b/packages/tasks/README.md +@@ -114,10 +114,14 @@ All eight go through the same steps: + and it differs, a `task.conflict` event and `task-conflict`. + 4. A done task refuses every verb with `task-done`. Reopening is a + person's act. +-5. `broker.authorize` with the decision id and the task ref. +-6. The writes, each with the acting role's own token. Only the writes ++5. Each task a relation names, in `task.create` or `task.schedule`'s ++ `add` and `remove`, is read once with the sync token. It must exist ++ (`task-not-found` otherwise) and sit in the project its ref names ++ (`task-moved` otherwise). ++6. `broker.authorize` with the decision id and the task ref. ++7. The writes, each with the acting role's own token. Only the writes + that change something are sent. +-7. A final read, then one self snapshot and its event. ++8. A final read, then one self snapshot and its event. + + `fields.json` holds the slice 1 field table as data (row 61, lead + decision 113): the writer each verb checks in step 1 or in +@@ -125,7 +129,7 @@ decision 113): the writer each verb checks in step 1 or in + fields each verb writes with their Vikunja calls. `loadFields()` checks + its shape and refuses any other. Whether a write also needs a resolved + decision isn't in it. That comes from the role's authority in +-`roles/*.json`, which `authorize` reads in step 5. ++`roles/*.json`, which `authorize` reads in step 6. + `tests/fields.test.mjs` walks every verb against the fake and checks + both. + +@@ -155,7 +159,7 @@ them, so it isn't passed on: + + | Verb | Who | Arguments | Event | + |---|---|---|---| +-| `task.create` | pm | `title`, `request`, `requirement`; optional `description`, `due_date`, `priority`, `labels`, `relations` | `task.created` | ++| `task.create` | pm | `title`, `request`, `requirement`; optional `project`, `description`, `due_date`, `priority`, `labels`, `relations` | `task.created` | + | `task.assign` | pm | `task_ref`, `role` | `task.assigned` | + | `task.reassign` | pm | `task_ref`, `role` | `task.assigned` | + | `task.schedule` | pm | `task_ref`; one or more of `due_date`, `labels.{add,remove}`, `relations.{add,remove}` | snapshot only | +@@ -165,8 +169,12 @@ them, so it isn't passed on: + | `task.close` | pm | `task_ref`, `verdict` | `task.closed` | + + Every verb also takes `decision` and `expect`. A task ref is +-`vikunja:/`, and a ref in another project refuses with +-`task-project`. ++`vikunja:/`. A verb acts in the ref's project, on that ++project's own board. The project must be installed: the root, or with ++`tracker.descendants` on, an installed child (see Project trees). A ref ++to a project outside the tree refuses with `task-project`, and one to a ++watch-only child with `project-not-installed`. The same holds for the ++task a relation names. + + 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 +@@ -179,9 +187,14 @@ Details that aren't obvious from the table: + + - `task.create` needs `request`, the id of a `human.input` event of this + business (`request-not-found` otherwise), and a `requirement` like +- `REQ-TASK-3`. `task.created` is recorded even when a label or relation +- write after the create fails. The task exists, and the verb then +- refuses with that write's code. ++ `REQ-TASK-3`. `project` is the tracker project id to create the task ++ in, the root when left out. Any other project than an installed one of ++ the tree refuses with `project-not-installed`, and a value that isn't a ++ positive integer with `invalid-request`. The decision a create may ++ need is bound to no target, so it doesn't name the project. ++ `task.created` is recorded even when a label or relation write after ++ the create fails. The task exists, and the verb then refuses with that ++ write's code. + - `task.assign` refuses a task a bot already holds (`task-assigned`). + `task.reassign` refuses one no bot holds (`task-unassigned`). Both leave + people's assignments alone, and remove only bots the compare-read +@@ -291,9 +304,13 @@ checks at discovery: + A task in a bucket outside the five, like a `Backlog` someone added, + keeps that bucket's raw id in its snapshot. + +-Refs stay `vikunja:/`. Verbs act on the root only. A verb +-on a child's task refuses with `task-project`, as it does for any other +-project. ++Refs stay `vikunja:/`. Verbs act in every installed ++project of the tree, on that project's own board: `task.update.assigned` ++moves a child's task between the child's buckets, and `task.close` puts ++it in the child's `done` (row 63, #1556). A verb on a watch-only child's ++task refuses with `project-not-installed`, because the adapter has no ++board there it trusts. With `descendants` off the tree is the root ++alone. + + 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 +@@ -357,6 +374,7 @@ on a live read with the sync key, and a switch is a follow-up row. + | `write-uncertain` | 5xx or no answer on a write, not settled by a re-read | + | `tracker-shape`, `tracker-paging`, `tracker-unexpected` | Vikunja sent something the probes didn't show | + | `task-moved`, `task-placement`, `task-project` | the task is in another project, or on no bucket | ++| `project-not-installed` | the project is watch-only, or not an installed project of the tree | + | `task-conflict`, `task-done`, `task-assigned`, `task-unassigned`, `not-assigned` | the task's state doesn't allow the verb | + | `field-writer`, `label-not-allowed`, `unknown-role`, `invalid-state`, `request-not-found`, `invalid-request` | the call itself is wrong | + +diff --git a/packages/tasks/src/verbs.mjs b/packages/tasks/src/verbs.mjs +index 9bf38511..50efa0e7 100644 +--- a/packages/tasks/src/verbs.mjs ++++ b/packages/tasks/src/verbs.mjs +@@ -6,6 +6,8 @@ import { board, normal } from './sync.mjs'; + // the sync token, the caller's expected digest, broker authority, the writes with the acting role's + // own token, a final read, then one self snapshot and its event. A write that answers 5xx or not at + // all is checked by a re-read and never retried (bus README rule). ++// Row B: a verb acts in any installed project of the tree (ctx.tree, the root alone with ++// descendants off), on that project's own board. + const refuse = (code) => { + throw new BusError(code); + }; +@@ -67,28 +69,48 @@ export function verbs(ctx) { + if (!ctx.labels.has(id)) fail('label-not-allowed'); + return id; + }; ++ // A project outside the tree isn't the business's: task-project. A watch-only one has no board the ++ // adapter trusts, so nothing writes to it: project-not-installed. ++ const installed = (project) => { ++ if (!ctx.tree.has(project)) fail('task-project'); ++ const view = ctx.tree.get(project); ++ if (!view) fail('project-not-installed'); ++ return view; ++ }; + const relation = (x) => { + keys(x, ['kind', 'task_ref'], ['kind', 'task_ref']); + if (!RELATIONS.includes(x.kind)) fail('invalid-request'); + const other = parseRef(x.task_ref); +- if (other.project !== ctx.project) fail('task-project'); +- return { kind: x.kind, other: other.task }; ++ installed(other.project); ++ return { kind: x.kind, other: other.task, project: other.project }; + }; ++ // Where a verb acts: the task's project, its board and the task id. + const target = (taskRef) => { + const r = parseRef(taskRef); +- if (r.project !== ctx.project) fail('task-project'); +- return r.task; ++ return { project: r.project, view: installed(r.project), id: r.task }; + }; ++ // The ref's project is only a claim until a read shows it, so each related task is read once ++ // with the sync token before anything is authorized. ++ async function located(list) { ++ for (const r of list) { ++ const x = await ctx.sync('GET', `/tasks/${r.other}`); ++ if (outcome(x) !== 'ok') fail(refusal(x)); ++ checkTask(x.json); ++ if (x.json.id !== r.other) fail('tracker-shape'); ++ if (x.json.project_id !== r.project) fail('task-moved'); ++ } ++ } + // The live state of one task: the task read plus its board bucket, both with the sync token. +- async function read(id) { ++ async function read(at) { ++ const id = at.id; + const x = await ctx.sync('GET', `/tasks/${id}`); + if (outcome(x) !== 'ok') fail(refusal(x)); + checkTask(x.json); + if (x.json.id !== id) fail('tracker-shape'); +- if (x.json.project_id !== ctx.project) fail('task-moved'); +- let bucket = ctx.view.done; ++ if (x.json.project_id !== at.project) fail('task-moved'); ++ let bucket = at.view.done; + if (!x.json.done) { +- bucket = (await board(ctx)).get(id)?.bucket; ++ bucket = (await board(ctx, at.project, at.view)).get(id)?.bucket; + if (!bucket) fail('task-placement'); + } + const fields = taskFields(x.json, bucket); +@@ -97,14 +119,14 @@ export function verbs(ctx) { + const relatedTo = (task, kind, other) => + Array.isArray(task.related_tasks?.[kind]) && task.related_tasks[kind].some((t) => t?.id === other); + // One write with the acting role's token. An uncertain answer is settled by a re-read. +- async function step(role, method, path, body, id, done) { ++ async function step(role, method, path, body, at, done) { + const r = await ctx.as(role, method, path, { body }); + const o = outcome(r); + if (o === 'ok') return r; +- if (o === 'uncertain' && id !== null && done) { ++ if (o === 'uncertain' && at !== null && done) { + let after = null; + try { +- after = await read(id); ++ after = await read(at); + } catch { + // the re-read failed too: the outcome stays unknown + } +@@ -120,8 +142,8 @@ export function verbs(ctx) { + if (PM_VERBS.has(verb) && mine.definition !== 'pm') fail('field-writer'); + const plan = prepare(verb, args, role, mine); + if (verb === 'task.create') return create(cap, role, plan); +- const id = target(args.task_ref); +- const before = await read(id); ++ const at = target(args.task_ref); ++ const before = await read(at); + if (args.expect !== undefined && args.expect !== before.digest) { + ctx.broker.recordTask({ + cap, +@@ -132,13 +154,14 @@ export function verbs(ctx) { + } + // Reopening a done task is a person's act; no verb writes to one. + if (before.task.done) fail('task-done'); +- const work = plan.check(before, id); ++ const work = plan.check(before, at); ++ await located(plan.others ?? []); + 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); ++ await step(role, method, path, body, at, done); + landed++; + } + } catch (e) { +@@ -146,7 +169,7 @@ export function verbs(ctx) { + } + let after = null; + try { +- after = await read(id); ++ after = await read(at); + } catch { + // handled below: a failed final read must not hide a write that landed (review r1, B2) + } +@@ -172,7 +195,13 @@ export function verbs(ctx) { + if (args?.expect !== undefined && !/^[0-9a-f]{64}$/.test(args.expect)) fail('invalid-request'); + switch (verb) { + case 'task.create': { +- keys(args, ['title', 'description', 'due_date', 'priority', 'labels', 'relations', 'request', 'requirement', 'decision'], ['title', 'request', 'requirement']); ++ keys(args, ['title', 'description', 'due_date', 'priority', 'labels', 'relations', 'request', 'requirement', 'decision', 'project'], ['title', 'request', 'requirement']); ++ // Mos's ruling: an optional project, which must be an installed project of the tree. Without ++ // it the task goes to the root. ++ const project = args.project === undefined ? ctx.project : args.project; ++ if (!Number.isSafeInteger(project) || project < 1) fail('invalid-request'); ++ if (!ctx.tree.get(project)) fail('project-not-installed'); ++ plan.at = { project, view: ctx.tree.get(project) }; + const body = { title: text(args.title, 250, 1) }; + if (!body.title.trim()) fail('invalid-request'); + if (args.description !== undefined) body.description = text(args.description, 65536); +@@ -182,6 +211,7 @@ export function verbs(ctx) { + plan.labels = args.labels === undefined ? [] : ids(args.labels).map(label); + if (args.relations !== undefined && (!Array.isArray(args.relations) || args.relations.length > 50)) fail('invalid-request'); + plan.relations = (args.relations ?? []).map(relation); ++ plan.others = plan.relations; + plan.request = text(args.request, 64, 1); + plan.requirement = text(args.requirement, 64); + if (!/^REQ-[A-Z]+-[1-9][0-9]*$/.test(plan.requirement)) fail('invalid-request'); +@@ -193,7 +223,7 @@ export function verbs(ctx) { + const to = ctx.roles[text(args.role, 64, 1)]; + if (!to || !Object.hasOwn(ctx.roles, args.role)) fail('unknown-role'); + const bots = new Map(Object.entries(ctx.roles).map(([r, x]) => [x.botId, r])); +- plan.check = ({ fields }, id) => { ++ plan.check = ({ fields }, { id }) => { + const held = fields.assignees.filter((u) => bots.has(u)); + const previous = held.map((u) => bots.get(u)).sort(); + if (verb === 'task.assign' && held.length) fail('task-assigned'); +@@ -232,7 +262,8 @@ export function verbs(ctx) { + remove: (list(args.relations.remove) ?? []).map(relation), + }); + const dueDate = args.due_date === undefined ? undefined : due(args.due_date); +- plan.check = ({ task, fields }, id) => { ++ plan.others = [...relations.add, ...relations.remove]; ++ plan.check = ({ task, fields }, { id }) => { + const steps = []; + const iso = dueDate === undefined ? undefined : dueDate; + if (iso !== undefined && iso !== fields.due_date) +@@ -261,7 +292,7 @@ export function verbs(ctx) { + case 'task.priority.change': { + keys(args, [...common, 'priority'], ['task_ref', 'priority']); + const p = priority(args.priority); +- plan.check = ({ fields }, id) => ({ ++ plan.check = ({ fields }, { id }) => ({ + steps: p === fields.priority ? [] : [['PATCH', `/tasks/${id}`, { priority: p }, (a) => a.fields.priority === p]], + expect: (f) => ({ ...f, priority: p }), + event: null, +@@ -275,7 +306,7 @@ export function verbs(ctx) { + if (body.title !== undefined && !body.title.trim()) fail('invalid-request'); + if (args.description !== undefined) body.description = text(args.description, 65536); + if (!Object.keys(body).length) fail('invalid-request'); +- plan.check = ({ fields }, id) => { ++ plan.check = ({ fields }, { id }) => { + const send = Object.fromEntries(Object.entries(body).filter(([k, v]) => fields[k] !== v)); + return { + steps: Object.keys(send).length +@@ -294,16 +325,16 @@ export function verbs(ctx) { + if (args.state !== undefined && !STATES.includes(args.state)) fail('invalid-state'); + const note = args.comment === undefined ? undefined : text(args.comment, 10000, 1); + if (pct === undefined && args.state === undefined && note === undefined) fail('invalid-request'); +- plan.check = ({ fields }, id) => { ++ plan.check = ({ fields }, { id, project, view }) => { + if (!fields.assignees.includes(mine.botId)) fail('not-assigned'); +- const to = args.state === undefined ? fields.bucket : ctx.view.ids[args.state]; ++ const to = args.state === undefined ? fields.bucket : view.ids[args.state]; + const steps = []; + if (pct !== undefined && pct !== fields.percent_done) + steps.push(['PATCH', `/tasks/${id}`, { percent_done: pct }, (a) => a.fields.percent_done === pct]); + if (to !== fields.bucket) + steps.push([ + 'PUT', +- `/projects/${ctx.project}/views/${ctx.view.id}/buckets/${to}/tasks`, ++ `/projects/${project}/views/${view.id}/buckets/${to}/tasks`, + { task_id: id }, + (a) => a.bucket === to, + ]); +@@ -316,8 +347,8 @@ export function verbs(ctx) { + kind: 'task.state', + body: { + role, +- state: ctx.view.titles[to], +- previous: ctx.view.titles[fields.bucket] ?? null, ++ state: view.titles[to], ++ previous: view.titles[fields.bucket] ?? null, + percent_done: pct ?? fields.percent_done, + comment: note !== undefined, + }, +@@ -330,16 +361,16 @@ export function verbs(ctx) { + keys(args, [...common, 'verdict'], ['task_ref', 'verdict']); + // The queue holds review verdicts in slice 1. This is a citation, recorded and not checked. + const verdict = text(args.verdict, 256, 1); +- plan.check = (_before, id) => ({ ++ plan.check = (_before, { id, project, view }) => ({ + steps: [ + [ + 'PUT', +- `/projects/${ctx.project}/views/${ctx.view.id}/buckets/${ctx.view.done}/tasks`, ++ `/projects/${project}/views/${view.id}/buckets/${view.done}/tasks`, + { task_id: id }, +- (a) => a.task.done && a.bucket === ctx.view.done, ++ (a) => a.task.done && a.bucket === view.done, + ], + ], +- expect: (f) => ({ ...f, done: true, bucket: ctx.view.done }), ++ expect: (f) => ({ ...f, done: true, bucket: view.done }), + event: { kind: 'task.closed', body: { verdict } }, + }); + return plan; +@@ -350,19 +381,21 @@ export function verbs(ctx) { + } + async function create(cap, role, plan) { + if (!ctx.broker.taskView(ctx.business, 'input', plan.request)) fail('request-not-found'); ++ await located(plan.others); + ctx.broker.authorize(cap, 'task.create', { decision: plan.decision, target: null }); + // No re-read can find a create whose answer was lost, so an uncertain create is reported as such. +- const r = await step(role, 'POST', `/projects/${ctx.project}/tasks`, plan.body, null, null); ++ const r = await step(role, 'POST', `/projects/${plan.at.project}/tasks`, plan.body, null, null); + if (r.status !== 201 && r.status !== 200) fail('tracker-unexpected'); + checkTask(r.json); + const id = r.json.id; +- const taskRef = ref(ctx.project, id); ++ const at = { ...plan.at, id }; ++ const taskRef = ref(at.project, id); + let failure = null; + try { + for (const l of plan.labels) +- await step(role, 'POST', `/tasks/${id}/labels`, { label_id: l }, id, (a) => a.fields.labels.includes(l)); ++ await step(role, 'POST', `/tasks/${id}/labels`, { label_id: l }, at, (a) => a.fields.labels.includes(l)); + for (const x of plan.relations) +- await step(role, 'POST', `/tasks/${id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, id, (a) => ++ await step(role, 'POST', `/tasks/${id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, at, (a) => + relatedTo(a.task, x.kind, x.other), + ); + } catch (e) { +@@ -370,14 +403,14 @@ export function verbs(ctx) { + } + let after = null; + try { +- after = await read(id); ++ after = await read(at); + } 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) }; ++ const fields = { ...taskFields(r.json, at.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. +diff --git a/packages/tasks/tests/tree.test.mjs b/packages/tasks/tests/tree.test.mjs +index e6851468..5ac99ae5 100644 +--- a/packages/tasks/tests/tree.test.mjs ++++ b/packages/tasks/tests/tree.test.mjs +@@ -412,10 +412,10 @@ test('bucket 0 on a watch-only task never becomes a bucket or a state', async (t + 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. ++ // A verb on a watch-only child's task refuses (row B); verbs.test.mjs covers the installed ones. + await assert.rejects( + w.call(w.agent('pm'), 'task.assign', { task_ref: r, role: 'coder' }), +- (e) => e.code === 'task-project', ++ (e) => e.code === 'project-not-installed', + ); + // The source has no bucket-to-state table and no write path. + const source = readFileSync(new URL('../src/sync.mjs', import.meta.url), 'utf8'); +diff --git a/packages/tasks/tests/verbs.test.mjs b/packages/tasks/tests/verbs.test.mjs +index c9ea98a6..9824a7fa 100644 +--- a/packages/tasks/tests/verbs.test.mjs ++++ b/packages/tasks/tests/verbs.test.mjs +@@ -1,6 +1,6 @@ + import test from 'node:test'; + import assert from 'node:assert/strict'; +-import { world, sleep } from './world.mjs'; ++import { world, sleep, SCOPES } from './world.mjs'; + import { FakeVikunja } from '../src/fake.mjs'; + const ready = async (t, edit) => { + const w = await world(t, edit); +@@ -312,15 +312,15 @@ test('a due date with milliseconds is written to the second', async (t) => { + 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) => { ++// Arms a fault on the final read: after `trigger` answers, the next GET of task `id` fails. ++const thenFailRead = (trigger, id = 1) => { + 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); ++ fake.fault('GET', new RegExp(`^/tasks/${id}$`), 0); + } + return res; + }; +@@ -410,3 +410,222 @@ test('task.created is recorded when a later label write fails', async (t) => { + assert.deepEqual(w.fake.task(1).labels, []); + assert.equal(w.events('task.created').length, 1); + }); ++// Row B: a tree read with its descendants, as in tree.test.mjs. The child is installed before ++// start, so the first reconcile reads it with its own board; the grandchild keeps Vikunja's ++// default buckets, so it is watch-only. tree()'s `edit` goes to world() with TREE. ++const TREE = { ++ tracker: { descendants: true }, ++ scopes: { sync: { ...SCOPES.sync, projects: [...SCOPES.sync.projects, 'read_all'] } }, ++}; ++const tree = async (t, edit = {}) => { ++ const w = await world(t, { ...TREE, ...edit }); ++ const child = w.fake.project('Child', w.owner, { parent: w.project }); ++ const board = w.fake.install(child); ++ const grandchild = w.fake.project('Grandchild', w.owner, { parent: child }); ++ const watched = (await w.ui('POST', `/projects/${grandchild}/tasks`, { title: 'watched' })).json.id; ++ await w.adapter.start('demo'); ++ assert.equal(w.adapter.status()[0].state, 'ready', w.adapter.status()[0].refused); ++ return { w, child, board, grandchild, watched: `vikunja:${grandchild}/${watched}` }; ++}; ++const writes = (w) => w.fake.requests.filter((r) => r.method !== 'GET').length; ++test('every verb acts in an installed child, on the child\'s own board', async (t) => { ++ const { w, child, board } = await tree(t); ++ const pm = w.agent('pm'); ++ const coder = w.agent('coder'); ++ const cto = w.agent('cto'); ++ const root = await task(w); ++ assert.equal(root.task_ref, `vikunja:${w.project}/2`); ++ const made = await task(w, { project: child, relations: [{ kind: 'blocking', task_ref: root.task_ref }] }); ++ const task_ref = `vikunja:${child}/3`; ++ // A cross-role verb on the child's task, approved by the cto. The decision binds the child's ref. ++ const approved = (cap, action) => { ++ const d = w.broker.request(cap, { ++ verb: 'decision.raise', ++ args: { ++ action, ++ domain: 'technical', ++ target: task_ref, ++ task_ref, ++ question: `${action}?`, ++ options: [ ++ { key: 'yes', text: 'yes' }, ++ { key: 'no', text: 'no' }, ++ ], ++ recommendation: 'yes', ++ blocking: false, ++ }, ++ }); ++ w.broker.request(cto, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } }); ++ return d.id; ++ }; ++ assert.equal(made.task_ref, task_ref); ++ assert.equal(w.fake.task(3).project, child); ++ assert.deepEqual(w.fake.task(3).relations, [{ kind: 'blocking', other: 2 }]); ++ assert.equal(w.fake.task(3).buckets.get(board.view), board.buckets.todo); ++ assert.equal(w.fake.task(3).buckets.get(w.installed.view), undefined); ++ assert.equal(w.snapshots(task_ref).at(-1).fields.project_id, child); ++ assert.equal(w.snapshots(task_ref).at(-1).fields.bucket, board.buckets.todo); ++ assert.equal(w.events('task.created').at(-1).subject, task_ref); ++ // An explicit root project is the default spelled out. ++ assert.equal((await task(w, { project: w.project })).task_ref, `vikunja:${w.project}/4`); ++ await w.call(pm, 'task.assign', { task_ref, role: 'coder' }); ++ await assert.rejects(w.call(pm, 'task.reassign', { task_ref, role: 'coder' }), refusedWith('task-assigned')); ++ assert.deepEqual(w.fake.task(3).assignees, [w.bots.coder]); ++ await w.call(coder, 'task.update.assigned', { task_ref, state: 'in-progress', percent_done: 0.5 }); ++ assert.equal(w.fake.task(3).buckets.get(board.view), board.buckets['in-progress']); ++ assert.deepEqual(w.events('task.state').at(-1).body, { role: 'coder', state: 'in-progress', previous: 'todo', percent_done: 0.5, comment: false }); ++ await w.call(coder, 'task.scope.change', { task_ref, description: 'in the child', decision: approved(coder, 'task.scope.change') }); ++ assert.equal(w.fake.task(3).description, 'in the child'); ++ await w.call(pm, 'task.reassign', { task_ref, role: 'reviewer' }); ++ assert.deepEqual(w.fake.task(3).assignees, [w.bots.reviewer]); ++ await w.call(pm, 'task.schedule', { task_ref, due_date: '2026-12-24T12:00:00.000Z', relations: { remove: [{ kind: 'blocking', task_ref: root.task_ref }] } }); ++ assert.deepEqual(w.fake.task(3).relations, []); ++ assert.equal(w.snapshots(task_ref).at(-1).fields.due_date, '2026-12-24T12:00:00.000Z'); ++ // A relation from a root task to the child's task, the other way across the tree. ++ await w.call(pm, 'task.schedule', { task_ref: root.task_ref, relations: { add: [{ kind: 'related', task_ref }] } }); ++ assert.deepEqual(w.fake.task(2).relations, [{ kind: 'related', other: 3 }]); ++ await w.call(pm, 'task.priority.change', { task_ref, priority: 4, decision: approved(pm, 'task.priority.change') }); ++ assert.equal(w.fake.task(3).priority, 4); ++ await w.call(pm, 'task.close', { task_ref, verdict: 'queue:63' }); ++ assert.equal(w.fake.task(3).done, true); ++ assert.equal(w.fake.task(3).buckets.get(board.view), board.buckets.done); ++ assert.equal(w.snapshots(task_ref).at(-1).fields.bucket, board.buckets.done); ++ assert.ok(!w.broker.taskView('demo', 'open').some((r) => r.task_ref === task_ref)); ++}); ++test('task.create refuses a project outside the tree, a watch-only one and one that is not an id', async (t) => { ++ const { w, grandchild } = await tree(t); ++ w.agent('pm'); ++ const outside = w.fake.project('Outside', w.owner); ++ const before = writes(w); ++ await assert.rejects(task(w, { project: grandchild }), refusedWith('project-not-installed')); ++ await assert.rejects(task(w, { project: outside }), refusedWith('project-not-installed')); ++ await assert.rejects(task(w, { project: 999 }), refusedWith('project-not-installed')); ++ for (const project of [null, 0, -1, 1.5, '1', [w.project]]) await assert.rejects(task(w, { project }), refusedWith('invalid-request')); ++ assert.equal(writes(w), before); ++ assert.equal(allowed(w, 'task.create').length, 0); ++}); ++test('with descendants off, task.create takes only the root project', async (t) => { ++ const w = await world(t); ++ const child = w.fake.project('Child', w.owner, { parent: w.project }); ++ w.fake.install(child); ++ await w.adapter.start('demo'); ++ w.agent('pm'); ++ await assert.rejects(task(w, { project: child }), refusedWith('project-not-installed')); ++ assert.equal((await task(w, { project: w.project })).task_ref, `vikunja:${w.project}/1`); ++}); ++test('a verb on a watch-only task, or a relation to one, refuses with project-not-installed', async (t) => { ++ const { w, watched } = await tree(t); ++ const pm = w.agent('pm'); ++ const root = await task(w); ++ const before = writes(w); ++ await assert.rejects(w.call(pm, 'task.assign', { task_ref: watched, role: 'coder' }), refusedWith('project-not-installed')); ++ await assert.rejects(w.call(pm, 'task.close', { task_ref: watched, verdict: 'queue:63' }), refusedWith('project-not-installed')); ++ await assert.rejects( ++ w.call(pm, 'task.schedule', { task_ref: root.task_ref, relations: { add: [{ kind: 'related', task_ref: watched }] } }), ++ refusedWith('project-not-installed'), ++ ); ++ await assert.rejects(task(w, { relations: [{ kind: 'related', task_ref: watched }] }), refusedWith('project-not-installed')); ++ // A project outside the tree is still not the business's. ++ await assert.rejects(w.call(pm, 'task.assign', { task_ref: 'vikunja:999/1', role: 'coder' }), refusedWith('task-project')); ++ assert.equal(writes(w), before); ++ assert.equal(allowed(w, 'task.assign').length + allowed(w, 'task.close').length + allowed(w, 'task.schedule').length, 0); ++}); ++test('a related task is read before authority: in another project than its ref names, task-moved; missing, task-not-found', async (t) => { ++ const { w, child } = await tree(t); ++ const pm = w.agent('pm'); ++ const root = await task(w); ++ const id = Number(root.task_ref.split('/')[1]); ++ // The ref claims the child, but the task sits in the root. ++ const claimed = `vikunja:${child}/${id}`; ++ const before = writes(w); ++ await assert.rejects( ++ w.call(pm, 'task.schedule', { task_ref: root.task_ref, relations: { add: [{ kind: 'related', task_ref: claimed }] } }), ++ refusedWith('task-moved'), ++ ); ++ await assert.rejects( ++ w.call(pm, 'task.schedule', { task_ref: root.task_ref, relations: { remove: [{ kind: 'related', task_ref: claimed }] } }), ++ refusedWith('task-moved'), ++ ); ++ await assert.rejects(task(w, { project: child, relations: [{ kind: 'related', task_ref: claimed }] }), refusedWith('task-moved')); ++ await assert.rejects(task(w, { relations: [{ kind: 'related', task_ref: `vikunja:${child}/77` }] }), refusedWith('task-not-found')); ++ assert.equal(writes(w), before); ++ assert.equal(allowed(w, 'task.schedule').length + allowed(w, 'task.create').length, 1); ++ // The same relation with the right ref goes through. ++ const other = await task(w, { project: child }); ++ await w.call(pm, 'task.schedule', { task_ref: root.task_ref, relations: { add: [{ kind: 'related', task_ref: other.task_ref }] } }); ++ assert.deepEqual(w.fake.task(id).relations, [{ kind: 'related', other: Number(other.task_ref.split('/')[1]) }]); ++}); ++test('a task moved to another project of the tree refuses its old ref with task-moved and takes its new one', async (t) => { ++ const { w, child, board } = await tree(t); ++ const pm = w.agent('pm'); ++ const { task_ref } = await task(w); ++ const id = Number(task_ref.split('/')[1]); ++ w.fake.moveToProject(id, child); ++ const before = writes(w); ++ await assert.rejects(w.call(pm, 'task.assign', { task_ref, role: 'coder' }), refusedWith('task-moved')); ++ assert.equal(writes(w), before); ++ await w.call(pm, 'task.assign', { task_ref: `vikunja:${child}/${id}`, role: 'coder' }); ++ assert.deepEqual(w.fake.task(id).assignees, [w.bots.coder]); ++ assert.equal(w.snapshots(`vikunja:${child}/${id}`).at(-1).fields.bucket, board.buckets.todo); ++}); ++// The child versions of the re-read and fallback paths the root's tests above cover (Dewey's ++// and Filbert's round 1 reviews): each fails if its path goes back to the root's project or view. ++test('a verb and a create in the child record the final read of the child\'s board', async (t) => { ++ const { w, child } = await tree(t); ++ const pm = w.agent('pm'); ++ const made = await task(w, { project: child }); ++ assert.notEqual(w.snapshots(made.task_ref).at(-1).etag, null); ++ await w.call(pm, 'task.assign', { task_ref: made.task_ref, role: 'coder' }); ++ assert.notEqual(w.snapshots(made.task_ref).at(-1).etag, null); ++}); ++test('a create in the child whose final read fails records the child\'s todo bucket', async (t) => { ++ // The grandchild's task is 1, so the create makes task 2. ++ const f = thenFailRead((m, p) => m === 'POST' && /\/projects\/\d+\/tasks$/.test(p), 2); ++ const { w, child, board } = await tree(t, f.edit); ++ w.agent('pm'); ++ f.arm(); ++ const r = await task(w, { project: child }); ++ assert.equal(r.task_ref, `vikunja:${child}/2`); ++ assert.equal(w.events('task.created').length, 1); ++ const s = w.snapshots(r.task_ref).at(-1); ++ assert.equal(s.source, 'self'); ++ assert.equal(s.etag, null); ++ assert.equal(s.fields.bucket, board.buckets.todo); ++ await sleep(20); ++ await w.adapter.tick('demo'); ++ // The start's reconcile recorded the grandchild's task as external; the create isn't. ++ assert.deepEqual(w.events('task.changed.external').map((e) => e.subject), [`vikunja:${w.fake.task(1).project}/1`]); ++}); ++test('a lost answer in the child is settled by a re-read of the child\'s board', async (t) => { ++ const { w, child, board } = await tree(t); ++ const pm = w.agent('pm'); ++ const coder = w.agent('coder'); ++ const { task_ref } = await task(w, { project: child }); ++ await w.call(pm, 'task.assign', { task_ref, role: 'coder' }); ++ // A state move and a close that landed but answered 500: each re-read shows it, so each succeeds. ++ w.fake.fault('PUT', /\/buckets\/\d+\/tasks$/, 500, { apply: true }); ++ await w.call(coder, 'task.update.assigned', { task_ref, state: 'in-progress' }); ++ assert.equal(w.events('task.state').length, 1); ++ assert.equal(w.snapshots(task_ref).at(-1).fields.bucket, board.buckets['in-progress']); ++ w.fake.fault('PUT', /\/buckets\/\d+\/tasks$/, 500, { apply: true }); ++ await w.call(pm, 'task.close', { task_ref, verdict: 'queue:63' }); ++ assert.equal(w.events('task.closed').length, 1); ++ assert.equal(w.snapshots(task_ref).at(-1).fields.bucket, board.buckets.done); ++ assert.equal(w.fake.requests.filter((r) => r.method === 'PUT').length, 2); ++}); ++test('every related task is read before authority, not only the first', async (t) => { ++ const { w, child } = await tree(t); ++ const pm = w.agent('pm'); ++ const root = await task(w); ++ const other = await task(w); ++ const id = Number(other.task_ref.split('/')[1]); ++ const before = writes(w); ++ await assert.rejects( ++ w.call(pm, 'task.schedule', { ++ task_ref: root.task_ref, ++ relations: { add: [{ kind: 'related', task_ref: other.task_ref }, { kind: 'blocking', task_ref: `vikunja:${child}/${id}` }] }, ++ }), ++ refusedWith('task-moved'), ++ ); ++ assert.equal(writes(w), before); ++}); diff --git a/agents/darkwing/work/queue-63/r2/candidate-manifest.sha256 b/agents/darkwing/work/queue-63/r2/candidate-manifest.sha256 new file mode 100644 index 000000000..a32b9355e --- /dev/null +++ b/agents/darkwing/work/queue-63/r2/candidate-manifest.sha256 @@ -0,0 +1,6 @@ +99a9511d72a225bb8df23435286ceaf6e97455b84887ce74cb12f70272cf0a10 packages/harness/src/tools.mjs +be63dcd26e48f6e20f1397e0fb0225c6570c066be90540daebcb50dc9947eb51 packages/harness/tests/tools.test.mjs +8d1e176cc90dd793cca1601f46f6094416209a62b68dcf764d2e8c6f8ae2cd9f packages/tasks/README.md +c0bcd5089a29d6361ae2ac9a92bf0ba40bd08385108d2fb249487650d137264a packages/tasks/src/verbs.mjs +2e90748d650ddbb778966b54202b35774569731ee245f6203e97d7ff4eb88b6a packages/tasks/tests/tree.test.mjs +7429419f3cad43a53901b1763359ce7ef212d21ca07ad852a5673a7b86660a07 packages/tasks/tests/verbs.test.mjs diff --git a/agents/darkwing/work/queue-63/r2/gate-summary.txt b/agents/darkwing/work/queue-63/r2/gate-summary.txt new file mode 100644 index 000000000..eac8175fe --- /dev/null +++ b/agents/darkwing/work/queue-63/r2/gate-summary.txt @@ -0,0 +1,16 @@ +Row 63 round 2 gate, candidate manifest 2a22fd3f0a04c38e65d40d215b95516c07a9eeff6b2199accc49c39422e22859, worktree detached at cca5f23e holding the candidate (a fresh worktree with build.patch applied checks 6/6 against the manifest), node_modules symlinked, TMPDIR in scratch, sequential, 2026-10-12 UTC, gate.sh here. test-task with DOCKER_HOST=unix:///nonexistent. +tasks rc=0 22:37:42-22:37:47 ℹ pass 84 ℹ fail 0 ℹ skipped 0 0 marks +harness rc=0 22:37:47-22:37:57 ℹ pass 65 ℹ fail 0 ℹ skipped 0 0 marks +business rc=0 22:37:57-22:37:59 ℹ pass 60 ℹ fail 0 ℹ skipped 0 0 marks +bus rc=0 22:37:59-22:38:02 ℹ pass 108 ℹ fail 0 ℹ skipped 0 0 marks +cli rc=0 22:38:02-22:38:18 ℹ pass 120 ℹ fail 0 ℹ skipped 0 0 marks +webui rc=0 22:38:18-22:39:13 ℹ pass 86 ℹ fail 0 ℹ skipped 0 0 marks +test-auth rc=0 22:39:13-22:39:14 selftest: 15 passed, 0 failed +test-conductor rc=0 22:39:14-22:39:19 selftest: 17 passed, 0 failed +test-config rc=0 22:39:19-22:39:21 selftest: 24 passed, 0 failed +test-discord rc=0 22:39:21-22:39:28 discord suite: 66 passed, 0 failed +test-extension-package rc=0 22:39:28-22:39:29 extension package selftest: 18 passed, 0 failed +test-foundation rc=0 22:39:29-22:40:10 selftest: 44 passed, 0 failed +test-queue rc=0 22:40:10-22:40:44 queue suite: 27 passed, 0 failed +test-release rc=0 22:40:44-22:40:55 selftest: 14 passed, 0 failed +test-task rc=0 22:40:55-22:40:57 selftest: 26 passed, 0 failed diff --git a/agents/darkwing/work/queue-63/r2/gate.sh b/agents/darkwing/work/queue-63/r2/gate.sh new file mode 100644 index 000000000..45dbc9f22 --- /dev/null +++ b/agents/darkwing/work/queue-63/r2/gate.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Row 63 round 2 gate, rebased onto cca5f23e: tasks, harness, business, bus, cli and webui (each package's own +# test files, from its directory), then every scripts/test-*.sh, sequential, full output kept. +cd ~/darkwing-scratch/r63/wt2 || exit 2 +W=$PWD +export TMPDIR=~/darkwing-scratch/r63/tmp +O=~/darkwing-scratch/r63/gate2 +mkdir -p "$O" +for p in tasks harness business bus cli webui; do + s=$(date -u +%T) + case $p in webui) a=(tests/);; *) a=(tests/*.test.mjs);; esac + (cd "$W/packages/$p" && node --test "${a[@]}") > $O/node-$p.txt 2>&1; rc=$? + echo "$p rc=$rc $s-$(date -u +%T) $(grep -E '^ℹ (pass|fail|skipped)' $O/node-$p.txt | tr '\n' ' ') $(grep -cE '^✖|^not ok' $O/node-$p.txt) marks" +done +for f in scripts/test-*.sh; do + n=$(basename $f .sh); s=$(date -u +%T) + if [ $n = test-task ]; then DOCKER_HOST=unix:///nonexistent bash $f > $O/$n.txt 2>&1; else bash $f > $O/$n.txt 2>&1; fi; rc=$? + echo "$n rc=$rc $s-$(date -u +%T) $(tail -1 $O/$n.txt | cut -c1-120)" +done diff --git a/agents/darkwing/work/queue-63/r2/mutants.sh b/agents/darkwing/work/queue-63/r2/mutants.sh new file mode 100644 index 000000000..466581e5c --- /dev/null +++ b/agents/darkwing/work/queue-63/r2/mutants.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Row 63 (#1556) round 2 mutants: round 1's 18, then 7 more. Run from the candidate worktree's root: +# bash mutants.sh /path/to/mut.mjs > mutants.txt +# Each mutant edits one site with mut.mjs (exactly one match, else exit 3), +# runs the named test files and restores the file. "killed" means the run +# failed; "survived" means it passed. +set -u +MUT=$1 +run() { + local name=$1 file=$2 from=$3 to=$4; shift 4 + cp -- "$file" "$file.orig" + if ! node "$MUT" "$file" "$from" "$to"; then + echo "$name: NO MATCH"; mv -- "$file.orig" "$file"; return + fi + if node --test "$@" > "${LOG:-.}/mut-$name.log" 2>&1; then + echo "$name: survived" + else + echo "$name: killed ($(grep -m1 -o 'ℹ fail [0-9]*' "${LOG:-.}/mut-$name.log"), $(grep -m1 -o "code: '[a-z-]*'" "${LOG:-.}/mut-$name.log" || echo 'assertion'))" + fi + mv -- "$file.orig" "$file" +} +V=packages/tasks/src/verbs.mjs +T=(packages/tasks/tests/verbs.test.mjs packages/tasks/tests/tree.test.mjs) +H=packages/harness/src/tools.mjs +HT=(packages/harness/tests/tools.test.mjs) +run watch-only-writable $V $' if (!view) fail(\'project-not-installed\');\n' '' "${T[@]}" +run create-root-only $V 'plan.at = { project, view: ctx.tree.get(project) };' 'plan.at = { project: ctx.project, view: ctx.view };' "${T[@]}" +run create-watch-only-ok $V "if (!ctx.tree.get(project)) fail('project-not-installed');" "if (!ctx.tree.has(project)) fail('project-not-installed');" "${T[@]}" +run create-project-any $V "if (!Number.isSafeInteger(project) || project < 1) fail('invalid-request');" '' "${T[@]}" +run move-root-view $V 'const to = args.state === undefined ? fields.bucket : view.ids[args.state];' 'const to = args.state === undefined ? fields.bucket : ctx.view.ids[args.state];' "${T[@]}" +run move-path-root-view $V '`/projects/${project}/views/${view.id}/buckets/${to}/tasks`' '`/projects/${project}/views/${ctx.view.id}/buckets/${to}/tasks`' "${T[@]}" +run close-root-done $V '`/projects/${project}/views/${view.id}/buckets/${view.done}/tasks`' '`/projects/${project}/views/${view.id}/buckets/${ctx.view.done}/tasks`' "${T[@]}" +run read-root-board $V 'bucket = (await board(ctx, at.project, at.view)).get(id)?.bucket;' 'bucket = (await board(ctx)).get(id)?.bucket;' "${T[@]}" +run read-no-project $V "if (x.json.project_id !== at.project) fail('task-moved');" '' "${T[@]}" +run relation-any-project $V $' installed(other.project);\n' '' "${T[@]}" +run handle-no-located $V $' await located(plan.others ?? []);\n' '' "${T[@]}" +run create-no-located $V $' await located(plan.others);\n' '' "${T[@]}" +run located-no-project $V "if (x.json.project_id !== r.project) fail('task-moved');" '' "${T[@]}" +run schedule-add-only $V 'plan.others = [...relations.add, ...relations.remove];' 'plan.others = [...relations.add];' "${T[@]}" +run located-after-authorize $V $' await located(plan.others ?? []);\n ctx.broker.authorize(cap, verb, { decision: plan.decision, target: args.task_ref });\n' $' ctx.broker.authorize(cap, verb, { decision: plan.decision, target: args.task_ref });\n await located(plan.others ?? []);\n' "${T[@]}" +run tool-project-string $H 'project: { type: "integer", minimum: 1,' 'project: { type: "string", minimum: 1,' "${HT[@]}" +run tool-project-zero $H 'project: { type: "integer", minimum: 1,' 'project: { type: "integer", minimum: 0,' "${HT[@]}" +run tool-project-required $H '}, ["title", "request", "requirement"]],' '}, ["title", "request", "requirement", "project"]],' "${HT[@]}" +# Round 2: the survivors of Dewey's and Filbert's round 1 mutants, each a child path sent back to +# the root's project or view, or the relation read cut to the first relation. +R='{ ...at, project: ctx.project, view: ctx.view }' +run located-first-only $V 'for (const r of list) {' 'for (const r of list.slice(0, 1)) {' "${T[@]}" +run step-reread-root $V $'after = await read(at);\n } catch {\n // the re-read failed too' $'after = await read('"$R"$');\n } catch {\n // the re-read failed too' "${T[@]}" +run handle-final-root $V $'after = await read(at);\n } catch {\n // handled below: a failed' $'after = await read('"$R"$');\n } catch {\n // handled below: a failed' "${T[@]}" +run create-final-root $V $'after = await read(at);\n } catch {\n // handled below, as in handle()' $'after = await read('"$R"$');\n } catch {\n // handled below, as in handle()' "${T[@]}" +run create-fallback-root-todo $V 'taskFields(r.json, at.view.ids.todo)' 'taskFields(r.json, ctx.view.ids.todo)' "${T[@]}" +run read-done-root $V 'let bucket = at.view.done;' 'let bucket = ctx.view.done;' "${T[@]}" +run close-done-root $V '(a) => a.task.done && a.bucket === view.done,' '(a) => a.task.done && a.bucket === ctx.view.done,' "${T[@]}" diff --git a/agents/darkwing/work/queue-63/r2/mutants.txt b/agents/darkwing/work/queue-63/r2/mutants.txt new file mode 100644 index 000000000..e935014e3 --- /dev/null +++ b/agents/darkwing/work/queue-63/r2/mutants.txt @@ -0,0 +1,25 @@ +watch-only-writable: killed (ℹ fail 2, assertion) +create-root-only: killed (ℹ fail 3, assertion) +create-watch-only-ok: killed (ℹ fail 1, assertion) +create-project-any: killed (ℹ fail 1, assertion) +move-root-view: killed (ℹ fail 2, code: 'tracker-not-found') +move-path-root-view: killed (ℹ fail 2, code: 'tracker-not-found') +close-root-done: killed (ℹ fail 2, code: 'tracker-not-found') +read-root-board: killed (ℹ fail 4, code: 'task-placement') +read-no-project: killed (ℹ fail 1, assertion) +relation-any-project: killed (ℹ fail 2, assertion) +handle-no-located: killed (ℹ fail 2, assertion) +create-no-located: killed (ℹ fail 1, assertion) +located-no-project: killed (ℹ fail 2, assertion) +schedule-add-only: killed (ℹ fail 1, assertion) +located-after-authorize: killed (ℹ fail 1, assertion) +tool-project-string: killed (ℹ fail 1, assertion) +tool-project-zero: killed (ℹ fail 1, assertion) +tool-project-required: killed (ℹ fail 1, assertion) +located-first-only: killed (ℹ fail 1, assertion) +step-reread-root: killed (ℹ fail 1, code: 'write-uncertain') +handle-final-root: killed (ℹ fail 1, assertion) +create-final-root: killed (ℹ fail 1, assertion) +create-fallback-root-todo: killed (ℹ fail 1, assertion) +read-done-root: killed (ℹ fail 1, code: 'write-uncertain') +close-done-root: killed (ℹ fail 1, code: 'write-uncertain')