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]>
388 lines
19 KiB
Markdown
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.
|