Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ jobs:
- name: Test
run: npm test

# Its own step, deliberately, and after the unit tests. A red vocabulary means "go
# edit src/vocabulary.ts" — a different instruction from any unit failure — and
# folding the two into one command lets either hide the other. It reaches the
# network on purpose and has no skip path; see src/gates/vocabulary-parity.ts.
#
# Do NOT set TASK_QUEUE_MCP_REF here. It defaults to task-queue-mcp's `main`, which
# is the whole point: pinning it to anything else asserts parity with something
# nobody is running.
- name: Vocabulary parity with task-queue-mcp main
run: npm run gate:vocabulary

# Production dependencies only. The dev tree carries advisories that do not
# apply to a plugin bundled at build time and never served by a dev server.
- name: Audit production dependencies
Expand Down
25 changes: 23 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ src/
launchLogName, fenced-block extraction, run timestamps.
launch-policy.ts Reads and validates the shared launch roster, and builds
the spawn argv. Same extraction reason.
vocabulary.ts The task-queue vocabulary (statuses, task types, workflow
modes) plus the UI maps keyed BY it. One copy, gated
against task-queue-mcp's main. See the invariant below.
types.ts Shared types. The Task shape mirrors task-queue-mcp's
YAML schema — keep them in step.
panels/
Expand All @@ -42,13 +45,29 @@ src/
dead-letters.ts Collapsed dead-letter section, grouped by failure reason
styles.ts Theme colours (read live from CloudCLI CSS vars), helpers
ws-client.ts Reconnecting WebSocket client
gates/
python-sets.ts Extracts `NAME = {"a","b"}` literals from Python source,
without executing any. Split out of the gate script so it
can be tested; that script exits at module scope.
vocabulary-parity.ts CI gate: fetches task-queue-mcp `main` and asserts the
vocabulary matches. Own npm script, own CI step.
tests/ node --test
```

**Relative imports use `.ts`, not `.js`.** `npm test` runs the sources directly under Node's
type stripping, which does not rewrite a `.js` specifier to the `.ts` file beside it — so a
module reached by a test through a `.js` import fails with `ERR_MODULE_NOT_FOUND`. The panels
all used `.js` and were simply never imported by a test; the first one that was, was not
importable. esbuild and `tsc` (`allowImportingTsExtensions`) both accept `.ts`.

## Invariants

- **The plugin never writes queue YAML directly.** Reads are direct (fast, watchable); every mutation goes through `control-api.ts` to the MCP control API, inheriting its transition validation, `fcntl` locking, and atomic writes. A new mutation means a new control-API action, never an `fs.writeFile`.
- **`ControlAction` must match the MCP's route set.** The union type in `control-api.ts`, the route regex in `server.ts`, and the MCP's custom routes are three copies of one contract. Change one, change all three. The two copies that live in *this* repo are now pinned to each other by a source-level test in `control-api.test.ts` — nothing detected the drift before, because adding an action to the union alone compiles and the failure mode is a button that 404s against the plugin's own backend. The third copy is in another repo and still needs a human.
- **The task-queue vocabulary lives in `vocabulary.ts`, once, and is gated against its owner.** This plugin does not own the queue's statuses, task types, or workflow modes — `task-queue-mcp`'s `src/tools/queue.py` does. It used to carry four partial hand-written copies (`STATUS_ORDER`, `NON_TERMINAL_STATUSES`, `DETAIL_NON_TERMINAL_STATUSES`, the `statusColor` switch); none of them learned about `routing-failed`, so for months the status most in need of an operator sorted *below* `cancelled`, rendered the same grey as `parked`, and was not offered by the status filter. `manual-then-auto` was the same omission one field over. Two mechanisms hold the line and they are different in kind: `npm run gate:vocabulary` fetches the MCP's `main` and fails on any difference, which catches "upstream changed and we did not"; and the UI maps are `Record<Status, …>` keyed by the vocabulary itself, so adding a status without giving it a sort position and a colour is a `tsc` error rather than a silent fallthrough to `?? 9` and `muted`. Do not weaken either — a `Record<string, …>` accepts anything and covers nothing, which is precisely how this happened.
- **The vocabulary gate has no skip-on-no-network path, and tracks `main`, not a pin.** It exits non-zero if it cannot read the upstream. A check that quietly passes when it could not reach its source of truth is indistinguishable from one that verified something, and that shape is how the dispatcher-side instance (vikunja#324) stayed open for months. It likewise exits non-zero if it parses zero set literals, rather than reporting a vacuous pass over an empty comparison. A vocabulary change merged upstream turning this repo's CI red is the alarm working. `TASK_QUEUE_MCP_REF` exists only for a paired pre-merge change from a shell; **CI must not set it**.
- **The gate is its own CI step.** A red vocabulary means "go edit `src/vocabulary.ts`" — a different instruction from any unit failure. Folded into `npm test` either could hide the other, and an expected-red check buried inside a longer step masks whether anything before it ran.
- **A queued `manual-then-auto` survives the Start button.** `toWorkflowMode` maps the UI's `review` to `semi-auto` *unless* the task was queued `manual-then-auto`, in which case it passes that through. Both gate this leg; only the second lets the tasks the session spawns run `auto`. Flattening it — which is what this did before #543 — re-pins the whole downstream chain to `semi-auto`, which is the failure vikunja#533 added the mode to fix. An explicit `auto` Start still overrides.
- **The plugin acts as `operator`, never as an agent.** Every proxied mutation sends `actor: 'operator'`. This is what makes the MCP's `amend_task` authorization accept it; the plugin must never assert an agent's identity.
- **Mutations fail closed without the secret.** `callControlApi` returns 500 and never attempts the fetch when `TASK_QUEUE_API_SECRET` is empty, and logs why. Do not add a fallback that proceeds without it.
- **The version comes from `package.json`.** `server.ts` reads it at startup rather than hardcoding a copy — a hardcoded constant silently drifted and reported a stale version on `/health` for two releases. `package.json` and `manifest.json` must also agree.
Expand All @@ -72,14 +91,16 @@ src/

```bash
npm install
npm run build # tsc --noEmit is the typecheck gate; esbuild bundles after it passes
npm run build # tsc --noEmit is the typecheck gate; esbuild bundles after it passes
npm test
npm run gate:vocabulary # parity with task-queue-mcp main — needs network, by design
```

Tests cover `control-api.ts` (the secret gate, task-id validation, header and body shape per action, transport-failure mapping, pass-through of the MCP's authorization rejections, and the union/route-regex drift gate), `dead-letters.ts` (the real dispatcher record shape including the `Date`-valued `created` js-yaml hands back, the id-less and reason-less fallbacks, and the grouping rule against the live seventeen-identical-reasons case), `ws-guard.ts` (all three upgrade cases, including the loopback-with-no-Origin one that v0.4.0 broke), `launch-policy.ts` (every closed-set rejection, whole-document rejection, and both argv shapes), `path-guard.ts` (a **real** symlink escape, traversal, the `/comms-other` sibling case — with real files in a tmpdir, because a mocked `fs` cannot demonstrate that realpath runs first), `launch-log.ts` (round-trip against the real `launchLogName`, the live bare-UUID orphans, path-shaped route ids, fence extraction including the dropped unterminated case, and the birthtime-after-mtime fallback), and the reconnect schedule. The UI panels are not unit-tested — verify them in CloudCLI after `./deploy.sh && pm2 restart cloudcli`.
Tests cover `control-api.ts` (the secret gate, task-id validation, header and body shape per action, transport-failure mapping, pass-through of the MCP's authorization rejections, and the union/route-regex drift gate), `dead-letters.ts` (the real dispatcher record shape including the `Date`-valued `created` js-yaml hands back, the id-less and reason-less fallbacks, and the grouping rule against the live seventeen-identical-reasons case), `ws-guard.ts` (all three upgrade cases, including the loopback-with-no-Origin one that v0.4.0 broke), `launch-policy.ts` (every closed-set rejection, whole-document rejection, and both argv shapes), `path-guard.ts` (a **real** symlink escape, traversal, the `/comms-other` sibling case — with real files in a tmpdir, because a mocked `fs` cannot demonstrate that realpath runs first), `launch-log.ts` (round-trip against the real `launchLogName`, the live bare-UUID orphans, path-shaped route ids, fence extraction including the dropped unterminated case, and the birthtime-after-mtime fallback), `vocabulary.ts` (every status has a sort position and a colour, `routing-failed` sorts above `in-progress` and is not muted, the derived non-terminal set, and the `manual-then-auto` pass-through through `buildLaunchArgv`), `gates/python-sets.ts` (the real `queue.py` shape including interleaved comments, and the two constructs that must NOT parse as string sets — a derived set and an annotated dict), and the reconnect schedule. The UI panels are not otherwise unit-tested — verify them in CloudCLI after `./deploy.sh && pm2 restart cloudcli`.

Two build/test gotchas worth knowing before you touch either script:

- **`npm run gate:vocabulary` reaches the network and has no offline mode.** It is not part of `npm test` — run it separately, and expect it to fail on a disconnected machine. That is the design, not a bug; see the invariant above.
- **`npm test` needs Node 22.18+.** `node --test` runs the `.ts` test files directly, relying on Node's built-in type stripping. On Node 20 every test fails with `ERR_UNKNOWN_FILE_EXTENSION`. The plugin's *runtime* requirement is still Node 20+ — `dist/` is bundled plain JS — so the README's stated minimum and CI's Node version differ on purpose.
- **`npm run build` invokes `tsc`/`esbuild` from `node_modules/.bin`, not via bare `npx`.** `npx tsc` silently downloads an unrelated registry package named `tsc` when devDependencies are missing, replacing the typecheck gate with a stranger's binary. Do not "simplify" it back to `npx`.

Expand Down
74 changes: 74 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,80 @@

All notable changes to this project will be documented in this file.

## [0.8.0] - 2026-08-29

Closes the plugin's task-queue vocabulary drift, then gates it so it cannot silently
reopen. Build `agent-workflow-interop-2026-08` Phase 2; vikunja#558, folding in #543.

### The drift

`task-queue-mcp` made `routing-failed` a first-class non-terminal status and counts it in
`active` on `GET /queue/summary`. The plugin carried four partial hand-written copies of a
vocabulary it does not own, and none of them were updated:

| Site | Effect |
|---|---|
| `panels/task-list.ts` `STATUS_ORDER` | no entry, fell through `?? 9` — a retrying, failing task sorted **below `cancelled`**, dead last |
| `panels/task-list.ts` `NON_TERMINAL_STATUSES` | the status filter never offered the value, and the status-change control could not move a task into it |
| `panels/task-detail.ts` `DETAIL_NON_TERMINAL_STATUSES` | second copy of the same list, stale the same way |
| `panels/styles.ts` `statusColor` | no `case`, so `default: muted` — identical to `parked` and `cancelled`, i.e. "not urgent" |

The status most in need of an operator was the one that sorted last and looked least
urgent. `manual-then-auto` (#543) was the same omission one field over.

### Fixed

- **`routing-failed` is a known status everywhere.** It sorts **above `in-progress`** —
first in its group — renders in the `error` colour rather than `muted`, is offered by the
status filter, and is a valid destination in the operator status-change control. Setting
it by hand is not a dead end: the dispatcher's routing-failed pass picks up any such task
whose retry window has passed, and a record with no `next_retry_at` is eligible
immediately, so it reads as "re-route now, skipping re-approval".
- **A `routing-failed` task explains itself.** The detail view gains a banner naming what
the dispatcher is doing, how many retries are used, when the next attempt is due, and that
the end of the budget is a dead letter. The status was previously a bare word.
- **An unreadable `next_retry_at` says so.** The `routing-failed` banner interpolated
`new Date(...).toLocaleString()` directly, so a malformed value in the queue YAML rendered
the literal string `Invalid Date` at the operator. Same failure as the launch-log
`birthtime` case: a date the code could not read, presented as though it could. Caught by
this build's own pre-audit baseline, not by the audit.
- **A queued `manual-then-auto` survives the Start button.** `toWorkflowMode` collapsed it
to `semi-auto`, which re-pins every task the session spawns back to `semi-auto` — the
exact failure vikunja#533 added the mode to fix (four security→steward return tasks sat
unactioned for over a week). `review` on such a task now passes `manual-then-auto`
through; an explicit `auto` Start still overrides. The launch note says which it did.

### Added

- **`src/vocabulary.ts`** — one copy of the queue's statuses, task types and workflow modes,
plus the UI maps keyed *by* it. `STATUS_ORDER` and `STATUS_COLOR` are `Record<Status, …>`,
so adding a status without a sort position and a colour is now a `tsc` error rather than a
silent fallthrough. That is the half the old code lacked: `Record<string, number>` accepts
anything and covers nothing.
- **`npm run gate:vocabulary`** — fetches `task-queue-mcp`'s `main` and asserts the sets
match, as its own CI step. Ported from `task-dispatcher`'s `test_task_queue_vocabulary.py`
(vikunja#324) with both of the properties that make that one work: **no
skip-on-no-network path** — it exits non-zero if it cannot read the upstream, because a
check that quietly passes when it could not reach its source of truth is
indistinguishable from one that verified something — and it tracks `main`, not a pin, so a
vocabulary change merged upstream turns this repo red. That is the alarm, not a
malfunction. It also fails rather than reporting a vacuous pass if it parses zero literals.
Proven to fire before shipping, in both directions and on all four outcomes.
- **A Mode filter and a mode badge.** The list can be filtered by `workflow_mode` and the
detail view names it with what it means; rows badge `auto` and `manual-then-auto` and stay
quiet about `semi-auto`, which is 98% of the queue. A task with no recorded mode renders as
unknown, not as the queue's default — older records predate the field and inventing a value
the queue never wrote is how this class of bug starts.

### Changed

- The two `NON_TERMINAL_STATUSES` literals are one derived constant
(`VALID_STATUSES - TERMINAL_STATUSES`, as upstream derives it). Two copies of one list is
how the second went stale.
- Relative imports now use `.ts`, not `.js`. `node --test` runs the sources directly and
does not rewrite a `.js` specifier to the `.ts` file beside it, so the panels — which all
used `.js` — were not importable by a test. The first one that needed to be, was not.

## [0.7.0] - 2026-08-29

Adds a collapsed **Dead letters** section below the task list, and a Requeue button.
Expand Down
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,10 +227,14 @@ A task whose `target_agent` is absent from the file returns a clean `Unknown age
rather than launching.

