Files
stack/packages/tasks

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:

{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:

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.