Skip to content

Un dessin Excalidraw est une entrée partagée par élément, écrite par un reducer - #61

Open
MChrys wants to merge 163 commits into
mainfrom
tac-216-excalidraw-substrate
Open

Un dessin Excalidraw est une entrée partagée par élément, écrite par un reducer#61
MChrys wants to merge 163 commits into
mainfrom
tac-216-excalidraw-substrate

Conversation

@MChrys

@MChrys MChrys commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Deux personnes qui dessinaient dans le même dessin s'écrasaient. Toute la scène tenait dans une seule entrée de shared map — le tableau d'éléments plus un SVG sérialisé — donc chaque trait réécrivait le tout, et la seule réconciliation était un test fromUser qui remplaçait la scène entière quand il différait. Rien ne pouvait arbitrer : le module n'avait aucun reducer backend, c'était le seul à écrire dans Yjs directement depuis le navigateur.

Ce qui change

  • Une entrée par élément, clé <drawingId>::<elementId>, pour que la concurrence par clé de la shared map fasse le travail : deux personnes sur deux éléments ne touchent jamais la même clé.
  • Un ExcalidrawReducer backend et des événements excalidraw:*, comme tous les autres modules. Plus aucune écriture directe depuis le frontend.
  • Réconciliation par reconcileElements d'Excalidraw, sur version/versionNonce, au lieu de remplacer la scène.
  • Le SVG stocké disparaît : un document sérialisé poussé dans Yjs à chaque frappe pour quelque chose que chaque pair sait dessiner. Il est dérivé localement.
  • Migration sur project:init des dessins écrits dans l'ancien format. Idempotente par construction plutôt que par marqueur de version : chaque dessin est supprimé au moment où il est déplacé, et les clés dérivent des données, donc deux exécutions concurrentes écrivent les mêmes clés avec les mêmes valeurs.

Le piège de l'ordre

L'ordre d'empilement vivait dans le tableau. Une map keyée n'en a pas — il est porté par l'index fractionnaire d'Excalidraw. Un dessin écrit avant l'existence de ce champ serait revenu dans l'ordre arbitraire de la map, silencieusement restacké. withStackingIndex synthétise les index pour un dessin entier d'un coup quand il en manque, jamais élément par élément : réels et synthétiques se comparent selon l'alphabet d'Excalidraw, donc les deux ne doivent pas s'entrelacer.

Vérification

28 tests, dont un contre un vrai document Yjs : deux documents qui n'échangent que des updates, chacun modifiant un élément différent hors ligne, convergent avec les deux modifications. C'est exactement le bug corrigé — et ce test ne pouvait pas s'écrire contre l'ancien modèle.

Non fait : la recette sur un environnement réel. La VM de dev n'existe plus dans ce workspace, donc ça reste à faire avant de considérer TAC-215 comme clos.

Au passage

L'arbre de calques keye ses lignes sur l'id de l'élément et non sur sa position dans le tableau — c'était faux dès qu'on supprimait quelque chose. Et l'observer de shared data est enfin libéré au démontage : chaque montage en laissait un derrière, à écrire dans une scène disparue.

Refs TAC-214, TAC-215

🤖 Generated with Claude Code


Open in Devin Review

MChrys and others added 30 commits August 5, 2026 15:14
TAC-175 in one line: `https://ganymede.apollo.test:8443/` was the only
thing between the macOS platform and one that responds. CoreDNS, the
certificate and nginx were already in place and verified — what was
missing was the service behind them.

    https://apollo.test:8443/                           200  ssl_verify 0
    https://ganymede.apollo.test:8443/oauth/public-key   200
    an internal route that queries Postgres              200
    POST /signup                                         200

That last one wrote a user and its auto-created organization into
Postgres, through nginx, a published port, an Apple container, and a
second Apple container holding the database. Which is the ticket's own
definition of finished.

Three things this needed, and each was found by it not working:

**`--publish` is silently ignored on a named network.** The same image
published on `default` answers 200; on a named network the port binds on
the host and every request times out. nginx reaches Ganymede over that
port, so Ganymede has to be on the default network. Both services are
there now — the isolation that matters is untouched, because a *user*
container still gets a private network of its own from the broker, and
neither of these binds anywhere but the loopback.

**A named network stopped carrying traffic mid-session.** No route on the
host, no reachability between two containers that each held an address on
it. Not worth debugging vmnet for a dev harness; the default network has
no such trouble.

**A cookie domain may not carry a port.** GANYMEDE_FQDN has to — every URL
built from it is a link somebody follows, and nginx listens on 8443
because binding under 1024 needs root — but `ganymede.apollo.test:8443`
is not a domain and a browser rejects it outright. The session cookie
takes the host without the port. One line, in the one place that needs it.

The script now follows the other macOS scripts: ENV_NAME, DOMAIN,
HTTPS_PORT, GANYMEDE_PORT, and state under ~/.holistix-macos/<env>/
rather than a directory of its own.

Refs TAC-175, TAC-174, TAC-129.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
From Devin's review of #57, and the first one is a real defect.

**`down` destroyed every environment's data.** The app container and the
database name became environment-scoped in this branch; the Postgres
container name did not. So one container holds every environment's
database, and `down` force-deleted it — with no volume behind it, so the
data went too. A second environment on the same machine would have lost
everything because somebody stopped the first.

`down` now removes only this environment's Ganymede and says what it
left running. Two commands carry the rest, and neither is reachable by
accident: `drop-db` for this environment's database, `down-all` for
Postgres and everyone's data, which says so in its own output.

Four smaller ones, all correct:

- The header still claimed Postgres never leaves a private network, and
  the run still carried a comment about needing two of them. Both were
  true of the version this branch replaced. A comment that describes the
  previous design is worse than none — it is read as current.
- `GANYMEDE_APPLE_DB_PASSWORD` was renamed without a fallback. Anyone who
  had set it would have got a different password and an authentication
  failure with no obvious cause. The old name is honoured.
- French had crept into the status output of an English script.

The sixth was a confirmation rather than a defect: stripping the port
from the cookie domain is safe for existing deployments, because a
deployment without a port in GANYMEDE_FQDN is unaffected by the split.

Verified: `down` leaves Postgres running, `up` brings the environment
back, and `https://ganymede.apollo.test:8443/` still answers 200.

Refs TAC-175.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… a hyphen

Three defects the review found, each of which only appears on the second use
of this script rather than the first.

A hyphen in the environment name produced an invalid SQL identifier:
`CREATE DATABASE ganymede_dev-001` is a syntax error, and every statement here
is redirected to /dev/null, so the first visible symptom was "schema looks
incomplete (0 tables)" — pointing at the schema files. create-env.sh:79 has
done the same substitution since it was written; now the two paths also agree
on the database name for a given environment.

Postgres was reused whenever it had any address at all. An earlier version of
this script put it on a named network, so a leftover container kept one, and
PG_HOST was staged with an address Ganymede cannot route to from the default
network. apply_schema would still pass — it goes through `container exec`, not
the network — so nothing before the first request would notice. It is now
checked, and refused with the command that fixes it, rather than removed:
that container holds every environment's database and has no volume.

GANYMEDE_PORT defaults to 6100 for everyone, so a second environment could not
bind and `container run`'s output is discarded. The port is checked after this
environment's own container is removed, so only a real conflict is reported.

Measured: apollo on 6100 and dev-001 on 6101 up at once, 21 tables each,
`ganymede_dev_001` created, and https://ganymede.apollo.test:8443 still 200.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The macOS platform had nothing between Ganymede and a browser. This adds the
missing link, and everything on the path turned out to be a real defect rather
than a translation.

`gateway-apple.sh` is the sibling of `gateway-pool.sh`, which does not change:
it runs on the Linux host under Docker and nothing here touches it. Three
flags do not survive the crossing, and each absence is the answer rather than
a workaround. `--device /dev/net/tun` is unnecessary — Apple's guest kernel
already has the node, and it is there in a running container. `--restart` does
not exist, which is the concession the broker's engine already names, and
launchd is still owed. `--dns` takes an IP and no port, so the container works
the host out from its own default route instead — as user containers have
since dd0d0dd.

That default-route trick was half right, and the half it got wrong only shows
on macOS. It skipped any name that already resolved, and the host's CoreDNS
answers 127.0.0.1 for every platform name: correct for the host, where nginx
is listening, and inside a microVM that address is the microVM. A loopback
answer is now overridden and a real one is still left alone. Both containers
that carry this code are fixed, because leaving one would be worse.

Ganymede writes an nginx server block per allocated gateway and reloads nginx.
On macOS neither the paths nor the signal survive: nginx is Homebrew's on
8443, its configuration is under the user's home, and Ganymede is in a
container no signal leaves. So NginxManager takes its placement and its reload
command from the environment, every default being exactly what it did before,
and nginx-reload.sh does the reload on the host.

Four things that only appear when the whole path runs:

  The upstream cannot be the loopback. Two things reach a gateway by that
  address — nginx on the Mac, and Ganymede health-checking from inside its own
  container — and 127.0.0.1 is right for only one. The container network's
  gateway is right for both, which is what the docker0 address is on Linux.

  DOMAIN has to carry the port, because every URL built from it is a link, and
  must not carry it in `server_name`, which nginx matches with the port
  already off. A port there matches nothing and every org request lands on the
  default server.

  Ganymede had no NODE_TLS_REJECT_UNAUTHORIZED. Its own health check is an
  HTTPS call to a mkcert certificate nothing told it to trust; `fetch` refused
  it, the check swallowed the reason, and the allocation rolled back.
  create-env.sh:289 sets this on Linux for this exact reason.

  Asking for a reload is not the reload happening. The health check ran before
  nginx had it, fell through to the default server, got a plausible 200 from
  the wrong server, and the first thing to notice was the POST handshake
  answering 405. The request now carries a token and waits for it to come
  back — which also returns a configuration the host refuses to the caller,
  since `nginx -t` can only run over there.

A row is also written before its container exists, so a failed `container run`
left an orphan that made every later `up` fail on a unique constraint with a
stack trace and no name in it. The port is checked before the row, and an
orphan for a name whose container is gone is removed.

Measured, on an M1, with Docker not involved anywhere:

    POST /gateway/start                        200
    org-<uuid>.apollo.test:8443/collab/ping    200, through nginx to the gateway
    gateway log                                Gateway initialized, 16 modules
                                               VPN started successfully

Refs TAC-176, TAC-174, TAC-129.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things go stale between iterations here, and each has already cost work:
a re-review that landed mid-change, a subsystem guessed at instead of read,
and a Linear issue describing an intention rather than what happened.

