Files
stack/packages/tasks/README.md
T
jason.woltjeandClaude Opus 5.5 3780e05df7 roles: drop the coder's stale task.reassign text, pin the untyped list (#1573)
Row 79 round 1, owner Rocko, candidate manifest 16b12387 on 331d037c.
Approved by Dewey (27611); Sage's gate green.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 20:23:35 -05:00

388 lines
19 KiB
Markdown

# Tasks adapter, slice 1 S3
This package connects the broker to Vikunja v2. It handles the eight 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,
a comment body or a description.
Addendum B section 4 defines the verbs and section 7 the probes. The
probe results it relies on are in
`agents/darkwing/work/slice1-s3/probes.md`, cited below by section letter.
## Host contract
The host forks `packages/bus/src/process.mjs` and puts a `trackers` map in
the boot config. The broker process loads this adapter only when the map
is present:
```js
{op: 'boot', config: {dataRoot, businesses, readers, repoRoots,
trackers: {acme: {baseUrl: 'https://tasks.example.org', project: 12,
pollSeconds: 30, reconcileMinutes: 60, descendants: false}}}}
```
`baseUrl` is the origin only, for example `https://tasks.example.org`. The
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. `descendants` defaults to false
and must be a boolean. See "Project trees".
From the business definition (`busBusiness` output) the adapter needs:
- `tracker.sync.botId` and `tracker.sync.credentials.vikunja`, the sync
bot. Its credential reference is `<business>/@sync`.
- `tracker.labels`, a map from label title to label id. These are the only
label ids a verb may add or remove.
- for each role with a `tracker` block, `tracker.botId` and its Vikunja
credential. Exactly one of those roles must have definition `pm`.
A business with no `trackers` entry has no tracker, and its task verbs
refuse with `tracker-unconfigured`. Any other problem in this config
refuses the whole boot with `tracker-config`.
To embed it without the process wrapper, pass the factory to
`startBroker`:
```js
import { tasksAdapter } from '@mosaic/tasks';
await startBroker({dataRoot, businesses, tasks: tasksAdapter({trackers})});
```
The factory returns `{handle, timeout, close}`. `timeout` is 60 s
(`TIMEOUT`). A verb can make several Vikunja writes, and the socket server
raises its idle timeout to this value for task verbs. A client that calls
a task verb must wait at least that long too. Give up sooner and the
outcome is unknown, not failed.
## Startup
The broker doesn't wait for Vikunja. Startup runs in the background, and
until it passes every verb for that business refuses with
`tracker-starting`. In order, it checks:
1. `GET /info` reports v2.4 or later (`tracker-version`). Anything other
than v2.7.0, the probed release, is logged as `untested-version` and
allowed.
2. The sync credential `<business>/@sync` exists
(`credential-unavailable`) and no Vikunja credential of the business
is past its `expires_at` (`credential-expired`). The adapter checks
the date itself, because Vikunja accepts a token minted with a past
expiry (probes.md, O).
3. The sync bot can read the project (`tracker-project`).
4. The project has exactly one manual kanban view with the buckets
`todo`, `in-progress`, `in-review`, `blocked` and `done`, each once.
`done` is the done bucket and `todo` the default (`tracker-install`).
5. Each token is no broader than its role (`scope-too-broad`). The sync
bot's PATCH on a missing task must answer 401. Each role must get
404/4002 reading it and 401 deleting it, and each non-pm role 401
adding a label to it. The missing task is id 2147483647 (`MISSING`).
The addendum said one more than the highest id the sync bot sees, but
on a shared instance that id can be another project's task, which
answers 403. The largest int32 is missing on any instance this stack
will meet. A scope 401 comes before the 404 (probes.md, O).
6. The pm can see every configured label through `GET /labels`
(`tracker-labels`). Otherwise label writes would answer 403. A bot
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`. 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
stays until the broker restarts, because it needs a person to fix the
install or a token. A 401 at any step refuses with
`tracker-unauthorized`.
## Verbs
All eight go through the same steps:
1. The field-table writer check. `task.create`, `task.assign`,
`task.reassign`, `task.schedule`, `task.priority.change` and
`task.close` write fields only the pm writes, so another role gets
`field-writer`. This runs before `authorize`, so a refused call
consumes no decision.
2. A compare-read with the sync token: the task, and its bucket from the
board when it's open.
3. If the caller passed `expect`, a digest of the fields they last saw,
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
that change something are sent.
7. A final read, then one self snapshot and its event.
A write that answers 5xx or doesn't answer is never retried. The adapter
reads the task again. If the write took effect, it continues. If not,
or the re-read fails too, the verb refuses with `write-uncertain`. A
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` |
| `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 |
| `task.priority.change` | pm | `task_ref`, `priority` 0 to 5 | snapshot only |
| `task.scope.change` | any role, by authority | `task_ref`; `title` and/or `description` | snapshot only |
| `task.update.assigned` | the assigned role | `task_ref`; one or more of `percent_done` 0 to 1, `state`, `comment` | `task.state` |
| `task.close` | pm | `task_ref`, `verdict` | `task.closed` |
Every verb also takes `decision` and `expect`. A task ref is
`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
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.
- `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
showed, because Vikunja answers 204 to removing someone who isn't
assigned (probes.md, D).
- `task.schedule` removes only labels the compare-read showed, because
removing an absent label answers 403 (probes.md, D). Every label id
must be in `tracker.labels`, or the verb refuses with
`label-not-allowed`. That allowlist is the second guard behind label
ownership (decision 68).
- `task.update.assigned` refuses `not-assigned` unless the caller's bot
is an assignee. `state` is `todo`, `in-progress`, `in-review` or
`blocked`. `done` isn't a target here (`invalid-state`), because
closing is the pm's `task.close`. The comment text stays in Vikunja.
The event records `comment: true` and nothing of the text.
- `task.close` moves the task into the done bucket. `verdict` is a
citation of the review verdict in the queue. The adapter records it
and doesn't check it. Checking it against the queue is a follow-up.
- The coder role holds no `task.reassign` grant (row 73). The writer
check refuses a non-pm `task.reassign` before authority is looked at
anyway. If a coder should hand work on, that's a change to the field
table, not to the role file.
Verbs for one business run one at a time, in the same queue as the poll,
so a poll never reads between two writes of a verb.
## Sync
Every `pollSeconds` the adapter reads the whole board, then the task list
filtered on `updated >` the start of the previous tick minus 60 s
(`WINDOW_MS`), floored to the second. Decision 68 set both. The window
covers Vikunja's late `updated` bump (probes.md, U, which has the
measured lag). Moving a task between open buckets doesn't bump `updated`
at all, so the board read is what catches moves. The adapter drops any
read whose digest matches what `task_current` already holds.
Each read that differs becomes a poll snapshot, with `via` set to
`board`, `cursor`, `task` or `reconcile`, and a `task.changed.external`
event naming the fields that changed. A read no newer than the last self
snapshot for that task is skipped, and the next tick reads it again.
That keeps a poll that started before a verb's write from recording the
old state over it.
A task that was open and isn't on the board any more is read once. Done
gives a snapshot in the done bucket and a `task.changed.external` event.
Moved to another project, not readable (403) or deleted gives a
`task.missing` event with the reason (`moved`, `no-access` or
`not-found`) and a tombstone snapshot. Still open in the project means
the board read raced a move, and the next tick places it.
For each task the cursor returned with a new `updated`, the tick reads
the comments and counts those by anyone but this business's bots, newer
than the last one it saw. On the first look at a task it counts only
comments from inside the window, so a restart doesn't report old ones.
The event carries the count, never the text.
Every `reconcileMinutes`, and once at startup, a full walk reads every
task with `expand=comment_count`. A task whose count changed since the
last reconcile gets the same comment check. This catches comments on
tasks the cursor didn't return, so those show up within one reconcile
period, not one poll.
The adapter checks credential state after startup and after every tick.
Each of `credential.expiring`, `credential.expired` and
`credential.changed` is recorded once per instance per process. `changed` means the token file's inode, size or
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 |
|---|---|
| `tracker-config` | the boot config or business definition is wrong |
| `tracker-unconfigured` | the business has no tracker |
| `tracker-starting` | startup hasn't passed yet |
| `tracker-closed` | the broker is shutting down |
| `tracker-unauthorized`, `tracker-forbidden` | Vikunja answered 401 or 403 |
| `tracker-not-found`, `task-not-found` | 404 without and with Vikunja's code 4002 |
| `tracker-invalid` | Vikunja answered 400 or 422 |
| `tracker-rate-limited` | 429 |
| `tracker-unavailable` | 5xx or no answer on a read |
| `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 |
| `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 |
Startup adds `credential-unavailable`, `credential-expired`,
`tracker-version`, `tracker-project`, `tracker-install`,
`scope-too-broad` and `tracker-labels`, listed above.
## Tests
`node --test tests/*.test.mjs` from this directory. The tests use
`FakeVikunja` (`src/fake.mjs`, also exported as `@mosaic/tasks/fake`),
an in-memory Vikunja v2.7.0 that follows the probe notes.
`tests/fixtures/v2-shapes.json` holds responses recorded from the pinned
image. `shapes.test.mjs` checks that every key the fake sends is one
Vikunja sent in the same place, with the same JSON type.
One limit of that check: user and task objects are compared against
every user or task any recording held, so a key one route adds, like
`comment_count`, passes on any route. The fake may also leave keys out.
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.
## Bundled Vikunja
`deploy/vikunja/` has the compose file for installs without their own
Vikunja. See its README.