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