The first is now a script. Devin posts each finding as a root review comment
and closes it with a *reply* starting "✅ **Resolved**" — so "open" is a root
comment with no such reply, which neither `gh pr view` nor the PR page will
tell you once a review has three rounds on it. Run against the live branch it
immediately surfaced seven findings from a re-review nobody had looked at.

It also carries two failures that already happened in this workspace. `gh pr
view` resolves the PR by the *local* branch name, and a Conductor workspace
holding `create-pr` while tracking `origin/commit-and-deploy-images-v1`
reports "no pull requests found" with the PR sitting open — so the upstream is
asked for first. The same mismatch is what sent `git push origin HEAD` to a
branch nobody asked for, which the gotchas name along with the one-line fix.

Check status prints on the same line as the findings, because a review with
nothing open and a red check is not a finished branch and the two are read in
different places.

The other two are MCP calls rather than code, written down with the exact
arguments that answer and with what each is worth: the DeepWiki names files
and functions, which is the reason to ask it, and is a map rather than ground
truth — one answer here corrected an assumption that would have shaped a whole
ticket, and the code still had to be opened. Linear is appended to as things
are measured, because the part worth keeping is what is not in the diff: what
was tried and rejected, and what is knowingly left undone.

Every command in SKILL.md was run in this workspace, including the two that
failed first: the skills probe is `find`, because zsh errors on a glob that
matches nothing and the `(N)` qualifier that would fix it is disabled here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`GANYMEDE_URL` is read at four call sites in app-gateway — project members,
organization members, and the two project role writes — and exported by
nothing. Not by gateway-pool.sh, not by the containerbroker role, not by any
environment file. Every one of them has been going to
`http://app-ganymede:3000`, a compose hostname that resolves in no deployment
this repository builds.

The consequence is quiet in the worst way. `ProjectRooms.initializePermissions`
catches the failure, logs it, and the very next line reports the project fully
initialized — so a collaboration room comes up with nobody authorised in it,
the WebSocket upgrade times out, and nothing above the log says why. This is
not a macOS symptom; the Linux path has the same four call sites and sets the
variable just as little.

They now share one helper next to the client whose docstring already explains
why raw fetches are the problem here. An explicit GANYMEDE_URL still wins, so
anyone working around this keeps their override. Otherwise it is the FQDN — the
one address a gateway can always reach Ganymede at, resolved by CoreDNS on
Linux and from the default route on macOS, and carrying its port so nginx need
not be on 443.

verify-collab-websocket.mjs also learns the second environment layout. It
already creates the two accounts live collaboration needs — one owner, one
admin, because gateway-init assigns no role to a plain member — through the
real /signup, so passwords are hashed by the application rather than written by
a script. It only ever looked for `.env.ganymede` and `jwt-key` under
/root/.local-dev; it now also accepts `ganymede.env` and `jwt.key`, which is
the whole difference between the two.

Measured on macOS, Apple container, no Docker anywhere:

    3 clients connected
    all 2 peers received the update
    awareness converged across 3 clients
    PASS collaboration WebSocket is functional

    claude@test.local  / TestUser123!   owner
    claude2@test.local / TestUser123!   admin

Refs TAC-174, TAC-129.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ved from

The login form answered "Failed to fetch" on macOS, which reads like Ganymede
being down. It was not: the bundle being served had been built for `dev.test`
and was calling `https://ganymede.dev.test`, a name that resolves nowhere on
this machine. Nothing in the served page says which environment it was built
for, so the only symptom is a fetch that fails before it leaves the browser.

build-frontend.sh already writes exactly the right .env — it just could not
find a macOS environment to read the domain from. It now looks for the Linux
pair first, so that path is unchanged, and falls back to the macOS one, the
same two-layout lookup verify-collab-websocket.mjs got. The workspace default
does the same: /root/workspace/monorepo when it exists, otherwise the checkout
the script itself lives in, because a dev container has both and building from
the wrong tree is the failure this is meant to avoid.

DOMAIN carries a port on macOS and everything here wants that — VITE_DOMAIN_NAME
and VITE_GANYMEDE_URL both become links the browser follows, and nginx is on
8443 because binding under 1024 needs root.

Verified against the running environment, as the browser makes them:

    OPTIONS /login   200, Access-Control-Allow-Origin: https://apollo.test:8443
    POST    /login   200, Set-Cookie: sessid=…; Domain=ganymede.apollo.test

Refs TAC-174.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…odeQL

Two gaps, both found by running the driver against #53#57 rather than by
reading it:

Devin runs three review jobs per PR, not one, and each has its own emoji
pair — BUG_ posts 🔴/🟡, ANALYSIS_ posts 🔍/📝, and SEC_ posts 🟨. Only 🟨
was missing from the severity table, so every security finding fell through
to `unmarked`: printed with a blank severity and sorted to the very bottom
of the list. #57 currently has two of them. The job prefix in the marker is
now read as a fallback, so a new emoji degrades into the right bucket
instead of off the end.

CodeQL is a different bot entirely (github-advanced-security[bot]) posting a
body with no Devin marker, so the author filter dropped it silently — three
alerts on #54, one on #56, none of them ever shown. They now get their own
block, and deliberately do not gate the exit status: a CodeQL comment is
closed in the security tab, never by a reply.

SKILL.md corrections, all re-measured: the sample output was a stale 7/8 for
a PR now at 18/12; the DeepWiki structure was described by the wrong section
titles; and the claim that a Conductor branch name breaks the Linear link was
too broad — TAC-175 and TAC-176 point at the same PR and only one of them is
linked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oud mode

The service card offered "Local" alone and nothing said why. The gateway
registers the platform runner only when it has both a broker URL and a broker
token — config/modules.ts is explicit that it is both or neither, and that is
the right rule, because half a broker puts a button in front of someone that
fails on click. But the consequence of neither is silent: a mode is simply
absent from the picker.

gateway-apple.sh now starts the broker and hands the gateway the pair. Three
of its settings are the same lesson this migration keeps teaching:

  It binds on the container network's gateway address. Its only client is a
  gateway inside a microVM, for which the loopback is itself.

  GANYMEDE_INTERNAL_TOKEN is a signed RS256 `gateway_token`, minted here from
  the environment's key. A random string authenticates nothing and the failure
  it produces is every project image reported as non-existent rather than as
  refused — the defect the containerbroker Ansible role had.

  Every concession the apple engine names is listed, or the broker refuses to
  start. That refusal is the point of them.

The readiness check asks whether the *port* is listening, not whether a broker
process exists. A broker left over from verify-container-broker.sh binds a
random free port, so the process question answers yes while nothing listens
where the gateway will look — and the gateway then registers a platform runner
whose first request is refused. That is exactly what happened here first.

Measured, on both gateways in the pool:

    Available runners for project 33333333-…: local, platform
    Available runners for project 8b6fca42-…: local, platform
    PASS collaboration WebSocket is functional

Refs TAC-129, TAC-174, TAC-176.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gateway reached the platform runner's broker at `https://…:9443` and got
`fetch failed`, with nothing in the message about TLS. The broker has none: no
certificate option, no secure server, `http.createServer` and nothing more.
Port 9443 means TLS everywhere else, which is exactly why the wrong scheme is
the one you write.

Measured from inside a gateway container, at the address it actually uses:

    http  ://192.168.65.1:9443/containers            401   (then 404 with the token)
    https ://192.168.65.1:9443/containers            000

The 401 is the point: the broker is there and refusing an unauthenticated
call. The 000 is what the gateway saw.

Stated in the script rather than left to be rediscovered: the bearer token
therefore travels in clear. That is tolerable only because both ends sit on a
host-local network — the docker bridge on Linux, vmnet here — and it deserves
saying out loud precisely because the port number claims the opposite.

Refs TAC-129, TAC-176.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng out

The badge had a fixed 18px height and said nothing about overflow, so any
description longer than a couple of words wrapped to a second line and rendered
outside the coloured box — a blue rectangle with text hanging off it. The
catalogue's own entries are sentences ("Minimal Ubuntu 24.04 container exposing
only a web-based terminal"), so this was the normal case, not an edge one.

One line, clipped, with the whole of it plus the image name on hover. The
`minWidth: 0` matters as much as the overflow rule: this sits in a flex row,
and a flex item will not shrink below its content width, so the ellipsis never
engages without it.

Storybook looked right for a reason worth writing down: its stories set
`last_watchdog_at` and `httpServices`, so they render a *running* container,
and its description happens to be two words. The difference from a real card
was state, not style — except here, where it really was style.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three defects between the click and a running container, each hidden behind
the one before it, and the last one mine.

The gateway sent hosts entries built straight from the FQDNs. Those carry a
port wherever nginx does not listen on 443, and a hosts entry is not a URL, so
the broker refused the whole start: "extra_hosts entry has a malformed host".
It was right to. Same distinction as the nginx `server_name`, which strips the
port for the same reason.

With the port gone the refusal became "engine apple cannot set extra hosts" —
also right, and not fixable by reformatting. `--add-host` is not something
every engine has, and the broker refuses rather than dropping them quietly
because a container that needed them and did not get them fails later and
further away. The gateway cannot know which engine is on the other end and no
longer has to: since dd0d0dd the container finds the host from the gateway of
its own network, unprompted, on any engine. So the platform runner sends none,
exactly as it sends no devices. The local runner still sends them — it builds
a command for somebody else's machine, where nothing has been arranged.

Then the broker returned `holistix_…_uc_msgac\n[0/6] [0s]\n[1/6] …` as the
container identifier. engineExec joins stderr onto stdout so a refused network
delete becomes visible, which is right for callers that decide on the output
and wrong for the one that uses it as a value — and I wrote that merge and its
comment claiming this case was safe. Measured: `container run --detach` puts
the name on stdout and six progress lines on stderr. That identifier is stored
and later compared for ownership, removal and reconciliation, so carrying the
noise makes a container unremovable by the broker that started it.

Measured end to end, on an M1, no Docker anywhere:

    POST user-container:start                    200
    broker                                       Started uc_msgac8t5boa5u4
    engine                                       holistixforge/ubuntu-terminal:24.04, running
    kernel inside                                6.18.15   (host: Darwin 25.5.0)
    network                                      holistix_uc_uc_msgac8t5boa5u4, 192.168.67.2/24
                                                 platform services are on 192.168.65.0/24
    /dev/net/tun                                 present, from the guest kernel
    reported to the gateway                      engine apple, runtime container-runtime-linux,
                                                 isolation microvm

A tenant container ran on the platform host, in its own microVM, on its own
private network, and said so on the card. That is what TAC-129 asked for.

Refs TAC-129, TAC-174, TAC-176.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… it leaked

DOMAIN carries a port wherever nginx is not on 443, because every URL built
from it is a link somebody follows. Two places already strip it: the session
cookie domain and the nginx `server_name`. The review found two more, and both
break the feature that just started working.

