Files
stack/docs/plans/2026-10-12_console-pinned-copy.md
T

7.3 KiB

Console runs from a pinned copy (2026-10-12)

Status: written by Sage, lead. It follows docs/plans/BRIEF-TEMPLATE.md and replaces row 77's brief in docs/plans/2026-10-11_console-service-followups.md. Mos asked for one row that ends in one reinstall (sage-0089): Vikunja #146 (a pinned copy with a deliberate update step), #147 (the uid from lead decision 101) and #155 (row 77's brief). Lead decision 112 records the choices below.

Console pinned copy, update step and service hardening

Problem

The Console unit runs node packages/webui/src/cli.mjs serve straight from the canonical checkout: scripts/webui-service.sh:35 sets REPO from the script's own location, and the template puts it in WorkingDirectory and ExecStart (packages/webui/systemd/mosaic-webui.service.in:19,24). Every landing that touches the code the Console loads is a live change the next time Node reads a file or the unit restarts (lead decision 109). The Console needs to run from a copy fixed at one landed commit, changed only by a step someone takes on purpose.

Two things in the code shape the fix:

  • The queue view runs packages/webui/src/queue-read.mjs as a child with its cwd at REPO (packages/webui/src/reads.mjs:62-66). The queue store refuses code outside the canonical root (packages/queue/src/store.mjs:85) and refuses linked worktrees (store.mjs:70). So a copy can't read the queue by itself, and a worktree can't serve as the copy.
  • The rest of the Console loads relative to its own files: the bus child (packages/webui/src/bus.mjs:31), the config check (packages/cli/src/config.mjs:15,22), the roles directory (config.mjs:36) and RELEASE (reads.mjs:198). Those can all come from the copy.

Row 75's review left five gaps (Filbert, comment 27430 on #1569), N1 to N5:

  • N1: install doesn't refuse while the unit is active, so the unit's next Restart=on-failure runs the new ExecStart without anyone starting it.
  • N2: an install from a worktree points the unit at the worktree, and no doc says to install from the canonical checkout.
  • N3: uninstall --no-reload skips the active check while the docs say uninstall always refuses while active.
  • N4: if systemctl --user list-units fails, the script exits 1 with no message.
  • N5: the --allow-user value has no check that it starts with a letter or digit.

Decision 101 switches the unit from X-authentik-username to X-authentik-uid once the uid is known, and only for a value matching ^[0-9a-f]{64}$. Today nothing in the script holds that rule. #147's title says "a dedicated uid"; decision 101 means the Authentik header, not a separate Unix user (decision 112).

Owner and reviewer

Owner: Rocko. Reviewers: Darkwing and Filbert. Rocko has no Gitea token; Sage runs the review request as jarvis.

Files owned

  • scripts/webui-service.sh
  • packages/webui/systemd/mosaic-webui.service.in
  • packages/webui/src/cli.mjs, the new --checkout flag and passing it to consoleReads
  • packages/webui/src/reads.mjs, only so the queue reader takes its own root
  • packages/webui/tests/service.test.mjs and new tests in packages/webui/tests/
  • packages/webui/README.md, the service section
  • docs/TOOLS.md, the script's entry only
  • agents/rocko/work/queue-77/

What ships

The copy:

  • A new verb, scripts/webui-service.sh copy COMMIT [--copies DIR]. It runs only from the canonical checkout (the repository root has a .git directory, not a worktree's .git file). It refuses a COMMIT that isn't reachable from refs/heads/refactor. It resolves COMMIT to the full 40-character id.
  • It writes git archive of the paths the Console loads into DIR/<full id>/, by default ${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-console/<full id>/. The path list lives in the script with a comment. It writes into a temp directory beside the target and renames it, then removes write permission from every file in the copy. A copy that already exists is left as it is, and the verb prints exists: and its path.
  • A test makes a copy from a fixture repository, starts cli.mjs serve from the copy with a stand-in socket, and gets /healthz 200. So a path missing from the list fails the suite.

The unit:

  • install and render take --copy ID, which names an existing copy by its full id. install refuses without it. The template's @REPO@ becomes the copy's path, so WorkingDirectory and ExecStart point at the copy.
  • serve takes --checkout PATH, the canonical checkout, and the unit passes the checkout the script runs from. The queue read runs the checkout's queue-read.mjs with its cwd there, as today. RELEASE, the roles directory, the config check and the bus child come from the copy. Without --checkout, serve behaves as it does now. A test shows each of the two roots in use.
  • status prints the copy id the unit runs, read from its WorkingDirectory, and whether refactor in the checkout points at another commit. It never changes anything.

The update step, documented in TOOLS.md and the README: copy, then stop, install --copy ID with the same flags, then start. Rollback is the same steps with the earlier id; copies are kept until someone removes them. The stop and start stay Jason's or Mos's (decisions 94, 102 and 103).

N1 to N5:

  • N1: install refuses while the unit is active, activating or deactivating, and its message says Jason or Mos stops it first. A test uses a stand-in systemctl.
  • N2: install and copy refuse when the script isn't in the canonical checkout. --allow-worktree overrides the refusal for tests only, and the help text says so.
  • N3: uninstall checks for an active unit even with --no-reload, or the docs say exactly when it doesn't. Rocko picks one and a test shows it.
  • N4: a failed state read prints one line to stderr naming the failed systemctl call.
  • N5: --allow-user and the other values must start with a letter or digit. Tests cover the two leading-character checks that row 75's mutants left untested.

The uid rule from decision 101:

  • When --auth-header is X-authentik-uid in any letter case, --allow-user must match ^[0-9a-f]{64}$, and render and install refuse anything else with a message naming the rule. Tests cover a good value, 63 and 65 characters, upper-case hex and a username.

The template's header comment (lines 6-9) still says only Jason starts the unit; it gets decision 102's wording, Jason or Mos (Vikunja #159 item 1).

Out of scope

Starting, stopping, enabling or restarting the unit. Reading the uid itself: ops-01 reads it without writing anything (decision 112), and the reinstall that uses it is Mos's. Pruning old copies. A health-gated activate and rollback like scripts/release.sh, which is D1 for the Console as a whole. Any change to packages/queue. The bus service script.

Gate

Darkwing and Filbert approve on #1571, each naming the candidate manifest. The webui node suite and every scripts/test-*.sh pass on Sage's gate rerun. Before the landing Sage notifies Mos (decision 109); after it, Sage sends Mos the commit, scripts/webui-service.sh status and /healthz. The live unit changes only at Mos's one reinstall: copy at the landed commit, stop, install --copy ID with the uid header and value, start, then status, /healthz and the anonymous 302.