> **Mode vocabulary.** Start sends `review | auto`; a launcher taking `--workflow-mode`
> receives `semi-auto | auto`, mapped explicitly. For a run-as agent, `review` is
> **prompt-enforced only** — the reference launcher sets `--dangerously-skip-permissions`
> itself and accepts no permission mode, so `--permission-mode plan` is not reachable. The
> UI says so on launch rather than implying a tool gate.
> receives `semi-auto | auto | manual-then-auto`, mapped explicitly. `auto` passes through.
> `review` becomes `semi-auto`, **except** for a task queued as `manual-then-auto`, which is
> passed through unchanged: both gate this leg, but only `manual-then-auto` lets the tasks
> that session spawns run unattended, and flattening it re-pins the whole chain to
> `semi-auto`. For a run-as agent, `review` is **prompt-enforced only** — the reference
> launcher sets `--dangerously-skip-permissions` itself and accepts no permission mode, so
> `--permission-mode plan` is not reachable. The UI says so on launch rather than implying a
> tool gate.

### Launch logs

Expand All @@ -243,6 +247,9 @@ same shape and directory the reference dispatcher writes, so both are listable t
npm install
npm run build # tsc --noEmit (typecheck) + esbuild bundle to dist/
npm test # node --test — requires Node 22.18+
npm run gate:vocabulary # asserts the queue vocabulary matches task-queue-mcp's main.
# Reaches the network and fails if it cannot — deliberately;
# a parity check that skips offline has verified nothing.
```

`npm run build` is the typecheck gate — `tsc --noEmit` runs first and the bundle only happens if it passes.
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "task-queue",
"displayName": "Task Queue",
"version": "0.7.0",
"version": "0.8.0",
"description": "Task queue dashboard — view, filter, and launch agent tasks.",
"author": "TadMSTR",
"icon": "icon.svg",
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
{
"name": "cloudcli-plugin-task-queue",
"version": "0.7.0",
"version": "0.8.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc --noEmit && esbuild src/index.ts --bundle --format=esm --outfile=dist/index.js --sourcemap && esbuild src/server.ts --bundle --format=esm --platform=node --outfile=dist/server.js --sourcemap --external:ws",
"test": "node --test src/tests/*.test.ts"
"test": "node --test src/tests/*.test.ts",
"gate:vocabulary": "node src/gates/vocabulary-parity.ts"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
Expand Down
2 changes: 1 addition & 1 deletion src/dead-letters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
// without a DOM, and so the reason-grouping rule — the thing that turns seventeen rows
// into one problem — is a pure function with tests rather than a loop inside a renderer.

import type { DeadLetter, DeadLetterGroup } from './types.js';
import type { DeadLetter, DeadLetterGroup } from './types.ts';

/** The `failed_reason` shape task-dispatcher writes. Every field is optional in practice. */
interface RawFailedReason {
Expand Down
Loading
Loading