docs(slice1): row 38 S3 build packet, probes and live rehearsal (darkwing)

Candidate for #1520 against 81339889: packages/tasks and the bus task
verbs. Live run rehearsed on a scratch v2.7.0 container; the estate run
waits for T236's base URL.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-08 18:33:50 -05:00
co-authored by Claude Opus 5.5
parent 81339889dc
commit 4ac133dd8a
7 changed files with 7138 additions and 0 deletions
@@ -0,0 +1,28 @@
ef568876a3420597a4dd5bc3a1e9a869cb32da620856fb43404f8ef8f2df0938 packages/bus/src/broker.mjs
72b53e03e3290be3d065c5e72a929cf9f8c1eabed259c19faefe3085e91f5d3e packages/bus/src/index.mjs
c3ed2eb8c690f43e4b0383e0d50c7a7cf6c59ce14e67b09b44cad6be2bd40f20 packages/bus/src/process.mjs
265b74444523e29cca6f76c9ea7b128ddf5890145b2f713de658c0144a44211f packages/bus/src/runtime.mjs
3e449bb037d8f1693237dd576dcaecb811e146280c94f2dd5ea2024754fa35bd packages/bus/src/server.mjs
a36f3492841b2541b2e43c05dc94400397013a69230f2cf66f153ba53622290e packages/bus/tests/tasks.test.mjs
9f31242e34de26f28677d6b6496923d6d4ee4077cfeb31a86cd2185e67a575b3 packages/tasks/deploy/vikunja/compose.yaml
aa79e805d782fe8a566c4160f0757efcf6edd3c0d7a7145a4764ab5911725856 packages/tasks/deploy/vikunja/README.md
72ea76d1d73e947a0acace06642ed5dac1fdd43fddd98fcebcae2b192344f165 packages/tasks/live/README.md
343c9a65a4e28792b70986ec73f35df4fc748dfab64ce8d524bf82e5c37d1e79 packages/tasks/live/run.mjs
269161444d253da7886d83f7b2f7bd79792b6c6b798c6e72f05f69bb99ad5295 packages/tasks/package.json
ac22ca703ccb7309d0b944328c2f169869c4d67aa19efefd42bca05216a0be7e packages/tasks/README.md
97478e907b15915e75e40e41df74fdeeefdacb390b402c92674bbade7b74153f packages/tasks/src/adapter.mjs
b5580224e931198c7024326135db5bd4f660dfa42834f19e4cd3c08550845bc9 packages/tasks/src/digest.mjs
e98a1e68a9f036f94569e607a83b36e3caef9b725fc9810e80a6aad4dfe6e204 packages/tasks/src/fake.mjs
883cbdd868c8d1a4f33ca5133b5324d1d8cd0b77a0293e864f0ae4e7c0bae81f packages/tasks/src/index.mjs
281029b955a64c18276bdace809e64fb74b152717ff38ccd383da9c07692f083 packages/tasks/src/startup.mjs
45298f168fa96670d3694f58bd8e644beba1c2d50c59813ec6b85695a8ad7af7 packages/tasks/src/sync.mjs
6172e322c861f76387004accad70aa538bee68a635886d505c1bc281894bd89a packages/tasks/src/verbs.mjs
47fce7d5180a0a9a7d6c763f92016c0d10e2c012920d995803a9a713487b121d packages/tasks/src/vikunja.mjs
1ebf6e769e91e917c2c473b956fa3a2a10e6488e10ec9e553cf88e4999f652e2 packages/tasks/tests/adapter.test.mjs
57b7dcbe5af76bbbc08ff9552b831d80e99449d295c8f2df23a36c1ad1291a66 packages/tasks/tests/deploy.test.mjs
df4412b870a1521182080ccae51e4ff16eab9e8ba5b9b58daf93ad889710a607 packages/tasks/tests/fixtures/v2-shapes.json
99dc77c72beccd421c9ea9058d29aaac2718c2093dbbdbf5ac4480222cf3c7c9 packages/tasks/tests/shapes.test.mjs
fb233bb220a4b777d259ed8d632a3486912bde6b93696356f92edcf27cd7f700 packages/tasks/tests/startup.test.mjs
92680573bc04ff4b97eb0379d8f4984ed6059600c00b3385eb4d6d606237c782 packages/tasks/tests/sync.test.mjs
bddbabd1acdd67e8d9a5f7a6fc0beb19e125e6b3a9e49159cf13b19b863bdf5f packages/tasks/tests/verbs.test.mjs
1659ba927506c15382a545a57733d2223e69ed8a6f9a942e9cd979f38c93d8c8 packages/tasks/tests/world.mjs
+200
View File
@@ -0,0 +1,200 @@
# Row 38, S3: tasks (the Vikunja adapter, broker task verbs, sync)
Darkwing, 2026-10-08. Issue #1520, reviewer Filbert. Brief:
`docs/plans/2026-10-04_slice-1.md`, section "Slice 1 S3". Design:
addendum B sections 3, 4 and 7, decisions 68 and 70. Nothing in the
candidate is committed, staged or pushed.
Base is 81339889 (refactor HEAD at gate time). The candidate was built on
bee89d10, and nothing under `packages/` or `scripts/` changed between the
two. In a fresh worktree at 81339889 the patch applies and the result
matches the manifest 28/28.
The row can reach review but can't close. The brief's gate wants the live
run against the instance from row SR, and that waits for T236's base URL.
What's here is the same run rehearsed against a scratch container.
## Files
`build.patch` (sha256
`eb225e5305f50811354604ee45a6e0a7489d9a76543615b2caaba102c13d5700`)
changes 5 files under `packages/bus/src/` and adds 23.
`build-manifest.sha256` (sha256
`be8dbd8ca03ca7264666ba171bd7bffbaaa08c6b265d60e7d587f1bc53897daf`) pins
all 28 after the patch.
New, `packages/tasks/`:
- `src/vikunja.mjs`: the v2 client. Every call goes through the broker's
`credentials.use()`, so the adapter never holds a token. Maps Vikunja's
answers to refusal codes.
- `src/startup.mjs`: the six startup checks.
- `src/verbs.mjs`: the eight task verbs.
- `src/sync.mjs`: the tick (board read plus `updated` cursor), the
reconcile walk, the comment check and missing-task handling.
- `src/adapter.mjs`: the factory the broker loads, one serial queue per
business, timers, credential state, `status()`.
- `src/digest.mjs`, `src/index.mjs`, `src/fake.mjs` (`FakeVikunja`, the
recorded fake of the v2 routes).
- `tests/`: 44 tests in 6 files, `world.mjs` (a broker, credentials and
the fake wired together) and `fixtures/v2-shapes.json` (responses
recorded from the pinned image).
- `deploy/vikunja/compose.yaml` and its README: the bundled option,
REQ-TASK-3. The upstream image by digest, published on 127.0.0.1, no
secret in the file. `tests/deploy.test.mjs` checks the digest against
the runbook and the bind address.
- `live/run.mjs` and its README: the live run.
- `README.md`, `package.json`.
Changed, `packages/bus/` (registering the task verbs, as the brief allows):
- `broker.mjs`: `TASK_VERBS`; `requestTask`, which checks the caller,
runs the secret check on args and result, and records a refusal the
same way `request` does; `recordTask`, which takes self snapshots from a
role session and poll snapshots and events from the adapter (cap
`null`), each from a closed list of event kinds; `taskView`. The
refusal recording moved into `#refused` so both paths share it.
- `runtime.mjs`: `startBroker({tasks})` takes the adapter factory,
refuses an adapter without `handle` or a valid `timeout`, and closes it.
- `server.mjs`: task verbs go to the adapter, async, with the socket idle
timeout raised to the adapter's 60 s. Other verbs stay synchronous.
- `process.mjs`: a plain-data `trackers` map in the boot config loads the
adapter. Decision 70 puts the poller and reconcile in the broker
runtime, and this is how the host process gets them.
- `index.mjs`: exports `TASK_VERBS`.
- `tests/tasks.test.mjs`: 9 tests for the above.
No file outside `packages/bus` and `packages/tasks` changes. The bus
schema (`task_snapshots`, `task_current`, `tasks_open`) was already in S2.
## Probes
`probes.md` in this directory has
every probe of addendum B section 7. The results the brief asked for:
- Labels (section L), the first open point. With the runbook as written,
a bot sees every label its owner made, in any project, and can attach
them. A bot owned by a separate `svc-$BIZ` account sees only labels on
tasks it can read. Decision 68 took that: svc owns the bots and the
labels, and the broker keeps the label-id allowlist as a second guard.
Startup check 6 refuses a business whose pm can't see its configured
labels.
- The async bump lag (section U). Comments, assignees and relations bump
`updated` from an async listener. On an idle server the bump showed
0 ms after the 201. Under load one read straight after missed it and
the next, 5 s later, had it. That bounds the lag at 5 s, and the 60 s
window decision 68 set is twelve times that. Moving between open
buckets doesn't bump `updated` at all, which is why every tick reads
the whole board.
- probe-s7g (section G) checked the reads the code relied on without a
probe: the `related_tasks` shape, `assignees` null when empty, done
tasks in the cursor, `due_date: null` stored as year 1, the missing id,
the id-keyed walk. Each one held.
## Decisions 68 and 70
- svc-$BIZ owns the bots: a runbook matter (T236), not code. The adapter
checks what follows from it, the labels at startup.
- Label-id allowlist: `tracker.labels`; any other id refuses
`label-not-allowed`.
- Poller: a full board read every tick, the cursor from the previous
tick's start minus 60 s, floored to the second, and a digest dedupe
against `task_current`.
- The broker hosts the poller and reconcile: `startBroker({tasks})` and
the `trackers` boot config.
## A defect the tests found
The first time the comment check looks at a task, it counts comments
created at or after a bound. For the reconcile that bound is the start
of the previous reconcile, to the millisecond. A comment read back from
`GET /tasks/{t}/comments` has `created` in whole seconds (only the POST
answer carries nanoseconds, see `fixtures/v2-shapes.json`). So a comment
posted after the previous reconcile, in the same second, read as older
than the bound and wasn't counted. `foreignComments` in `sync.mjs` now
floors the bound to the second. The cost is that on a first look a
comment up to a second older than the bound can be counted. After the
first look the check goes by comment id, so this doesn't repeat.
## Live run, rehearsed
`live-rehearsal.txt` (sha256
`2a79eda46af5bb20b3aa0f5e5de959320b310a299b1c157c2712df9da74c012f`) is
`live-run.txt` from the rehearsal: the real
broker, adapter and socket clients against a scratch v2.7.0 container,
the pinned digest, on 127.0.0.1 with its own data directory. Nothing
touched the estate instance or tasks.mosaicstack.dev.
`rehearse.mjs` and `rehearse-setup.json` are the scratch setup that
produced it. `rehearse.mjs` registers `svc-demo`, builds the project and
board, makes the label and four bots, mints their tokens into
environment variables only, and runs `run.mjs`. It isn't in the
candidate.
The first rehearsal failed the priority and scope steps with
`decision-required`. The pm holds both verbs cross-role, and the run
didn't raise a decision. The run now has the pm raise one and the
operator resolve it through the human CLI binding, and it checks the
refusal without one and the refusal on reuse. The second rehearsal
went as expected in every step. Its two ticks and the reconcile after
the run's own writes recorded 0 snapshots, so the dedupe works against
a real Vikunja. The log was checked for every token and for "bearer"
before it was written.
For the estate run, the operator writes a setup file with the T236
project, bot ids, label ids and token files (`live/README.md`), then
runs `run.mjs` with `--base` repeating the URL. That log goes into this
directory as `live-run.txt` in a later round.
## Gate
In the worktree at 81339889 with the patch applied, run one after
another, each to its own file:
| Suite | Result |
|---|---|
| `node --test 'packages/tasks/tests/*.test.mjs'` | 44/44 |
| `node --test 'packages/bus/tests/*.test.mjs'` | 67/67 |
| `test-auth.sh` | 15/15 |
| `test-conductor.sh` | 17/17 |
| `test-config.sh` | 24/24 |
| `test-discord.sh` | 63/64, see below |
| `test-extension-package.sh` | 18/18 |
| `test-foundation.sh` | 44/44 |
| `test-queue.sh` | 27/27 |
| `test-release.sh` | 14/14 |
| `test-task.sh` | 98/98 |
After the gate I corrected the `expires` format in `live/README.md`'s
example (the broker takes `YYYY-MM-DD`, not a timestamp). That's the
only change since, and the tasks suite passed 44/44 again on it. The
patch and manifest above are the corrected ones.
`test-discord.sh` isn't clean, and I can't show it's unrelated by more
than reasoning and reruns. Two engine timing tests failed: "when pi has
not started a timed-out turn by the end of the abort grace..." and "a
timed-out run pi did start outlives the grace...". Nothing in
`packages/discord` or `scripts/test-discord.sh` imports `packages/bus`
or `packages/tasks`. The load average was 22 to 28 from other sessions
on the host. Reruns, full suite:
| Run | With the patch | Base 81339889 |
|---|---|---|
| 1 | 63/64 | not run |
| 2 | 64/64 | 64/64 |
| 3 | 63/64 | 64/64 |
| 4 | 64/64 | 64/64 |
| 5 | 64/64 | 64/64 |
`engine.test.mjs` alone passed 6/6 in each tree. Three of five with the
patch against four of four without is a small sample. If you want it
settled, the next step is the full suite on a quiet host.
## Follow-ups, not in this row
- S1's business validator accepts a `tracker.baseUrl` with a path. The
adapter refuses it at boot with `tracker-config`, which is later than
it should be.
- `task.close` records the verdict citation and doesn't check it
against the queue.
- A coder's cross-role grant of `task.reassign` can never be used,
because the field table lets only the pm write the assignee. Either
the role file drops it or the field table changes.
- The comment-count bound above.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
base http://127.0.0.1:34571 project 2 business demo
startup ready - version v2.7.0 tested
reconcile snapshots 0 events 0
human-input recorded
step create-by-coder coder task.create refused field-writer (expected)
step create pm task.create ok vikunja:2/1
step assign pm task.assign ok vikunja:2/1
step update-by-unassigned reviewer task.update.assigned refused not-assigned (expected)
step start coder task.update.assigned ok vikunja:2/1
tick snapshots 0 events 0 (self writes only: expect 0)
step conflict pm task.priority.change refused task-conflict (expected)
step priority-undecided pm task.priority.change refused decision-required (expected)
decision task.priority.change raised and resolved
step priority pm task.priority.change ok vikunja:2/1
step priority-reused pm task.priority.change refused decision-consumed (expected)
step schedule pm task.schedule ok vikunja:2/1
decision task.scope.change raised and resolved
step scope pm task.scope.change ok vikunja:2/1
step review coder task.update.assigned ok vikunja:2/1
step reassign pm task.reassign ok vikunja:2/1
step close pm task.close ok vikunja:2/1
step after-close reviewer task.update.assigned refused task-done (expected)
tick snapshots 0 events 0 (expect 0)
reconcile snapshots 0 events 0 (expect 0)
final vikunja:2/1 source self done true
events action.refused 6
events task.assigned 2
events task.closed 1
events task.conflict 1
events task.created 1
events task.state 2
snapshots vikunja:2/1 self coder 2
snapshots vikunja:2/1 self pm 7
result all steps as expected
+220
View File
@@ -0,0 +1,220 @@
# S3 probes, addendum B section 7 (Darkwing, row 38, #1520)
Run on 2026-10-08 against a scratch Vikunja: the runbook's Path B digest
`vikunja/vikunja@sha256:e2204a1c1c6a81e833c2b3a5442be182ca2335b54c2e7e37578cc3fe12a27cfc`
(v2.7.0), sqlite, bound to `127.0.0.1:34571`, data dir under
`~/darkwing-scratch/s3/vk/`, wiped before each run. No probe touched the estate
instance or tasks.mosaicstack.dev. Users and bots were created by the probe with
random in-memory passwords. Each script refuses to write its output if any
password or token appears in it.
Scripts and raw output live in `~/darkwing-scratch/s3/probes/`:
`probe-s7.mjs` (main), `probe-s7b.mjs` (label control, comments, cursor, shapes,
rate limit), `probe-s7c.mjs` (date-filter bounds), `probe-s7d.mjs` and
`probe-s7e.mjs` (bump timing), `probe-s7f.mjs` (token revoke, decision 68). Source reads are from the v2.7.0 tag.
## L. Labels: what a bot can see
This was the critical probe. Result: with the runbook as written, a bot sees
labels from projects that aren't shared with it. If the bots get a dedicated
owner account, it sees none of them.
1. The runbook creates the bots from the owner's account. A pm bot shared only
into mosaic-stack got the full list back from `GET /labels`:
`on-ms-task`, `on-launchpad-task`, `on-personal-task`, `owner-unattached`.
A search for `launchpad` returned the same list. The bot also attached the
owner's launchpad label and the owner's unattached label to a mosaic-stack
task (201). Another user's label gave 403.
2. This is intended upstream behavior. `pkg/models/label_test.go` (#3592) says
a bot reads labels its owner created and labels sibling bots created.
3. Control: the same bot, owned by a separate account `svc-mosaic-stack` that
owns no labels, was shared into the same project with the same scopes.
- `GET /labels` showed only `owner-label-on-ms-task`, the one label attached
to a task in mosaic-stack.
- Attaching the owner's launchpad label gave 403, and so did the owner's
unattached label. The label already on an ms task gave 201.
- After the owner detached that label from every ms task, the bot saw `[]`.
- After another member attached their own label to an ms task, the bot saw
that label.
- The owner's account can't mint a token for svc's bot: `POST /tokens` gave
404/1005. Token minting has to run as the svc account.
4. A regular shared member sees their own labels plus the labels on tasks they
can read. Bots are the only case that leaks.
What I recommend for T236 and the runbook:
- Create the five `bot-$BIZ-*` accounts from a dedicated per-business owner
account. It owns no labels and no other bots, and it isn't Jason's account.
Share the bots into the project as the runbook already does.
- Mint the tokens while logged in as that account.
- In S3, the broker also accepts only label ids listed in the business file,
so a label that becomes visible later can't be attached.
## R. Revoking a bot's token (decision 68)
`probe-s7f.mjs`. `svc-mosaic-stack` creates bot `bot-ms-pm` and mints two
tokens for it, a and b. The owner shares the bot into the project. Every
account call uses a `/login` JWT, the same kind `vklogin` writes.
| Request | Result |
|---|---|
| owner `DELETE /tokens/{a}` | 403 Forbidden, token a still 200 |
| bot `DELETE /tokens/{a}` with token a | 401/11, token a still 200 |
| svc `DELETE /tokens/{a}` | 204 |
| token a straight after, and 1.5 s later | 401/11 |
| token b after a's revoke | 200 |
| svc `DELETE /tokens/{a}` again | 403 Forbidden |
| svc `GET /tokens` | `[]`, the bot's tokens aren't listed |
| svc `GET /tokens?owner_id={bot}` | `items` envelope with a and b, then only b |
| owner `GET /tokens?owner_id={bot}` | 404/1005 |
| svc mints token c, then calls with c and a | c 200, a 401/11 |
- Only the account that owns the bot can revoke its tokens, and the revoke
takes effect on the next request. Revoking one token leaves the bot's
other tokens working.
- To find an old token's id, list with `owner_id` as svc. A plain
`GET /tokens` returns only svc's own tokens.
## O. Startup probe order
The order in addendum B section 3 holds. With the minimal scopes, every probe
against a missing task gives 401/11 before any 404:
- sync `PATCH /tasks/{missing} {}`, pm `DELETE`, coder `DELETE` and coder
`POST /tasks/{missing}/labels`.
- The controls (`GET /tasks/{missing}` 404/4002, sync `GET /projects/{p}` 200)
show that the token itself works.
- An over-broad token gets 404/4002 on the same probes. That 404 is the signal
that the token has more scope than it needs.
- An over-broad sync token's `PATCH {}` on an existing task returns 304 and
writes nothing.
- An unknown token and an expired token both give 401/11.
- A mint with a past `expires_at` is accepted (201), so S3 checks expiry itself.
## U. What bumps `updated`
| Change | Bumps `updated` | How |
|---|---|---|
| PATCH of an owned field | yes | in the request |
| PATCH with the same value | no, 304 | |
| label add or remove | yes | in the request |
| comment create, edit or delete | yes | async listener |
| assignee add or remove | yes | async listener |
| relation create or delete | yes, source task only | async listener |
| move between open buckets | **no** | |
| move into done | yes, sets `done` | |
- The comment row is a correction to my first run, which recorded "comment
create does not bump". The listener is `HandleTaskUpdateLastUpdated`, which
`pkg/models/listeners.go` registers for comment, assignee, attachment and
relation events and dispatches on commit.
- Measured lag (`probe-s7e.txt`): three comments on an idle server, two by
the bot token and one by the owner. Each bump showed on the read 0 ms
after the 201, and reads at 50 ms to 4 s showed the same value.
- In the first run (`probe-s7.txt`), with other writes in flight, the read
straight after the comment showed no bump. The next read, 5 s later,
showed it. That bounds the lag under load at 5 s. It isn't a measurement
of the lag itself. The 60 s poll window is twelve times that bound.
- `updated` is reported to the second. Comment and label `created` carry
nanoseconds in the POST answer only. Read back with GET, they're whole
seconds too, and the comment check's bound is floored to match.
## Paging and the poll cursor
- `max_items_per_page` is 50, and `per_page=200` is capped at 50 on the board
and the list.
- Board paging works past 50. With 121 tasks in one bucket, pages held 50, 50
and 21, every page reported `count` 121, and all 121 ids were distinct.
- The list envelope is `{items, total, page, per_page, total_pages}`.
- Sorting by `updated,id` ascending orders correctly.
- 120 creates took 565 ms, so many tasks share one reported second.
- On this sqlite instance, a date filter can't select one reported second:
- `updated = T` returned 0, even with 13 tasks showing `updated` T.
- Those 13 matched `updated < T`, and tasks reported at T+1 s matched
`updated < T+1s`. The stored value and the reported second disagree by up
to a second.
- Windows with a margin work: `updated > T-1s && updated < T+1s` returned
all 60.
- A window plus `id > X`, sorted by id, pages correctly.
- The `.000Z`, `+00:00` and quoted forms parse. A space-separated form
gives 400.
What S3 does with this:
- The poll lower bound is the previous poll's start time minus a margin, not
the largest `updated` seen. The margin is 60 s, which covers listener lag
and rounding.
- Pages are walked with `id > last` under a fixed window.
- Snapshots dedupe overlap: a task whose fields match `task_current` produces
no new row.
- Moves between open buckets don't bump `updated`, so the board read stays in
every cycle. The reconcile's `comment_count` covers comments.
## D. Removing what isn't there
| Request | Result |
|---|---|
| unassign a user who isn't assigned | 204 (silent) |
| remove a label that isn't on the task | 403 |
| remove a relation that doesn't exist | 404/4009 |
| assign a user who is already assigned | 400/4021 |
| add a label that is already on the task | 400/8001 |
Because unassign is silent, S3 compares before every unassign and records an
absent target as a no-op, not as a success.
## Writes and moves
- `PATCH bucket_id` alone returns 200 and echoes the new `bucket_id`, but the
board bucket doesn't change. S3 refuses `bucket_id` in PATCH (decision 51),
and the fake rejects it too.
- `PATCH` with an unknown key gives 422.
- `PUT …/buckets/{b}/tasks {task_id}` into the done bucket sets `done:true`.
- On sqlite, a write right after an assignee change got
`500 database is locked`: the listener held the write lock. S3 treats any 5xx
or network error on a write as an uncertain outcome. It re-reads and compares,
and it never retries under the same approval (bus README rule).
## M. Tasks that leave the project
| Case | sync bot `GET /tasks/{t}` | Snapshot |
|---|---|---|
| moved to a project shared with the sync bot | 200, new `project_id` | `moved` with `project` |
| moved to a project not shared with it | 403, no code | `no-access` |
| deleted | 404/4002 | `not-found` |
## Other
- `GET /tasks/{t}` returns an ETag, and `If-None-Match` gives 304.
- Rate limit: tested with it enabled at 8 per 60 s and the default
`kind: user`.
- Bot A got 200 eight times (`x-ratelimit-remaining` 7 down to 0), then 429.
- Bot B, from the same IP straight after, got 200 with 7 remaining.
- The limit is per account, so one busy role can't starve the others.
- The source agrees: `pkg/routes/rate_limit.go` keys by `user_<id>` and
falls back to the IP for unauthenticated requests.
- Install: a new kanban view starts with manual buckets. Renaming the three
defaults with PUT and adding `in-review` and `blocked` with POST keeps
`done_bucket_id` and `default_bucket_id` pointing at the right buckets.
## G. Reads S3 relies on (probe-s7g)
Same scratch container and digest, four tasks in one project. These were
assumptions in the code until this run; each one held.
- `related_tasks` is an object keyed by kind. Each value is an array of full
task objects. After `t2 blocked t1`, t2 shows `{"blocked":[t1]}` and Vikunja
adds `{"blocking":[t2]}` to t1 on its own. The task read, the project list
and the board all carry it.
- After an assign, the task read and the board both return `assignees` as an
array of user objects. With no assignee the field is `null`, not `[]`.
- A user object for a person leaves out `bot_owner_id`. A bot's has it.
- The project list with no `done` filter returns done tasks. So does the
cursor filter `updated > … && id > 0`. A close made in the UI is therefore
seen by the cursor, not only by the board diff.
- `PATCH due_date: null` returns 200 and stores `0001-01-01T00:00:00Z`. S3
reads that value as no due date.
- `GET /tasks/2147483647` and `/tasks/2147483648` both give 404/4002. The
startup probe uses the first as its missing id.
- The id-keyed walk (`id > last`, `sort_by id`, `per_page 2`) over four tasks
returned `[1,2,3,4]` in three pages, the last one empty.
The three reads with a relation are in `tests/fixtures/v2-shapes.json` as the
`related` entries, and the shapes test compares the fake against them.
@@ -0,0 +1,30 @@
{
"business": "demo",
"baseUrl": "http://127.0.0.1:34571",
"project": 2,
"roles": {
"pm": {
"botId": 3,
"env": "VK_LIVE_PM",
"expires": "2029-12-31"
},
"coder": {
"botId": 4,
"env": "VK_LIVE_CODER",
"expires": "2029-12-31"
},
"reviewer": {
"botId": 5,
"env": "VK_LIVE_REVIEWER",
"expires": "2029-12-31"
}
},
"labels": {
"slice-1": 1
},
"sync": {
"botId": 2,
"env": "VK_LIVE_SYNC",
"expires": "2029-12-31"
}
}
@@ -0,0 +1,52 @@
// Darkwing, S3: rehearse packages/tasks/live/run.mjs on the scratch container. 127.0.0.1 only.
// Builds the runbook's install (svc owner, bots it owns, a label it owns, scoped tokens), then runs
// the live script with the tokens in its environment. Passwords and tokens stay in memory.
import { randomBytes } from "node:crypto";
import { writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
const [ORIGIN, RUN, OUT] = process.argv.slice(2);
if (!/^http:\/\/127\.0\.0\.1:\d+$/.test(ORIGIN)) throw new Error("scratch origin on 127.0.0.1 only");
const BASE = ORIGIN + "/api/v2";
const secrets = [];
async function api(method, path, body, auth) {
const headers = { "Content-Type": "application/json" };
if (auth) headers.Authorization = `Bearer ${auth}`;
const r = await fetch(BASE + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
return { status: r.status, json };
}
const must = (r, l) => { if (r.status >= 300) throw new Error(`${l}: ${r.status} ${r.json?.code ?? ""}`); return r.json; };
const password = randomBytes(18).toString("base64url"); secrets.push(password);
must(await api("POST", "/register", { username: "svc-demo", email: "[email protected]", password }), "register");
const O = must(await api("POST", "/login", { username: "svc-demo", password }), "login").token; secrets.push(O);
const P = must(await api("POST", "/projects", { title: "demo" }, O), "project").id;
const K = must(await api("GET", `/projects/${P}/views`, undefined, O), "views").items.find((v) => v.view_kind === "kanban").id;
const rename = { "To-Do": "todo", Doing: "in-progress", Done: "done" };
for (const b of must(await api("GET", `/projects/${P}/views/${K}/buckets`, undefined, O), "buckets").items)
must(await api("PUT", `/projects/${P}/views/${K}/buckets/${b.id}`, { title: rename[b.title] }, O), "rename");
for (const title of ["in-review", "blocked"]) must(await api("POST", `/projects/${P}/views/${K}/buckets`, { title }, O), title);
const label = must(await api("POST", "/labels", { title: "slice-1" }, O), "label").id;
const SCOPES = {
sync: { projects: ["read_one", "views_buckets", "views_buckets_tasks_get"], projects_views: ["read_all"], tasks: ["read_all", "read_one"], tasks_comments: ["read_all"] },
pm: { tasks: ["read_one", "create", "update"], tasks_assignees: ["create", "delete"], tasks_relations: ["create", "delete"], tasks_labels: ["create", "delete"], tasks_comments: ["create"], labels: ["read_all"], projects: ["views_buckets_tasks"] },
worker: { tasks: ["read_one", "update"], tasks_comments: ["create"], projects: ["views_buckets_tasks"] },
};
const env = { ...process.env };
const setup = { business: "demo", baseUrl: ORIGIN, project: P, roles: {}, labels: { "slice-1": label } };
for (const r of ["sync", "pm", "coder", "reviewer"]) {
const b = must(await api("POST", "/user/bots", { username: `bot-demo-${r}`, name: `demo ${r}` }, O), `bot ${r}`);
must(await api("POST", `/projects/${P}/users`, { username: b.username, permission: r === "sync" ? 0 : 1 }, O), `share ${r}`);
const t = await api("POST", "/tokens", { title: `s3-live-${r}`, owner_id: b.id, expires_at: "2030-01-01T00:00:00Z", permissions: SCOPES[r] ?? SCOPES.worker }, O);
if (t.status !== 201) throw new Error(`mint ${r} ${t.status}`);
secrets.push(t.json.token);
env["VK_LIVE_" + r.toUpperCase()] = t.json.token;
const entry = { botId: b.id, env: "VK_LIVE_" + r.toUpperCase(), expires: "2029-12-31" };
if (r === "sync") setup.sync = entry; else setup.roles[r] = entry;
}
writeFileSync(`${OUT}/setup.json`, JSON.stringify(setup, null, 1) + "\n", { mode: 0o600 });
const run = spawnSync(process.execPath, [RUN, `${OUT}/setup.json`, OUT, "--base", ORIGIN], { env, encoding: "utf8" });
const all = (run.stdout ?? "") + (run.stderr ?? "");
if (secrets.some((s) => all.includes(s))) { console.error("secret in the run output; not shown"); process.exit(3); }
process.stdout.write(all);
console.log("exit", run.status);
process.exit(run.status ?? 1);