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:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user