`validateRedirectUris` compares DOMAIN against `URL.hostname`, which never has
a port — so with one, the comparison was false for every URI ever submitted.
Not a loosened check: a permanent refusal. The caller is the gateway
registering a user container's own sign-in endpoint, so starting a service got
a 400 saying its redirect was not a subdomain of a domain it plainly is one of.

`start_auth_guard` derives `--cookie-domain .${DOMAIN}` from GATEWAY_FQDN. A
cookie domain is a domain; a browser rejects `.apollo.test:8443` outright, so
every container behind the auth guard would sign a user in and hand them a
cookie the browser drops.

Three more the review found in the macOS scripts, each of which only shows up
on a machine that is not this one:

A gateway's address was read once, immediately after `container run --detach`.
It is Apple's to assign and is not there yet — every other place in this
harness already polls for it. Read once, an empty result is the common case
and a healthy gateway is declared dead *after* its row is in the database,
leaving the pool half-built.

`nginx-gateways.d` is bind-mounted into Ganymede and was never created. Apple
`container` refuses to bootstrap on a missing mount source rather than
creating it, and `container run`'s output is discarded — so the only symptom
is "Ganymede did not start", pointing at the bundle. It worked here because
setup-nginx.sh happens to create the directory; the order the scripts were run
in decided whether `up` worked.

The container resolver was deleted and recreated on every `up`. `--dns` is
baked into a container at creation, and that resolver is one per machine while
environments are not — so bringing up a second environment gave it a new
address and left the first pointing at one that no longer answers. Measured
after the fix: the address survives a `restart` unchanged.

Refs TAC-129, TAC-174.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TagsBar's layout effect resets both tag lists and then measures overflow one
animation frame per tag. It depended on the `tags` array, and every caller
builds that array inline — `resource-list.tsx` and `server-card.tsx` both do —
so each render of the parent handed down a new identity and replayed the whole
cascade from zero. Keying on the contents instead ends it.

Measured, not read: with the array dependency, six parent renders carrying
identical tags cost six extra cascades; with the content key, zero. That is
what the test asserts, and it was confirmed to fail against the old version.

Two dead ends worth recording, because both look like tests and are not:

- Counting renders from a parent proves nothing. TagsBar re-renders on its own
  state, which never reaches the parent, so a parent-side counter stays flat
  while the component spins.
- Rendering <TagsBar/> on its own cannot show the defect at all. Its own
  setState leaves the `tags` prop referentially identical, so the effect never
  re-fires. That version of the test passed against both implementations.

Not covered: which tags stay visible and which fold into the overflow menu.
That branch reads clientWidth, which jsdom always reports as 0, so every tag
measures as overflowing and the assertion would be about jsdom. It belongs in
the screenshot suite, where a real engine lays the row out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
probe.tmp.mjs and shot.tmp.mjs were scratch Playwright scripts, written to find
out which Storybook stories rendered and which hung. They were never meant to
be committed; ed3f9ae swept them in along with its own change.

Nothing depends on them and they carry no findings — what they measured is in
the TagsBar fix and in TAC-180.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sweep of all 1001 commits on every branch, answering: what was added to the
Storybook since the repository began, what survives, and where each piece
belongs under the project's own hierarchy.

269 story files have existed; 117 remain. The 152 missing paths look alarming
and mostly are not — they are renames. Deduplicating by identity leaves **21
elements genuinely lost**, and only 5 of those can be restored as they are:
their component still exists, only the story file was deleted. Four more are
already covered by a renamed equivalent and must not be restored, or they
become duplicates. The remaining 12 show code that no longer exists.

The reclassification is the other half. `Views > Components > Resource >
Assets`, applied inside each group, moves **82 of the 117** — which is the
measure of how far the arrangement drifted. It also folds in `Module` vs
`Modules`, the trailing space in `Users `, the `reource-list` typo, and the
demo titles (`a-b`, `solar system`, `Super title`) still sitting in the sidebar.

Three limits are stated in the document rather than papered over: identity is
the filename, so renames read as loss and had to be qualified by hand; the
title read is the file's, not the sidebar's; and the story count is syntactic.

Use/ versus Library/ is deliberately absent — it needs the import graph, and a
hand-written answer would be wrong again after the first refactor. TAC-180.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eep's blind spots

The first pass answered "what existed and disappeared". It left the neighbouring
question open, and that one is larger: of 199 components at HEAD, 137 names have
carried a story at some point in the repository's history. **97 never have.**
Those are not losses, they are original absences — and they dwarf the 21 lost.

Three places hold most of them: whiteboard (22, the largest module, whose
Storybook shows only its atoms), app-frontend (21, of which 13 are routed
screens, and which has never had a single story), and airtable plus notion
(18 between them, each reduced to its `Main`).

Also records what the sweep does and does not reach, because an inventory whose
blind spots are unknown is worth no more than a guess. `git log --all` covers
416 refs here — 394 of them Conductor checkpoints holding states no branch
references any more. Outside that: 3 reflog-only commits, checked one by one and
touching no story; no stashes; no other naming convention (the files under
`stories/` that are not `.stories.*` are harnesses and fixtures, not sidebar
entries). What it cannot see is a commit never written to this clone — and the
February 2025 rename of demiurge-ui-components to ui-base hints at an earlier
life whose own history is not here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…Resource > Assets

The sidebar had twelve roots. `Modules` and `Module` both existed; so did
`Users` and `Users ` with a trailing space. `root`, `internals`, `icons` and
`Palette` sat outside any of them. Nothing enforced the project's own
hierarchy, so each new story picked its floor by eye and the arrangement in the
original Storybook — Views, Components, Resource, Assets — stopped being true.

97 of the 117 titles move; the other 20 were already right. Three roots remain:
Base 107 stories, Modules 91, Mvp 59. Verified against a running Storybook, not
inferred: 257 stories served, which is every one of them.

The 155 screenshot baselines are renamed with them, because a story's id is
derived from its title and renaming the title alone would orphan every file.
Checked against the ids the server actually publishes: zero orphans. The 102
stories with no baseline had none before this change either.

Also folded in: the `reource-list` typo, `user-bubble` leaving `Mvp` for `Base`
where its component lives (16 stories, the most in the repo), `IDCard` leaving
`Users` for the socials module that owns it, and the Space/Whiteboard rename
finally reaching the titles.

One file is deliberately untouched. `tabs-radix.stories.tsx` opens with a data
payload whose field is also called `title` — the tab tree's root label. A
scripted rewrite that takes the first `title:` in a file corrupts it, and did,
before the audit caught it. Its meta title was already correct. The audit that
found it now runs over all 117 and reports one case, this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three the review found, all of them things a second user or a second gateway
would hit and a single run never does.

The nginx reload used one `.reload` file as a single slot. Two gateways set up
moments apart and the second request overwrites the first before the watcher
reads it; the first then waits out its full ten seconds for a confirmation
nobody will ever write, and Ganymede turns that into "Nginx reload failed" and
abandons an allocation that was fine — nginx having reloaded correctly all
along. Now one file per request and one acknowledgement per file: requests are
claimed before the reload so one arriving during it stays queued, and a single
reload satisfies everyone waiting. Measured: two concurrent requests, both
exit 0.

BUILD_PORT was configurable on the server side and hardcoded on the consumer's
— `fetch-gateway-build.sh` built `http://${BUILD_SERVER_IP}:8090` — so moving
the build server off the default gave every gateway a fetch failure pointing
at the download rather than at the setting. The port travels now, with 8090
still the default so nothing that does not set it changes.

The image badge tooltip glued an optional description onto the image name, so
a catalogue entry without one showed "ttyd ubuntu — undefined". The badge body
is empty in that case too, which makes the tooltip the only text there is.

And one the review raised as a question rather than a defect, now written down
where it will be found: sending no `extra_hosts` from the platform runner is
correct only while every image it can start resolves the host itself. That
code lives in the user-container base image, so a catalogue image on a
different base has no way to reach its gateway by FQDN in development. Closing
it properly means the broker telling the gateway what its engine can do before
a start rather than refusing after.

Refs TAC-129, TAC-174.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n born empty

Three defects found by loading all 259 stories in a real browser and reading
what the page reported. Failures drop from 17 to 9.

**Node backgrounds 404'd, all of them.** `nodes.scss` reached for
`url('../<name>.svg')`, which resolves inside whiteboard — a package that ships
no SVG at all. The six files live in ui-base. A missing background-image paints
nothing and reports nothing, so this was invisible until a network log showed
it. Eleven references corrected.

**Node components crashed before painting.** Any node reaching a connector
calls `useConnector` → `useDispatcher` → `reducers.dispatcher` off the module
context, and the shared story harness never wrapped anything in
`ModuleProvider`. The context defaults to `{ exports: {} }`, so the read threw
"Cannot read properties of undefined (reading 'dispatcher')" — naming neither
the hook nor the absent provider. It killed fourteen Jupyter stories:
node-vault, node-python, node-screening, node-dataset, node-notebook and
node-notebook-component, every state of each. They were never broken
components; they were rendered without half their context.

**NotebookView opened on a screen that has no content.** `activeView` starts at
`biome-server`, which has no content block, and the only way out is a setter
handed to a child inside a branch you must already be in. `status` is read only
under `biome-notebook`, which nothing can reach — so Stop, Load, Running and
Host rendered byte-identical DOM and identical pixels, both confirmed by hash.
It opens on a view now. Checked across every revision since the file appeared
on 2025-02-03: it was always like this, so there is no regression to bisect.

Two things worth writing down for whoever debugs the next one:

- A story importing a harness from another package gets that package's `dist`,
  not its source. Editing the source changes nothing until the package is
  rebuilt — the stack trace says `whiteboard/dist/...` and means it.
- `useModuleExports` takes a `from` label for diagnostics and never uses it.
  Passing "useDispatcher" buys nothing; the error still names only the property
  it failed to read.

