Lição 25 · Fusão v3 · @alembic/tui + @alembic/web ← índice
Alembic × Hermes — O Curso de Fusão v3 · Percepção & Fábrica (21–25)

@alembic/tui + @alembic/web: observar um run vivo

A VERDADE de um run mora no disco — o run-dir com events.jsonl append-only. Esta lição é sobre as LENTES que olham para ela: o TUI ASCII (alembic tui <run-id>, render puro + poll de 500ms), o cockpit web read-only sobre os run-dirs (com UMA escrita permitida: aprovações T4, append-only), e o servidor stateful do harness (alembic serve — HTTP+SSE, POST /runs inicia um run Forge de verdade). Três clientes, uma fonte.

1

A fonte única: o run-dir no disco

fusao · 19-the-swarm · s1

Nenhuma das três superfícies inventa estado. O TUI lê index.json, events.jsonl e t4-parked.jsonl direto do run-dir a cada redraw (packages/tui/src/index.ts:92–94); o cockpit se descreve como "HTTP + SSE read-only view over run directories" (packages/web/src/server.ts:20–25); e até o servidor stateful do harness anota no próprio código que "the durable run directory holds truth" (apps/cli/src/commands.ts:1856–1857). Observabilidade aqui é literalmente: ler os mesmos arquivos que o replay lê.

run-dir (a VERDADE) index.json · status events.jsonl (append-only) t4-parked.jsonl · approvals.jsonl ~/.alembic/runs/<run-id> alembic tui poll 500ms · frame 80 col alembic tail -f stream do events.jsonl alembic cockpit SSE: re-lê a cada 300ms server.ts:124–150 POST …/approve a ÚNICA escrita: approvals.jsonl (append) três lentes read-only sobre os MESMOS bytes — e uma caneta só, append-only, para o humano aprovar T4
O run-dir como fonte única; tui/tail/cockpit leem, e só o approve escreve — em ledger append-only (server.ts:260–291).

Pense como… a caixa-preta de um avião com três telas na sala de controle: nenhuma tela "sabe" nada — todas projetam a MESMA fita. Onde a analogia quebra: aqui uma das telas tem um botão físico (aprovar T4) — e ele não edita a fita, só cola um post-it datado nela.

2

O TUI ASCII: render puro + loop de 500ms

@alembic/tui separa o que é testável do que é terminal: TODA a renderização é pura — "No I/O here: these functions take structured state and return lines" (packages/tui/src/render.ts:1–6). renderRunFrame desenha uma caixa fixa de BOX_WIDTH = 80 colunas (render.ts:25) com header do run, stats (events: N | parked: N) e os eventos mais recentes como swimlanes (render.ts:95–120); a lane de um evento com taskId vira task:<id> (render.ts:61–65); o rodapé é literal: 'Press q to quit' (render.ts:118).

O loop interativo (packages/tui/src/index.ts:68–139) redesenha a cada refreshMs default de 500ms (index.ts:70), lendo os três arquivos do run-dir com parsers que NUNCA lançam — linha JSON quebrada vira evento { kind: 'raw' } (index.ts:34–51). q, Q ou Ctrl+C encerram limpo (index.ts:107–113), e o setRawMode é guardado por try para streams não-TTY (index.ts:115–121) — dá para injetar stdin/stdout fakes e testar o loop inteiro sem terminal (index.ts:16–23). Na CLI, runTui primeiro valida que o run EXISTE via readRunIndex — id errado é err antes de abrir tela (apps/cli/src/commands.ts:1906–1918).

packages/tui/src/index.ts:91–105 (o redraw, trecho real)
const redraw = async (): Promise<void> => {
  const index  = await safeReadJson(join(runDir, 'index.json'));
  const events = await safeReadJsonl(join(runDir, 'events.jsonl'));
  const parked = await safeReadJsonl(join(runDir, 't4-parked.jsonl'));
  const status = { runId: index?.runId ?? 'unknown', status: index?.status ?? 'unknown',
                   goal: index?.goal, eventCount: events.length, parkedCount: parked.length };
  const frame = renderRunFrame(status, events);   // PURO — testável sem terminal
  clearAndHideCursor(stdout);
  for (const line of frame) stdout.write(`${line}\n`);
};
3

O cockpit web: read-only + a única caneta (approve)

alembic cockpit (commands.ts:1789–1806) sobe createCockpitServer (server.ts:156+) sobre --data-dir (default ~/.alembic, porta efêmera por default — apps/cli/src/args.ts:239–243). O dashboard em / lista runs e abre um EventSource para o stream (server.ts:339); a API: GET /api/runs (server.ts:204), GET /api/runs/:id/status e GET /api/runs/:id/events (consumidos pelo próprio dashboard — server.ts:442, :454), e GET /api/marketing (server.ts:209) — a visão read-only dos manifests da fábrica da lição 23: <dataDir>/marketing/<signalId>/manifest.json, validados por schema, "nothing is ever written" (packages/web/src/marketing.ts:7–16).

O stream SSE não usa watcher mágico: pollLines re-lê o events.jsonl append-only a cada 300ms e emite só as linhas novas (server.ts:124–150); cada frame carrega id (o seq do evento), event (o kind) e o JSON em data: (encodeSseFrame, server.ts:98–105). E a exceção que confirma a regra read-only: POST /api/runs/:id/approve APPENDA um registro em approvals.jsonl (server.ts:260–291) — o gesto humano do T4-park, gravado como ledger, nunca como edição.

Qual é a ÚNICA operação de escrita que o cockpit web faz sobre um run-dir?
Correto: o servidor se define como read-only sobre run directories e "approvals are append-only writes to approvals.jsonl" (server.ts:153–155, :260–291). Ele nunca muda status (isso é do motor que executa) nem compacta o journal (o SSE re-lê incrementalmente via pollLines, :124–150).
4

alembic serve: o harness stateful que INICIA runs

alembic-completo · 0005-camada-l4-harness · s2

O cockpit observa runs que já existem; alembic serve (commands.ts:1816–1901) é outra categoria: um servidor STATEFUL do harness cujo contrato REST vive em @alembic/harnessPOST /runs, GET /runs/:runId/status, GET /runs/:runId/events (packages/harness/src/http.ts:118–121; um cliente sem SSE recebe o snapshot bufferizado dos eventos como fallback — http.ts:152–154).

O createRun injetado exige phase === 'run' COM goal e plan — senão err imediato (commands.ts:1836–1837). Com eles: loadScope materializa o escopo Forge no run-dir, um HarnessCore entra no registry (commands.ts:1848–1854) e o run parte em BACKGROUND via runGoalPlan enquanto a resposta volta imediatamente como snapshot 202 (commands.ts:1808–1815, :1858–1882) — você faz o POST, guarda o runId e passa a observar por status/SSE. Com --offline, o registry de adapters sobe com o offline como default — o servidor inteiro roda hermético (commands.ts:1828–1830).

POST /runs {phase,goal,plan,yes} phase='run' + goal E plan? faltou um ⇒ err (commands.ts:1836–1837) loadScope escopo Forge → run-dir HarnessCore registry :1854 202 já snapshot status + events SSE o run roda em BACKGROUND (runGoalPlan, :1858–1882); o run-dir durável segue sendo a verdade
alembic serve — o portão fail-closed do POST /runs e o caminho até o 202 imediato + observação por status/SSE (http.ts:118–121).
SuperfícieNaturezaFonte de estadoEscreve?
alembic tuiterminal, poll 500msrun-dir (3 arquivos)nunca
alembic cockpitHTTP+SSE read-onlyrun-dirs + marketing/só approvals.jsonl (append)
alembic serveHTTP+SSE statefulHarnessCore + run-dir durávelinicia runs (POST /runs)
Um POST /runs chega ao alembic serve com { phase: 'run', goal: 'GOAL.md' } — sem plan. O que volta?
Correto: if (body.phase !== 'run' || !body.goal || !body.plan) return err('POST /runs requires phase="run" with goal and plan paths') (commands.ts:1836–1837). Nada de plano derivado, nada de meia-execução: o contrato falha fechado antes de tocar o disco.
5

Playbook: observar um run vivo, do zero

A sequência do operador, toda read-only exceto o passo 1 (que só inicia se VOCÊ mandar):

#ComandoO que você vê
1alembic run --goal GOAL.md --plan alembic.plan.ts --yeso run parte; o run-dir nasce em ~/.alembic/runs/<id>
2alembic runs listids + status de todos os runs no data-dir
3alembic tui <run-id>o frame ASCII de 80 colunas se redesenhando a cada 500ms; q sai
4alembic tail <run-id> -fo events.jsonl cru, streaming — o mesmo journal que o replay usa
5alembic cockpitdashboard web; runs, status, SSE ao vivo, e o botão de aprovar T4
O fio que une as lições 21–25: percepção (vision/ocr) alimenta a fábrica (marketing), a fábrica publica com a cara da casa (design/docs), e TUDO que roda deixa um rastro observável por três lentes (tui/web) — cada camada com o mesmo esqueleto: ports injetados, offline default, fail-closed, e o disco como verdade.
CAMADA TÉCNICA — comandos copy-paste

Observabilidade é read-only por natureza — experimente à vontade:

# o render puro e o loop do TUI
sed -n '1,10p;95,120p' packages/tui/src/render.ts
sed -n '68,113p' packages/tui/src/index.ts

# abrir o TUI num run existente (q sai; id errado = err antes da tela)
alembic runs list
alembic tui <run-id>

# o cockpit e suas rotas (porta efêmera impressa no start)
alembic cockpit
curl -s http://localhost:<porta>/api/runs | head -c 400
curl -N http://localhost:<porta>/api/runs/<run-id>/events   # SSE ao vivo

# o contrato REST do harness + o gate do POST /runs
sed -n '116,124p' packages/harness/src/http.ts
sed -n '1834,1840p' apps/cli/src/commands.ts

# subir o serve hermético e iniciar um run via HTTP
alembic serve --offline
curl -s -X POST http://localhost:<porta>/runs \
  -H 'content-type: application/json' \
  -d '{"phase":"run","goal":"GOAL.md","plan":"alembic.plan.ts","yes":true}'
O que levar desta lição
Pergunta de acompanhamento sugerida: "por que o SSE re-lê o arquivo em vez de usar fs.watch?" — pense em NFS, editores que trocam inode e na simplicidade de um journal append-only: re-ler do offset é a versão que nunca perde evento. Próxima lição: o chão onde tudo isso roda — @alembic/infra, DevOps como código em três planos, com provisionamento que NASCE dry-run.