Still failing, a different family, untouched here: six module `Main` stories
whose harness predates the collab registry API (`registerSharedData` on
undefined, and one that says so outright — "Collab module frontend now requires
CollabRegistryConfig"), and two Jupyter forms wanting a CollabProjectProvider.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The macOS scripts started every host process with `nohup … &`. Nothing watched
them, nothing restarted them, and nothing said when one was gone. On Linux
these are systemd units; here they were background jobs.

That is not a convenience gap, and this session is the evidence. nginx and the
host CoreDNS both died — the disk had filled — and the platform went dark. The
symptoms were a page that would not load, a WebSocket with no token, and
`fetch failed`, which reads exactly like an application bug: an hour went into
chasing the tab bar and a Yjs document that turned out to be my own probe
failing to connect, before anybody asked whether the daemons were still there.

Six agents under `so.holistix.*`: CoreDNS, nginx, the reload watcher, the
build server, the broker, and a one-shot that resumes the gateway pool at
login. `install` first stops whatever is running by hand, because otherwise
launchd starts a second copy and both fight for the port — which surfaces as a
service that flaps rather than as a conflict.

`status` prints what launchd holds *and* what is actually listening, and marks
the disagreement in red. That pairing is the whole point: a loaded agent whose
port is silent is the state this script exists to make visible.

Two things the first attempt got wrong, both now measured:

nginx daemonises, so launchd saw the master fork, called the job dead and
restarted it against a port already bound. It runs with `daemon off`.

`exec … &` is not foreground: the `&` forks, so exec never replaces the shell
and the broker died anyway. It has an explicit branch now — foreground for
launchd, detached for a person at a terminal — and the first version's failure
is named there.

`resume` starts the gateways that already exist rather than registering new
ones. After a reboot the pool is down while its rows still say ready, and the
next organization to open a project is handed a gateway that answers nothing.

Measured: coredns, nginx, the broker and the build server killed together, all
four back within ten seconds, frontend 200 and the collaboration WebSocket
still PASS. A stopped gateway resumed at its address.

Closes the last item of TAC-174. Refs TAC-129.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every container in an organization shared one client certificate, so with
`duplicate-cn` the server saw the common name `clients` for all of them: it
could not tell two apart, and could not give a particular one the address its
network was allocated. The gateway half — the credentials file, the allocation
lookup, the flag — has been there since af5bc94. The client half had never
been written, so the flag could not be turned on.

The client now presents the pair the server already knew how to check: its
container id as the username, and as the password the hosting token the
gateway minted for it and handed over in SETTINGS. It sends them whether or
not the server asks, because a server without `auth-user-pass-verify` ignores
them — which is what makes the rollout safe in this order. Every container
learns to send them first; only then can the flag go on. The other order takes
every service in every organization offline at once.

Five things stood between writing that and a tunnel, and only the first was
the feature:

`resolve_platform_hosts` read the default route with `ip route`, and iproute2
is not in the ubuntu-terminal image this repository ships. It found nothing,
returned early, and wrote no hosts entries at all — silently, because "no
default route" and "no `ip` command" look the same. It reads /proc/net/route
now, in shell rather than awk, because mawk is what these images have and it
has no `strtonum`.

The port then leaked twice more. Into /etc/hosts, where the line reads
`192.168.68.1 org-….apollo.test:8443` and resolves nothing while looking right
at a glance. And into the openvpn `remote`, whose template is `remote
GATEWAY_FQDN <port>` — host and port are already two fields — producing
"Cannot resolve host address" for a name that was in /etc/hosts, correctly,
without it.

`via-file` fails outright on OpenVPN 2.6.19: "could not write username/password
to file", as root, with `mktemp` in the same directory succeeding by hand. It
is deprecated upstream. `via-env` puts the credentials in the script's
environment instead and needs no file at all.

And the server named `/app/lib/vpn-auth-verify.sh` — the copy baked into the
image — while `pack` updates the one at /opt/gateway/app/lib. The running
gateway called a stale verifier, which refused every client while the fixed
copy sat unused a directory away. The config now names the scripts beside
itself.

Measured, with VPN_PER_CLIENT_IDENTITY=1:

    client   TUN/TAP device tun0 opened
             Initialization Sequence Completed
    server   [uc_msghr5y9u3pb86] Peer Connection Initiated
             MULTI: Learn: 172.16.0.6 -> uc_msghr5y9u3pb86
             MULTI: primary virtual IP for uc_msghr5y9u3pb86: 172.16.0.6

The flag stays off by default. Turning it on makes the server *require*
credentials, so it waits until every image in a catalogue is rebuilt from this
base — start-vpn.sh says the same thing from the other side.

Refs TAC-155, TAC-129.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… bare config

Not my work — this is the Storybook pass from a parallel session on the same
workspace, committed here because it was left uncommitted and typechecks
clean across collab, chats, user-containers, whiteboard and socials.

`collab` gains `createLocalCollabRegistry` and exports `CollabProjectProvider`
from its frontend entry, and every module story is moved onto them rather than
handing the module a `{ type: 'none' }` config literal.

The two Playwright probes at the repo root (`probe.tmp.mjs`, `sweep.tmp.mjs`)
are deliberately left untracked: they are throwaway scripts that drive
Storybook on port 6007, the review already flagged them as not belonging in
the tree, and adding them would make that permanent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… reachable

The seventh place, and the one that mattered most: it made the whole feature
unusable on macOS while every part of it reported success.

`DOMAIN` carries a port wherever nginx is not on 443, so the gateway builds
`gatewayFQDN = org-<uuid>.apollo.test:8443`, the reducer derives service hosts
from it, and `update-nginx-locations.sh` emitted them verbatim as
`server_name terminal.uc-x.org-y.apollo.test:8443`. nginx matches server_name
against the Host header with the port already stripped, so that block matched
nothing; every request fell through to the catch-all `server_name _` and was
proxied to app-gateway. Opening a service answered — with something else.

Stripped where it is emitted rather than upstream, because the same FQDN is
also the link on the service card and there it needs its port. Same
distinction this branch already draws for the Ganymede `server_name`, the
container hosts entries and the openvpn `remote`.

And new gateways were numbered from a count of the containers that exist. With
gw-pool-<env>-0 deleted and -1 running, the count is 1, so the next one is
named -1 as well — and the loop force-deletes whatever holds that name, taking
a live gateway and its database row with it while the organization using it
loses its connection with nothing said. It takes the next free suffix now.
Measured: with -0 gone and -1 up, adding one produces -2.

Measured end to end, on an M1, no Docker anywhere:

    server_name terminal.uc-….org-….apollo.test        (no port)
    GET https://terminal.uc-….apollo.test:8443/    200
                                                   ttyd - Terminal

That is the web terminal inside the user container, reached by its own FQDN
through host nginx, the gateway's nginx, and the VPN.

Noted, not fixed: a gateway removed outside `down` leaves its allocation row
behind, and the organization holding it gets 502 until the row is cleared.

Refs TAC-129, TAC-174.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dule

Two leftovers from the collab-registry work; 3e6bb74 carries the rest.

`clearProject` and `clearAll` now test `collab` for a `destroy` method instead
of for `instanceof YjsClientCollab`. The class check reads better and is wrong:
the suite passes mocks there, so it silently skipped them, and two existing
tests said so within a minute of the change.

`new-server.stories.tsx` loads the tabs module. user-containers declares tabs
as a dependency and `loadModules` refuses to load a module whose dependency is
absent — it reported "Module tabs is not loaded, needed by [user-containers]",
which was exactly true and had simply never been acted on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…enrol

TAC-154 made Ganymede keep the `code_challenge` it had been dropping, and the
enrolment flow was tested against a fake Ganymede that enforces PKCE. Run
against the real one, it never reaches the route: the OpenAPI validator sits in
front and the document never learned about any of this.

    GET /oauth/authorize?…&code_challenge=…
    400  Unknown query parameter 'code_challenge'

Past that, the token exchange asks a public client for the one thing it cannot
have:

    POST /oauth/token
    400  must have required property 'client_secret'

`getClient` already handles a missing secret — "validate secret if provided" —
so the route was always willing. Only the schema refused, and it refused every
public client permanently rather than in some edge case.

Both parameters are optional in the document, because the confidential client
that predates PKCE still exchanges without them, and `code_challenge_method`
is an enum of `S256` alone rather than a free string: `plain` is not something
to accept by omission.

Measured, first enrolment against a real Ganymede:

    302  Location: http://127.0.0.1:63255/callback?code=…&state=…
    Enrolled as "mac-m1" (7c93e774-3e9f-49bd-8f03-b6bbbb08d077)

The loopback port varies per run and the registered redirect has none, which
RFC 8252 says to ignore for loopback — confirmed rather than assumed.

Refs TAC-154, TAC-156, TAC-129.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rest of the same parallel-session pass already committed in 3e6bb74 —
jupyter's two form stories and their shared utils, left behind because they
landed after that commit. Typechecks clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The runner drove `docker` in hard. On the machine this platform is migrating
to there is no Docker at all, so a pass got as far as asking for its
containers and stopped:

    Cannot connect to the Docker daemon at unix:///…/docker.sock

The same seam the broker has, and for a reason that is not the same one. A
platform host runs tenants beside each other. A laptop runs *its owner's* work
beside services other members of their projects placed there — the confused
deputy pointed at a person's own machine, which is how the ticket puts it.
Under Docker on a Mac every one of those shares the single VM's kernel. Apple
`container` gives each its own guest kernel at level 1, so the owner is
protected from the other members of their own project.

An addition: the Docker entry names the functions that were already in
`docker.ts`, unchanged, and claims no concessions. Nothing on that path
behaves differently for having a table above it.

`listOwned` is one operation rather than a list and an inspect the caller
stitches together, because `container ls` has no `--filter label=` and the
selection has to happen in code — a difference that belongs inside the engine.

Three concessions, reported rather than gated: the broker refuses to start
until an operator accepts its own, but a runner is somebody's laptop and there
is no operator to ask. No `--restart`, so the loop restarts what exited. No
live network attach, so a placement whose networks changed is recreated rather
than corrected in place — and the entry throws instead of quietly doing
nothing, which would leave the container on the wrong network while the pass
reported success. No `--add-host`.

Devices are dropped rather than passed: under a microVM the container has its
own guest kernel and `/dev/net/tun` is already there — measured, `crw------- 10,
200`, with nothing given.

Apple's `inspect` is not Docker's — `configuration.id`, `configuration.labels`,
`status.networks[].network` — and read as Docker's it yields a container with
no labels, which reconciliation calls "not ours" and leaves running forever. An
unparseable answer throws rather than reporting an empty list, because "nothing
here" would make the pass start every container again.

The engine is named, never sniffed: "whichever binary is on the PATH" would
pick one on a machine that has both, and the two do not isolate the same way.

A test caught one of my own: the refusal threw synchronously from a function
typed as returning a promise, so a caller reaching for `.catch()` would have
got an uncaught exception instead of the refusal it was handling.

Refs TAC-156, TAC-129.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he machine

`reconcile` took a `TDockerExec` and called the docker functions directly, so
the engine table added last commit had nothing driving it. It takes an engine
now, and `main.ts` picks one from `RUNNER_ENGINE` — named, never sniffed,
Docker by default so nothing that exists today changes.

An engine that cannot move a network on a live container recreates instead.
The plan stays engine-agnostic: what a placement should look like has nothing
to do with what started it, so the substitution happens once, where the
refusal arrives. It costs a restart the Docker path does not pay — the
`no-hot-network-attach` concession — and that is better than leaving a
container on a network its placement no longer names while the pass reports
success. Only that refusal becomes a recreate; any other error still fails the
pass, or a broken engine would hide behind a service that silently restarts.

The gateway accepted `token <jwt>` and not `Bearer <jwt>`, and the runner
sends Bearer. The schema refused it before the route, so the heartbeat could
not reach any gateway and the machine stayed absent from every project
catalogue with a 400 about a regular expression. The same defect as the one
found in the CSRF gate while wiring the runner, pointing the other way.

Measured on this Mac, with Docker uninstalled:

    docker   Cannot connect to the Docker daemon at unix:///…/docker.sock
             1 project(s) · 0 announced, 1 unreachable
    apple    Engine apple. Controls it cannot express: restart-policy,
             no-hot-network-attach, no-add-host
             1 project(s) · 1 announced, 0 unreachable

Found and not fixed, because it is the seam itself and deserves its own
change: `GET /placements` returns the raw collab documents — `{runner: {…},
httpServices, …}` — where the runner expects a `TPlacement` with `machine_id`,
`imageRef`, `settings`, `networks` at the top level. So every placement is
refused with "Placement names no machine", and would be unstartable even if it
were not. Both halves are tested, each against its own idea of the shape, and
they have never agreed. Building the real thing needs the image registry and a
hosting token per container — the same one that is the container's VPN
password — which the route does not have today.

Refs TAC-156, TAC-129.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four causes, each one a story asking a module for something nobody had loaded.
Every remaining failure has changed character as a result: none of them still
reports what it reported before.

**The Jupyter form stories mounted only the init gate.** NewKernel and
NewTerminal wrapped in `JupyterStoryInit` and nothing else, so they reached for
a module context, then for a registry, then for a shared map, one layer at a
time as each was supplied. `JupyterStoryProviders` now mounts the whole
frontend stack once, in the harness rather than in each story, which is what
kept the two drifting apart.

**Jupyter declared `reducers` in its type and not in its dependency array.**
The loader injects from the array; the compiler reads the type. It went
unnoticed because the only story that loaded the module also loaded reducers
first for unrelated reasons. Loading the stack in dependency order finds it
immediately.

**The story `gateway` stub predated `permissionRegistry`.** user-containers
registers its permissions against it at load, so the module read `.register`
off undefined and the story died before painting. The stub now answers.

**`tabs` was loaded after the module that depends on it.** `loadModules` walks
its list in order and refuses a module whose dependency has not been loaded
yet — it named tabs and user-containers, and was simply never read.

Also: `fs` is now overridden inside `vite-plugin-node-polyfills` rather than
aliased around it. The plugin resolves node builtins itself, so a
`resolve.alias` entry never gets a look in — an empty module is `null` once
required, and `const { existsSync } = require('fs')` throws on it before any
component renders.

Still failing, all of them now a dependency-level problem rather than a missing
provider: jupyter's Main and Terminal want `__webpack_public_path__`;
user-containers' Main dispatches without a project_id; notion and airtable
expect a server to answer; socials frames google.com, which sets
X-Frame-Options.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MChrys and others added 20 commits August 9, 2026 14:48
… space

Outside a project the space entries were simply gone, so the rail changed
length by page — the thing under a given position moved, and someone
navigating by muscle memory clicked the wrong one. It also read as loss:
whiteboard and resources had *disappeared*, not moved out of reach.

Two groups now, in one column, with a rule between them: an organization's
places, then a space's. The whole list renders on every page. An entry out of
reach is greyed and says why in its tooltip, which is the only room a 56px
column has for a reason.

And out of reach is now the exception, because the last space anyone opened
is remembered. A rail that went half-dead on leaving a project would be dead
exactly where someone is most likely to want to go back to work. What gets
stored is the route's own parameters — what worked as a URL — so rebuilding
it cannot drift from the routing. Grey is left for the first visit, where
there is genuinely nothing to point at and saying so beats a link that lands
on `/p/undefined`.

The space links are absolute where they used to be relative. `..` resolves
against the matched route, so a relative link means different things on a
project page and on an organization page — and the point of remembering a
space is that it means one thing from everywhere.

The group rule takes `--color-border` and not `--color-border-muted`: the
muted one resolves to the same surface as the rail's own background, so the
first version was there and invisible. Seen on the page, not read off the
palette.

Verified in a browser across six navigations: greyed before any space is
opened, live on every page after one, and the rule always in the same place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same reasoning as the space, applied to the level above it. The list of
organizations names none, so the two entries under it were dead there — and
that page is exactly where someone wants to get back to the organization they
were just in.

Both levels now fall back to the last one visited, so the only page where any
of it is grey is the first one a new account sees. Arriving anywhere records
it, and entering a space records its organization too: going straight from
the list into a project would otherwise leave the list with nothing to point
back to.

The route wins over the memory where it says something. Two organizations
open in two tabs would otherwise show each other's.

`last-space.ts` becomes `last-visited.ts`, holding both, with the storage
access wrapped once — it can fail in private mode, and losing the way back is
a smaller failure than a rail that throws while rendering.

One thing worth knowing, and written down rather than discovered: this
outlives a session. Someone who signs in as another user inherits the
previous one's last places, and following one lands on a page the server
refuses — as it would for a pasted link. A wrong link, not a leak.

Verified in a browser across five navigations: grey on a fresh profile, the
organization live on the list after visiting one, and everything live after
opening a space.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Over a canvas, one panel with a rule through it reads as one thing that has a
line in it — which is the opposite of what the two levels are. So on the
whiteboard they are two boxes with air between them: the aside becomes the
stack, transparent, and each group carries the surface and the rounded edge.
Docked as a bar the column is continuous and a rule is the right separator,
so that stays.

The split had to move into the DOM for this. A border between two list items
cannot become two boxes, so `separatorBefore` now cuts the items into groups
and each group is its own list. The flat index is carried alongside, because
the active mark is held by position and grouping must not renumber it.

A rail with nothing in it still renders one list. It used to get one for
free; grouping would have left an empty box, and an empty rail is still a
rail — the grouping is about where the cuts are, not about whether there is a
list at all. Caught by the one test that passes no items.

Measured in a browser: two boxes at 56×176 and 56×120, 16px apart, 8px
corners, the aside transparent behind them — and unchanged as a bar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…beside it

The light carries the placement in its colour — blue for a machine somebody
owns, green for the platform — and a colour on its own is a legend nobody was
given. A cloud sits to its left now, and a laptop for a local one: the same
fact in a form that needs no key.

They are handed the same value rather than deriving it twice, so they cannot
drift. A green light beside a blue cloud would be worse than no cloud at all.

Nothing at all for a runner this build has never heard of, rather than a
placeholder. "Running somewhere I cannot name" is what the light already says
on its own, and a question mark beside it would add doubt without adding
fact.

The ink is also written to a data attribute, which is not decoration: jsdom
drops a `var()` from a standard property outright — it never even reaches the
style attribute — so the inline colour is unreadable from a test, and "the
glyph agrees with the light" is the one thing here worth asserting.

Measured in a browser: the cloud 22px to the left of the light, on the same
row, painted rgb(63,191,134) beside a green LED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A card glows on hover — ninety pixels of blur — and it came out sliced off
square on all four sides. Which does not read as a glow with an edge; it
reads as a rendering fault.

Two clips, both lifted. Excalidraw clips every embed to its element, which is
right for an embedded web page: it must not paint outside the rectangle
somebody drew. Ours are not pages, they are this application's own cards, and
`validateEmbeddable` admits exactly one host — so every embed inside this
layer is a node we drew, and lifting it here is safe in a way that lifting it
generally would not be.

The other clip was ours, and it was a guard against a node outgrowing its
box. That guard is now done properly one level up: the box is measured to fit
the node instead of cutting it to size, so the clip was insurance against a
case that no longer happens, charged against a case that does.

No `!important` on the Excalidraw rule — measured. Theirs is two classes and
this is three, so it wins on specificity despite loading first. Worth saying
because it is not true of everything in this file: the dark-mode entry needed
one, for a rule of equal weight loaded later.

Verified in a browser: both computed to `visible`, the shadow resolves, and
the halo spreads well past the element's box in every direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I read the wrong pastille. What the Storybook shows beside the run control is
a round badge with a ring around it — the face of whoever's machine is doing
the work — and I put a small glyph down by the light instead. That glyph is
gone; it was a second indicator for a fact the light already carried.

On the platform there is nobody to show, so the slot was simply empty. That
does not read as "nobody owns this", it reads as a card missing a piece. It
gets a face of its own now: the same badge, the same ring, a cloud instead of
a person and green instead of blue — green because that is what the light
says for the platform, and one fact should not be told in two colours.

Shown only while the service is actually up, and the person's face now
follows the same rule. Written as "the light is blue or green" rather than as
a second reading of the same facts, since that is exactly the condition. On a
stopped service the badge would answer "where would this run" — a question
the runner picker below already owns, and one nobody asked.

Verified on the running notebook: a 40px badge ringed rgb(63,191,134), 40px
left of the run control and on its row, beside a green light.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two faults, one badge.

The cloud came out black. Its path carries no fill of its own, so it falls to
the SVG default, and the parent's `color` never reaches it — on a dark badge
that reads as a hole rather than as a cloud. `fill` on the svg, which is
inherited, so one declaration covers every path in the icon.

And it was still. A running service is a thing in motion; a still badge
beside a still light says only "configured". It pulses now, the same way the
avatar it stands in for does: a disc the size of the badge growing out of it
and fading, behind the badge's own background so only what has grown past the
edge is ever seen — a ring rather than a flash over the icon.

Its own keyframes rather than the `ping` ui-base declares for avatars. That
one is in the document only because this card happens to import an avatar
component from the same barrel; a rule that works by accident of an unrelated
import is one that stops working without warning.

Verified in a browser: fill and border both rgb(63,191,134), the animation
running at 1s, and two frames a third of a second apart that differ — which
is the only way to tell a pulse from a picture of one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It was a padlock in the header, sitting beside the settings gear — where it
read as a preference rather than as a place you go. It is a place: the rail's
upper group, directly above the accesses, which is the neighbour that makes
the pair legible as "who I am" and "who else may".

Never out of reach, unlike the two entries around it. A wallet belongs to the
person, not to an organization, so there is nothing for the route to name and
nothing to fall back to — it is the one entry in that group that is live on a
fresh account with no organization yet.

The wallet page gets a rail too, having had none. It is an account page and
names no organization, so the rail falls back to the last one visited, which
is what keeps a wallet from being a dead end.

Verified in a browser on three pages — the board, the wallet itself and the
permissions page — same six entries in the same order, four above the rule and
two below, and no padlock left in the header.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It was a flat list whose nesting was faked with left margins, painted in two
saturated gradients — violet for the active layer, pink for everything in it
— which made a panel you scan the loudest thing on a screen whose subject is
the board.

Flat, a group could not be closed, and "inside that group" looked exactly
like "below it, at the same level": an indent is not containment when nothing
can be collapsed to prove it. It is a tree now. Closing a layer closes what is
in it, and a shut branch is not walked at all rather than walked and hidden —
a board with two thousand nodes should cost nothing to draw while its layer is
shut.

The state is "what is shut", not "what is open", so a node created while its
layer is open arrives open. The other way round, everything new would appear
folded away.

One behaviour changed rather than moved: only the ReactFlow layer was
clickable and only its nodes selectable, written when it was the only surface.
It is not, and Excalidraw is the default one, so the panel spent most of its
time listing things nobody could touch.

Split in two, which is what makes it storyable: `LayerTree` takes a collection
and hands back clicks, and the panel is the wiring that reaches for awareness
and the layer registry. Six stories — a board, another layer active, a
selection, the flags, empty, and deep enough to show what the indent is for.

No eye and no padlock. The board's `onTreeOperation` is a stub that logs, so
those would be controls that lie. Locked and hidden are *shown* where they are
true, since that costs nothing, and the controls arrive when the operations
do.

The two flatteners go with the list that needed them.

Verified on the running board: 19 rows, the active layer marked, the other
layer's three rows dimmed, and collapsing Excalidraw taking it to 4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The panel listed the board twice. A second layer held the graph's nodes,
properly named; the drawing surface held the same nodes as the elements it
projects them into, named "Embeddable 3". Two listings of one board,
disagreeing about what is on it — and the disagreement was the tell that the
distinction had stopped being real. The surface is the board. There is one
list.

So the embeds take their names from the graph, which is where a node's name
lives — the element only ever carried the id. The board's own layer is
reported only while ReactFlow is the surface, where the nodes really are
somewhere else.

And a frame's contents go inside it. Excalidraw says which frame an element
belongs to and then keeps the scene flat, so a group and its contents were
siblings — the one arrangement that makes a group look like a neighbour.
Reading `frameId` is reading a fact the scene already carries.

"Excalidraw" is gone from the panel. It is the name of a library we happen to
draw with: nobody using this board chose it, and it says nothing about what
the layer holds. `Layer 1`, because there will be a second. The id stays
`excalidraw` — that one is the registry key, and saved payloads name it.

Verified on the running board: one layer, sixteen rows, the projected node
listed as "notebook" rather than as an embeddable, and no empty second layer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The model and its writer, ahead of the surface that will use them. Nothing
sends these events yet, so this changes nothing for anyone — which is the
point of landing it on its own: the half that touches people's boards should
arrive on top of a settled model, not alongside one.

A layer is not a second canvas. Excalidraw's scene is an ordered array and
that order *is* the paint order, so a layer is a contiguous block in it and
reordering layers reorders their blocks. Two stacked instances would have
doubled the cost measured at 2000 nodes and left a focus and a z-order to
arbitrate between them.

`order` is a number, not a position in a list, because this is a shared map
and two people may reorder at once: per-key last-writer-wins on a number
converges on *an* order, where a shared list rebuilt by two writers can
converge on a list with holes in it. Ties break by id, so two layers made in
the same millisecond do not swap places between two readers.

Reordering carries the whole stack rather than "move this one there". A move
is only meaningful against the list the mover was looking at, and two people
moving at once against different lists produce an order neither asked for.
And a layer missing from a stale list keeps its own order rather than being
dropped — a client must not delete another client's work by omission.

An element with no layer is not broken and needs no repair: it belongs to the
bottom of the stack, which is where it was when that was the only place there
was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rder

The half that touches the board. Reading the stack, giving a board its first
layer, putting a new stroke on the layer its author was working on, and — the
whole point — painting back to front.

That last one is three lines, because Excalidraw's array *is* the paint order:
sorting the scene by layer is the entire feature, and nothing else in the
pipeline has to know layers exist. Stable within a layer, so Excalidraw's own
bring-to-front still means something. An element with no layer, or on a layer
this client has never heard of, sorts to the bottom — where it was when the
bottom was the only place there was, which is why a board that predates
layers needs no migration.

A first layer is asked for on sight rather than migrated in. The elements of
an old board have no layer at all and belong at the bottom whether or not the
bottom has a name yet; naming it is what gives the panel something to list and
the next stroke somewhere to go.

Which layer is active rides in the layer payload — per client, not shared. Two
people drawing on one board are usually on different layers, and sharing it
would have each of them moving the other's pen.

The panel gains `updateLayerTrees`, for a provider that is several layers
rather than one. Publishing them one at a time cannot express a removal: a
deleted layer would sit in the panel until a reload because nothing said it
was gone.

One mock had to stop bypassing its selector. It answered the layers key with
the raw list, so the filtering by drawing was never exercised — and a test
about ignoring another board's layers passed while doing nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Excalidraw already has send-to-back and bring-to-front, and they reorder the
scene — which is where layers live. So pushing an element past a boundary is
how you move it between layers. Membership follows position.

The alternative was to sort it back into its block, and that would have made
those two commands do nothing across a boundary: click, watch it move, watch
it come back. A control that appears to work and then undoes itself, which is
the thing this board keeps being caught doing.

Within a layer nothing changes. The order of the elements is still what says
which is in front, and Excalidraw's own commands are still what change it.

An element that broke the order is the one that moved. A first attempt asked
its neighbours instead, and that pushed an element which is simply the only
one on its layer into its neighbour's — at an end there is one neighbour, and
one neighbour is not a vote. Two tests caught it: the boundary between two
blocks, and a scene with an untagged element at the front.

A move past a boundary changes no version — Excalidraw renumbers on a
mutation, not on a reorder — so the version gate alone dropped it and the
element snapped back on the next reload. The flush now sends an element whose
version *or* whose layer changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The half that makes the rest usable. A `+` in the panel's header, and layer
rows you can drag past each other.

The panel does not know how a layer is made. Creating one is an Excalidraw
event, and a panel that dispatched it would know one provider by name — so
the provider publishes two verbs alongside its layers and keeps its own
vocabulary. No verbs, no controls: a `+` that does nothing is worse than no
`+`, which is the rule the eye and the padlock are still waiting on.

A reorder carries the whole stack rather than "move this one there", and the
panel hands it back front-first because that is the order it was showing. The
flip to paint order happens where both facts are known.

Three defects the browser found, none of which a test would have:

The panel showed "Layer 1" twice — the placeholder seeded for the provider
before it had spoken, beside the real first layer. Publishing now clears the
placeholder too.

The first layer was requested five times. The write takes a round trip, and
the effect ran again on every render until it landed. Once per drawing now.

And the `+` appeared to do nothing: the section was published from `onChange`
alone, so a layer created in the panel did not reach the panel until the next
stroke. It publishes when the stack changes as well.

Verified in a browser: the `+` adds Layer 4 with one event, dragging the
bottom row to the top gives Layer 1, Layer 4, Layer 3, Layer 2, and one
reorder event. No duplicate rows, no page errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing a node

This deleted a node from a real board. The service behind it went on running,
with nothing left on the board pointing at it — which is the worst shape a
data loss can take: nothing throws, nothing is logged, and the thing that is
gone is the only way anyone had to reach the thing that is not.

`updateScene` replaces the element list. A node missing from a projection —
an incomplete write, a graph view that has not loaded yet — comes back
tombstoned on the next change, and the write-back read that as a person
reaching for the eraser.

So the write-back now consults what the last projection actually drew. A
tombstone for a node that was not in it is the projection's own doing and is
ignored. Erasing a node on the surface still deletes it, which is the
behaviour that path exists for and is tested above.

Found by testing this branch against the shared board, not by a test: the
notebook node vanished from `sync-test` while its container kept reporting
green on the Resources page. The node is not recoverable — Yjs has no undo
here — and it can be created again from the resources card.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he board

It was drawn as a card — a border on all four sides, rounded corners — and
the collapse control was a 24px strip with a rule of its own. Against a board
that has no cards in it, that read as three vertical bands down the left
edge: the panel's own border, the strip, and the line between them. The
middle one looked blue because `--color-border` is a blue-violet in this
palette, so it read as a stray element rather than as an edge.

A panel that is docked has one edge that matters, and it is the one where it
meets the board. One hairline there, in the muted border, and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Selecting any layer in the panel made the whole surface vanish — no canvas,
no toolbar, nothing. Reported as "the layer system is broken in general", and
it was: this is the bug behind most of what looked like separate faults
today, including a node that seemed to have disappeared.

A row's id carries its provider, `excalidraw:layer-2`, since the surface now
publishes several layers and two providers must not collide on a name. The
board picks which layer renders by comparing the active id to a *provider*
id. Handed the row's, nothing matched, the surface got `active={false}` and
returned null.

Which layer was clicked belongs in the payload, where the surface already
looks for it — that is what the active-layer payload was added for. The
provider id goes where the board expects a provider id.

Verified in a browser: clicking Layer 1 and then Layer 3 leaves the surface
mounted, two canvases, nineteen rows — where before the first click emptied
the page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The top-left island was nudged past the panel when that rule was written; the
bottom-left group was not, so the zoom and undo controls sat under the panel.
The one piece of Excalidraw's chrome the panel could cover, and the one
nobody looks for, because everything else had moved.

Padding, not margin: the row is `justify-content: space-between`, so a left
margin would drag the right-hand group with it and push the help button off
the board.

The class named twice, to outweigh `.excalidraw .App-menu_bottom { padding: 0
1rem }` — the same specificity as one mention, and its stylesheet loads after
this one, so the shorthand won and the padding never applied. Measured: the
controls stayed at x=16 until the selector was heavier, then moved to 271,
clear of a 240px panel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`convertToExcalidrawElements` walks a frame skeleton's `children`
unconditionally — `element.children.forEach(...)`, no guard — and our
group branch never set the key. So the moment a board held one group
node, the conversion threw; and because the projection is an async
effect, the rejection went nowhere.

What that looked like: a canvas that drew, a layers panel that listed
every shape, and not one service card. Which reads as "the node was
deleted", not as a crash — I read it that way myself, said so, and went
looking for a node that had never left.

Empty children rather than populated: membership is already ours, set
as `frameId` from the view's `parentId` just below. Excalidraw's path
wants ids present in the same batch and throws on any it cannot map,
which our ids — assigned after the conversion — are not.

The conversion now also fails loudly. It stays fatal to the projection,
since a skeleton cannot be dropped without dropping the view it is
matched to by index, but it names itself in the console instead of
leaving a blank board to be interpreted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things the layers panel was missing, and one it was only
pretending to have.

Clicking a layer now selects everything on it, and clicking a row
brings the board to what it names. A panel row and its element can be
a screen apart — on a board wide enough to need layers they usually
are — so a click that only highlighted the row answered "which one"
without ever answering "where".

Both are published by the surface with its stack, next to `addLayer`
and `reorderLayers`, rather than reached for. The panel holds a list of
rows; how a row maps to a scene element is the provider's business, and
a panel that knew it would know one provider by name.

Centring does not go through `api.scrollToContent`, which is the
obvious call and does nothing here: this layer owns the viewport and
pushes the shared one back into Excalidraw whenever it changes, so
Excalidraw's own scroll is overwritten the moment it is set. Measured,
after the call ran, found its element, and left the board exactly where
it was. It reports a viewport instead — the same road a mouse scroll
takes — which also keeps the whiteboard's own viewport in step.

And the pretence: elements drawn before layers existed were *read* as
the bottom layer's, by a fallback in the grouping, but nothing had ever
written it down. The board's data said "no layer" while the panel said
"Layer 1", and reordering the stack moved rows that were only there by
default. They are adopted once, on first sight of a stack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 14 potential issues.

Open in Devin Review

Comment on lines +1163 to +1165
const elements = pendingElements.current.filter(
(e) => !embeddedNodeId(e)
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Connector lines drawn between cards get saved as if someone had drawn them by hand

The connecting lines the board draws for itself are kept as part of the saved drawing (filter((e) => !embeddedNodeId(e)) at packages/modules/excalidraw/src/lib/layer.tsx:1163-1165) instead of being left out like the cards they join, so people see duplicated and flickering lines and the saved drawing fills up with lines nobody drew.

Impact: Connector lines appear twice after a reload and blink on and off while two people have the same board open, and the saved board accumulates content the user never created.

How the projection's own arrows leak into `excalidraw:elements`

The projection effect builds an Excalidraw arrow per graph edge and marks it with customData.holistixEdge (packages/modules/excalidraw/src/lib/layer.tsx:992-1013), then writes it into the scene together with the node embeddables.

The save path only excludes elements carrying holistixNodeId:

const elements = pendingElements.current.filter((e) => !embeddedNodeId(e));

so every projected arrow is dispatched through excalidraw:upsert-elements and stored in the shared element map as an ordinary drawing element.

Three consequences follow:

  1. On the next projection the scene is rebuilt from scene.filter((e) => !embeddedNodeId(e) && !e.customData?.['holistixEdge']) plus freshly converted arrows, and convertToExcalidrawElements mints new ids for them — so the previously stored arrow ids disappear from current and the same flush emits excalidraw:delete-elements for them while upserting the new ones. Every node move therefore writes and deletes one element per edge.
  2. A second client pulls those arrows into its scene, its own projection strips them (they carry holistixEdge), and its flush then deletes them from shared state — the two clients fight over the same keys.
  3. Anything that survives is reloaded from the map at mount and re-projected, so the same edge is drawn twice.

User-drawn arrows that are converted into edges (packages/modules/excalidraw/src/lib/layer.tsx:1230-1266) hit the same problem from the other side: the original arrow stays a stored drawing element while the new edge grows a projected arrow of its own.

Suggested change
const elements = pendingElements.current.filter(
(e) => !embeddedNodeId(e)
);
const elements = pendingElements.current.filter(
(e) => !embeddedNodeId(e) && !e.customData?.['holistixEdge']
);
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +292 to +302
const newKernels: Kernel[] = (kernelModels ?? this.kernelResources).map(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(k: any) => ({
kernel_id: k.kernel_id ?? k.id,
name: k.name,
type: k.type,
last_activity: k.last_activity,
execution_state: k.execution_state || '',
connections: k.connections || 0,
notebooks: [],
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 A notebook's kernels temporarily lose the notebooks they belong to when one status request fails

When only part of a container's status can be read, the previously known kernels are rebuilt with their notebook list emptied (notebooks: [] at packages/modules/jupyter/src/lib/driver.ts:294-302) and published anyway, so the project briefly forgets which notebook each kernel belongs to.

Impact: Until the next successful poll, kernel cards show no notebook attached even though nothing changed on the machine.

Partial-failure path in `pollResources`

pollResources returns early only when both halves failed. If pollKernels() returns null (request refused) while pollTerminals() succeeds, newKernels is mapped from the cached this.kernelResources — but the mapper hardcodes notebooks: [], and the session lookup that would repopulate it is deliberately skipped because kernelModels === null (packages/modules/jupyter/src/lib/driver.ts:305-317).

deepEqualKernels then reports a change for any kernel that previously had notebooks, the listeners fire, and jupyter:resources-changed pushes the stripped kernels into the project's shared state. The association returns only on the next successful kernel poll, so the state flaps.

Carrying the cached notebooks through when the kernel list is not fresh would keep the last known truth instead of publishing a partial one.

Prompt for agents
In packages/modules/jupyter/src/lib/driver.ts, pollResources falls back to this.kernelResources when pollKernels() returns null, but the mapper always sets notebooks: []. Since the session lookup that repopulates notebooks is (correctly) skipped for a stale kernel list, the published kernels lose their notebook associations on every partial failure and regain them on the next success, causing shared state to flap. Preserve the cached notebooks when the kernel list comes from this.kernelResources rather than from a fresh poll — for example by reusing the cached objects unchanged in that branch instead of re-mapping them.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +269 to +274
event.layerIds.forEach((layerId, index) => {
const key = layerKey(event.drawingId, layerId);
const layer = layers.get(key);
if (!layer || layer.order === index) return;
layers.set(key, { ...layer, order: index });
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Reordering layers leaves unnamed layers with colliding order values

_reorderLayers writes order = index only for the layers named in the event and deliberately leaves the others untouched, so a layer that a client had not yet seen keeps whatever number it had — which will almost always collide with one of the freshly written indices. _layersOf then breaks the tie with a.id.localeCompare(b.id), so the unseen layer's position is decided alphabetically by id rather than by anything the user did, and it can silently jump in the stack after any reorder. The comment acknowledges the layer is not deleted, but not that its position becomes arbitrary. Appending unlisted layers after the rewritten block (or renumbering the whole stack) would make the outcome predictable.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +68 to +79
export const withStackingIndex = (
elements: readonly TJsonObject[]
): TJsonObject[] => {
const complete = elements.every((e) => typeof e['index'] === 'string');
if (complete) return [...elements];

const width = String(Math.max(elements.length - 1, 0)).length;
return elements.map((element, i) => ({
...element,
index: `a${String(i).padStart(width, '0')}`,
}));
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Legacy migration rewrites every index when only some elements carry one

withStackingIndex regenerates a0…aN for the whole drawing as soon as a single element lacks index, which discards the fractional indices the elements that did have one were carrying. The reducer test only covers the all-present and all-absent cases, so the mixed case — a drawing edited across the version that introduced index — is untested and would be silently restacked. Synthesising only for the elements that lack one is unsafe for the reason the comment gives, so the safest reading is that a mixed drawing should be renumbered in the array's own order, which is what this does; worth a test stating that explicitly.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +470 to 479
// Write a clean error state without pending, keeping the attempt
// count: this clears a stuck flag, it is not a fresh failure, and
// dropping the count here would restart the backoff every time a tab
// died mid-fetch — which is the state most likely to repeat.
const previous = this.read<T>(key) as AnError | null;
this.write(key, {
error: true,
wait: new Date().getTime() + ERROR_WAIT,
attempt: previous?.attempt,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Backoff is discarded when a stuck pending state is cleared

restartAfterError preserves attempt but writes a fixed ERROR_WAIT deadline, so the next retry happens 30s later regardless of how many consecutive failures preceded it. The comment says dropping the count would restart the backoff, which is true, but the wait itself is still restarted — a tab that repeatedly dies mid-fetch keeps retrying every 30s against a rate-limited endpoint, which is exactly the pattern errorWait was introduced to stop. Using errorWait(previous?.attempt ?? 0) here would make the two paths agree.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 117 to +120
if (!service) {
throw new Error(`Service ${serviceName} not found`);
}
throw new Error('Not implemented');
return (config as TConfig)?.getAccessToken?.() ?? '';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Comment and behaviour disagree about the token handed to a container

The new doc block says the caller "gets an empty string" and that returning a real token "would hand the notebook's whole API to whoever holds the page", but the implementation now returns config.getAccessToken?.() — and module-data-provider.tsx supplies exactly that for the user-containers config key. The user's own access token is the intended value (the guard validates it and swaps in the service token upstream), so the code is doing what the rest of the change wants; the comment describes the previous contract and will mislead the next reader into thinking the empty string is a security invariant. The accompanying spec asserts '' only because it loads the module with no config.

(Refers to lines 95-120)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +43 to +63
// allows reports whether this origin may read a response from the container.
func (c *CORS) allows(origin string) bool {
if origin == "" {
return false
}
o := strings.ToLower(origin)
if _, ok := c.origins[o]; ok {
return true
}
// A subdomain of the platform. Compared against the allowed origin with its
// scheme, so `https://evil.com/?x=https://apollo.test` and
// `https://apollo.test.evil.com` both fail: the first is not a suffix at
// all, the second does not end at a label boundary of an allowed origin.
for allowed := range c.origins {
host := strings.TrimPrefix(allowed, "https://")
if strings.HasPrefix(o, "https://") && strings.HasSuffix(o, "."+host) {
return true
}
}
return false
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Credentialed CORS is granted to every subdomain of the platform domain, including tenant-served container pages

NewCORS/allows accept any origin ending in .{platform domain} and answer with Access-Control-Allow-Credentials: true and the caller's origin echoed back. Every user container's services are themselves published as subdomains of that same domain ({service}.uc-X.org-Y.{domain}), and those containers run tenant-chosen images that can serve arbitrary HTML/JS. A page served by one tenant's container can therefore issue credentialed cross-origin requests to another container's auth guard; the browser attaches the platform-scoped session cookie, and the guard's response becomes readable to the attacking page. The per-container session cookie naming added in this PR limits which session is sent, but a victim who has opened the target container in the same browser has exactly that cookie.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 117 to +120
if (!service) {
throw new Error(`Service ${serviceName} not found`);
}
throw new Error('Not implemented');
return (config as TConfig)?.getAccessToken?.() ?? '';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 The signed-in user's platform access token is now handed to container services from the browser

getToken used to refuse (throw new Error('Not implemented')) and now returns the user's own platform access token, which callers attach as Authorization on requests to a container's host ({service}.uc-X.org-Y.{domain}) — see packages/modules/jupyter/src/lib/ds-backend.ts:29-46 and packages/modules/jupyter/src/lib/driver.ts:202-208. That token is the user's platform-wide credential, and it is sent to a host running a tenant-supplied image whose front proxy is the only thing between it and the workload.

(Refers to lines 113-120)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +34 to +39
function redactSecrets(uri: string): string {
return uri.replace(
/([?&](?:token|access_token|refresh_token|api_key|apikey)=)[^&#]*/gi,
'$1REDACTED'
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Failed-request error messages now embed full URLs and only mask a fixed list of credential parameter names

API failures now build a message containing the full request URI, redacting only token, access_token, refresh_token, api_key and apikey. Any other credential-bearing query parameter — code, state, id_token, secret, password, signature, sig, key — survives verbatim into a message that, as the surrounding comments note, is logged and shipped to the collector.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +306 to +309
if [ -n "${AUTH_GUARD_UPSTREAM_TOKEN:-}" ]; then
GUARD_FLAGS="$GUARD_FLAGS --upstream-token ${AUTH_GUARD_UPSTREAM_TOKEN}"
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 The token that unlocks a container's notebook API is passed as a command-line argument

The upstream service token is appended to the auth guard's argv (--upstream-token ${AUTH_GUARD_UPSTREAM_TOKEN}). Anything able to read /proc/*/cmdline inside the container — which includes the tenant's own workload processes, since the guard and the service share the container — can read it, as can anything capturing process listings for diagnostics.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

MChrys and others added 9 commits August 20, 2026 21:24
The rail and Excalidraw's top-left column are both placed from
`--holistix-left-rail`, so they landed on the same x — the rule was
written to clear the layers panel, and the rail became an island on the
same surface afterwards. Measured: rail 255→311, panel 271→471, forty
pixels of overlap over three hundred and sixty-eight, which is the
column holding one control from every row.

Both are moved out by the rail's own width. The panel keeps its place
below the button rather than beside it: it is 200px, and Excalidraw
centres the shapes toolbar in the middle third — at 1280px that starts
at x=505, so a panel raised to the button's row would run into it.

The button's own menu had the same forty pixels under the rail's first
icons, unreported and found while measuring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… everything

An external drive carries its own name into the path, and those have
spaces. Unquoted, nginx counts the words in the `root` directive and
rejects the whole configuration:

    nginx: [emerg] invalid number of arguments in "root" directive

The message names the directive and not the reason, and the platform
stays up on its old config until something reloads it — so the failure
surfaces later and somewhere else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A local service is one image today, but an application is a pile — an
interface, an API, a database — and only the interface should be
reachable from the project.

The routing for that already exists: a container publishes one
`map-http-service` per door and the gateway writes one nginx block per
entry, at `{service}.uc-{cid}.org-{oid}.{domain}`. What was missing is a
way to say which doors exist at all.

`holistix.stack.yaml`, in the repository the stack is built from, so
what runs and what is reachable move with the code that changed them.
`ports` and `tunnel` are two separate lists on purpose: one list would
have made "runs" and "is public" the same word. Not exposed is the
default — publish-then-subtract makes a forgotten line the difference
between a private database and an open one.

Deliberately not a compose file. `image_id` is an allowlist key and the
broker "never accepts a command line"; a compose carries `privileged`,
`volumes`, `network_mode`. This declares, and the runner composes the
invocation. Every service names an id resolved through the image
catalogue, so a stack cannot reach an image its project could not have
started alone.

The refusals are each a failure diagnosed somewhere else: a tunnelled
port the service never opens would answer connection refused and read as
a broken tunnel; two doors of one name describe one FQDN and the loser
answers nothing; a dot in a name adds a label to the FQDN, which is built
by interpolation and cleans nothing; and `sync.from` may not leave the
repository, because this file is read by a script somebody pasted into a
terminal.

`ContainerImageRegistry.onProjectCleared` pairs the two catalogues: a
second one keyed by project is a second thing to forget, and the one
forgotten keeps serving the previous tenant. Neither has a caller yet —
tracked in TAC-366.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Local button dispatched `runner_id: 'local'` and nothing else. The
event has carried an optional `machine_id` for weeks and the platform
routes it end to end, but nothing emitted it — so every local placement
named no machine, `assertPlacementIsForUs` refused it, and no enrolled
runner ever received one. The whole local path was unreachable from the
interface.

"local" is not a destination. It is a set of machines, so the click now
opens a picker and the other runners go straight through unchanged.

The list comes from Ganymede's `GET /runners`, not from the project's
machine catalogue: that one holds only machines already heartbeating
into this project, and the first placement is what puts one there — a
picker fed from it would offer nothing, forever.

`describeMachines(runners, now)` takes the instant as an argument so the
availability rule is testable without freezing a clock. Thirty seconds,
the same number the container watchdog uses, because to somebody looking
at a card a dead machine and a dead container are one event. A runner
stamps `last_seen_at` on every authenticated request and polls every
fifteen, so that is two missed passes rather than one.

Unavailable machines are present and refused, never dropped: one that
disappears reads as one that was never enrolled. The quiet state is
named as an action — "run holistix-runner run on it" — because "offline"
would suggest the machine is off when the usual cause is that the runner
is not started.

A dialog rather than a popover, for what has to be said in it: placing a
service means other members of the project can place services on that
machine too, which TAC-156 asks to be said in the UI and not only in a
ticket.

The connected half is mounted only while the dialog is open — a hook
runs whenever its component does, and a project with several cards would
poll once per card for a list nobody is looking at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`wait` unref'd its interval timer. The intent was that a pending wait
should not delay shutdown, which is right — but unref does not apply
only when stopping. Between two ordinary passes that timer is the only
thing referencing the event loop: a pass opens no listener and holds no
socket, and `process.once` on a signal does not reference it either. So
node found nothing left to do and exited, code 0, no message.

Measured against a real Ganymede: `holistix-runner run -i 10` announced
itself once, stamped one `last_seen_at`, and was gone within twenty-five
seconds. The machine then showed in the picker as "not answering", which
is true and says nothing about why.

Shutdown was already handled by clearing the timer when `stop` resolves,
so the unref was both unnecessary and fatal.

Asserted on the timer rather than on a count of passes: a test runner
holds the event loop open by itself, which is why every pass-counting
test in this file went on passing while the built binary could not
survive its own interval.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…there

Two faults on the same path, both found walking it against a real
Ganymede for the first time.

**Moving a service did not take it off where it was.** `_setRunner`
wrote the new runner and never told the old one, so the container it
had started went on running. The old runner is now asked to stop first
— softly, because it may be an engine that is now unreachable, and
refusing the move would pin the service to a machine nobody can reach.
A container left behind can be removed by hand; a placement that cannot
be moved cannot.

**A built-in image was referenced by digest.** A digest makes the
runtime resolve the manifest at the registry even when the tag is
already on disk, and a runner holds no registry credentials — the
broker has the project's, a laptop has nothing. Measured on Apple
`container`: with the digest, `401 Unauthorized, no credentials found
for host registry-1.docker.io`; with the tag alone, the same image
started from disk in five seconds.

That is the line `TRunnerPlacement.builtin` already draws rather than a
new one: a tenant image is trusted *because* it is pinned, a built-in
because it is in this deployment's own catalogue. Only the runner paths
read this — the broker is handed `imageId` and re-resolves it host-side,
so nothing there loosens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two writers, two spellings, one container: the broker labels
`holistix.user_container`, this runner `holistix.user_container_id`.

Both set `holistix.project`, so `listOwned` did return the broker's
container — with no id on it. `planReconcile` opens by dropping anything
it cannot name:

    running.filter((c) => c.user_container_id)

so it concluded there was no container for the placement and asked to
create one whose name was already taken. Measured moving a notebook off
the platform onto a laptop: `container with id holistix_notebook_uc_msiod
already exists`, every ten seconds, with no convergence possible.

Both spellings are read now, and only the new one is written. Unifying
them would be tidier and is not this change: the broker's spelling is on
every container it has already started, and a runner that understood
only the new one would go on being unable to adopt them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
**`pack` packaged whatever was in dist/.** It checked the directory
existed and refused when it did not — but a directory from an earlier
build satisfies that perfectly. So it packaged code from before the
change, `reload-gateway.sh` installed it, and the gateway came back
running exactly what it had been running. Every step reported success.
Measured today: a whole diagnosis cycle spent looking for a fix inside
a bundle that had never contained it, twice.

It builds now. nx is incremental, so a no-op pack costs the graph read
and nothing else, and there is no case where skipping is worth shipping
the previous build. `PACK_NO_BUILD=1` covers the one thing this is not
about: packing a tree you did not build, deliberately.

**`fetch failed` was the whole message.** Node hides the reason on
`error.cause`, and on a runner that matters more than usual: the only
thing it does is talk to a platform it was pointed at, so a transport
failure *is* the failure — and the person reading it is on their own
laptop with no server log to check.

`holistix-runner status` printed those two words. The cause was
`UNABLE_TO_VERIFY_LEAF_SIGNATURE`, a mkcert certificate whose authority
node does not carry, and finding that took reading the source and
reissuing the request by hand. The remedy is one environment variable.
It now says so, and says which rootCA.pem to use — not the untracked
copy lying in the repository, which on this machine is four months
older than the authority in use.

Unrecognised codes say only themselves: a message that explains the
wrong thing sends somebody looking where the fault is not.

The wrapper binds the real fetch at module load. Installed as
`globalThis.fetch`, a bare `fetch` inside it resolves to itself — which
it did, on the first build: `Exception in PromiseRejectCallback`, on
every command, including the ones with nothing wrong. Unit tests could
not see it, so the regression test reproduces the installation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four conflicts, two of which needed a decision rather than a side.

**servers-reducer.ts** — this branch had pulled config construction out
of its two call sites into `_runnerConfig()`; main had added
`service_label_prefix` to the inline version. Taking either side would
have swallowed the other. The extraction is kept and the field moved
into it, which is what the extraction exists for: the file already
records that these two sites drifted twice, on `auth_guard.client_id`
and then on `gateway_dev`, each time a field added to one and forgotten
at the other.

**server-card.tsx** — main introduced a local `ledColor` for green =
platform, blue = somebody's machine. This branch has an exported,
tested `ledColor()` that already encodes that rule, plus yellow for the
window between the ask and the container's first report, plus a stop
outranking an earlier start. The auto-merge produced both in one scope,
which is a temporal dead zone: `const color = ledColor(container)` runs
before `const ledColor` is initialised. Helper kept, `StatusLed` pointed
back at it.

**server-card.spec.tsx** — both sides add tests, and they test different
things: this branch `isAlive`, `ledColor` and the platform badge; main
the command that overflowed the card, the LED as rendered, and presence.
Merging them in place produced invalid syntax — git had interleaved the
context, so the two sides were competing continuations of one scaffold
rather than separate blocks. Rebuilt from this side with main's three
describes appended whole, and the two imports they need.

**doc/README.md** — a line each in the same two lists. Both kept.

263 tests pass in user-containers, up from 212: main's 51 are all here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant