From d734e9470ebe2a60bdf59ffba684e2facb5997a6 Mon Sep 17 00:00:00 2001 From: Pletor Date: Wed, 21 Oct 2026 23:36:34 +0200 Subject: [PATCH 1/5] feat: add interactive process observatory --- README.md | 1 + docs/process-studio.md | 50 ++++++ src/client/process-studio.html | 81 ++++++++++ src/client/process-studio/graph.css | 38 +++++ src/client/process-studio/graph.ts | 154 +++++++++++++++++++ src/client/process-studio/index.ts | 130 ++++++++++++++++ src/client/process-studio/model.ts | 184 +++++++++++++++++++++++ src/client/process-studio/motion.ts | 60 ++++++++ src/client/process-studio/panels.ts | 99 ++++++++++++ src/client/process-studio/scenario.ts | 40 +++++ src/client/process-studio/style.css | 141 +++++++++++++++++ src/server/http/routes/index.ts | 2 + src/server/http/routes/process-studio.ts | 13 ++ tests/process-studio.test.ts | 84 +++++++++++ vite.config.ts | 1 + 15 files changed, 1078 insertions(+) create mode 100644 docs/process-studio.md create mode 100644 src/client/process-studio.html create mode 100644 src/client/process-studio/graph.css create mode 100644 src/client/process-studio/graph.ts create mode 100644 src/client/process-studio/index.ts create mode 100644 src/client/process-studio/model.ts create mode 100644 src/client/process-studio/motion.ts create mode 100644 src/client/process-studio/panels.ts create mode 100644 src/client/process-studio/scenario.ts create mode 100644 src/client/process-studio/style.css create mode 100644 src/server/http/routes/process-studio.ts create mode 100644 tests/process-studio.test.ts diff --git a/README.md b/README.md index fac9441a5..2946144ff 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/i ## What it is +- **Process Observatory (interactive demo).** Open `/process-studio` after signing in to explore a Czech-language simulation of an Opora plan, parallel office workers, failed checks and repair, GitHub delivery, and memory reuse. Animated agents follow recorded events; repository-role inputs extend the local scenario. It uses sample data and does not connect accounts or change repositories. See [the scenario, controls and integration boundary](docs/process-studio.md). - **A floor per project.** Ride the elevator, pick one of your GitHub repos, and the office clones it (showing how far along it is) and opens a floor for it. Every worker, board and queue on that floor works in that checkout. - **Workers at desks.** Walk up to an empty desk, press **E**, and pick Claude Code, Codex, OpenCode, Grok, Muse, DeepSeek Harness, Pi or Cursor, each with its model and reasoning effort. The agent's live terminal shows on its laptop, and anyone can open it and type. - **Talk instead of typing.** Hold **Ctrl+Space** (or the **🎤**) in a worker's terminal or a prompt box and say what you want: it's typed in for you to send. Your browser does the listening, so there's nothing to install. diff --git a/docs/process-studio.md b/docs/process-studio.md new file mode 100644 index 000000000..87effc310 --- /dev/null +++ b/docs/process-studio.md @@ -0,0 +1,50 @@ +# Process Observatory + +The separate `/process-studio` page is an interactive Czech-language **simulation**, built with the office and protected by its existing session authentication. After signing in, navigate to `/process-studio`. With the Vite development server, open `/process-studio.html` instead. No additional dependency or server configuration is required. + +It demonstrates how an Opora task, office workers, memory and GitHub could share an observable workflow. It does not connect to Canvas, OpenViking, GitHub or a running worker. Every event, test result, memory record, PR and merge displayed on the page is model data. + +## What the scenario demonstrates + +1. A card has a goal, scope, expected output and acceptance conditions. Recall brings earlier experiences into the plan. +2. A missing reconnect condition blocks handoff. Refinement creates a new revision, with explicit proof requirements. +3. Two workers perform parallel work in the modeled office. Their completion is distinct from passing checks. +4. A duplicate-event regression fails a check. The workflow grows a repair node and a new plan revision. Earlier checks and approvals become invalid. +5. New checks cover the repaired behavior and every added repository. Review and a sample PR become available only for the current revision. +6. The simulation stops at the human decision. **Schválit v ukázce** authorizes only the local scenario to continue; it does not merge a real PR. +7. A memory candidate is proposed from the evidenced result, then a separate event confirms persistence. A subsequent task recalls it. Reuse is not presented as measured improvement. + +The precise acceptance conditions and evidence are available under **Plán** and **Detail**. The spider animation only interpolates positions from state; it never controls a transition or supplies evidence. + +## Controls + +- **Tok procesu / Vazby paměti** switches between the workflow and an illustrative context/evidence relationship view. The latter does not depict an LLM's internal neurons. +- **Pozastavit**, **Krok**, speed and restart control the deterministic scenario. Reduced-motion preference starts it paused and suppresses procedural motion. +- Select a node with a pointer or Enter/Space to inspect its status, revision and evidence. +- The history slider restores an earlier snapshot for viewing. It pauses playback and hides the approval action. Returning to the latest event does not automatically resume playback. +- **Exportovat stopu** downloads JSON with `schemaVersion`, `mode: "simulation"`, the run, configured repositories and observed events. The application does not import arbitrary event files. +- The information dialog closes with its top-right X or Escape and restores focus to its opener. This standalone page has no 3D player or mouse-look to restore. + +## Adding a repository to the model + +The **Moduly** pane opens by default. Supply an exact `https://github.com/owner/repository` URL and choose a role: context source, implementation module or checking service. Up to four additional repositories can participate. + +Adding one starts a new model run and clears that run's previous history. Export first if the previous trace is needed. The extension receives a visible graph node, an inspect/verify step, a plan entry and a mandatory inclusion in the subsequent checks. It is kept only in page memory; reloading clears it. Duplicate identities and malformed URLs are rejected. URL values are rendered as text, not HTML, and are never fetched or cloned. + +This describes how an integration contract could appear. It does not infer an unfamiliar repository's architecture or execute any of its functions. A functioning integration still needs an authenticated adapter and independently verified input/output semantics. + +## Implementation and future integration boundary + +- `src/client/process-studio/model.ts`: pure state reducer, event identity/order, plan revisions and evidence gates. +- `scenario.ts`: the explicitly simulated producer; repository extensions insert their steps here. +- `graph.ts` / `motion.ts`: graph projection and separate visual interpolation. +- `panels.ts` / `index.ts`: inspection, playback, history, extension form and trace export. +- The Vite input registry and `http/routes/index.ts` register this entry independently of the 3D office and its state store. + +Future live adapters need to correlate `run`, task, worker, repository, plan revision, operation/attempt and evidence identity. They must translate actual service observations into domain events, authenticate the source, handle reconnect snapshots and persist events. The demo reducer is **not** an authorization boundary or a production ingestion API: it consumes a local typed scenario, not untrusted service messages. + +In particular, OpenViking session submission is not confirmation that asynchronous memory processing finished. Recall only proves that context was supplied, not that it caused a better answer. A claim of improvement requires a comparable evaluation, outside this demonstration. + +## Verification + +Run `npm run typecheck`, `npm test`, and `npm run build`. `tests/process-studio.test.ts` checks the complete scenario and failure paths: missing plan conditions, invalid/stale evidence, blocked merge, duplicate/out-of-order observations, missing repository coverage and memory lifecycle ordering. Use a headless browser to exercise the actual page, including the repository form, replay and human gate, and capture both graph views. diff --git a/src/client/process-studio.html b/src/client/process-studio.html new file mode 100644 index 000000000..427a9201e --- /dev/null +++ b/src/client/process-studio.html @@ -0,0 +1,81 @@ + + + + + + + Opora · Process Observatory + + + +
+
+ OPORA / + PROCESS OBSERVATORY agent-office +
INTERAKTIVNÍ DEMO
+ +
+
+

PLÁN, PRÁCE A ZKUŠENOST V JEDNOM OBRAZU

Sledujte, jak vzniká výsledek.

Workflow roste. Agenti pracují. Každý výsledek má svůj důkaz.

+
+ + + + +
+
+
+
PLÁN OPORYrevize 1čeká na doplnění
+
DOLOŽENÉ KONTROLY0 / 3typy · testy · sestavení
+
PROPOJENÉ MODULY4Opora · kancelář · paměť · GitHub
+
PAMĚŤ AGENTAčekápřínos zatím nezměřen
+
+
+ +
+
DEMO-042
+
+
● Práce● Doloženo● Čeká / chyba● PaměťOtevřete libovolný uzel
+
●
ŽIVÁ UKÁZKA

Připravujeme první verzi plánu.

+ +
+ +
+
+
STOPA BĚHU0 událostí
+ +
+
+
OPORA / PROCESS OBSERVATORY 02Plán → důkazy → výsledek → paměť → další úkolSimulovaný zdroj událostí · žádné připojené účty
+
+

JAK UKÁZKA FUNGUJE

Pohyb následuje události.

Čtyři pavouci představují plánovače, vývojáře, kontrolora a správu paměti. Každý se přesouvá podle konkrétní události scénáře. Pohyb neřídí dokončení práce.

Plán obsahuje podmínky a důkazy. Změna revize zneplatní staré kontroly. Při chybě vznikne opravný krok. Výsledek čeká na rozhodnutí člověka.

OpenViking je zobrazen jako navržená paměťová integrace: načtení → návrh zkušenosti → potvrzení zápisu → opětovné použití. To samo nedokazuje vyšší kvalitu agenta.

Moduly rozšiřují tento lokální model. Skutečné napojení vyžaduje adaptéry služeb, jejich autentizaci a ověřené události. V této verzi žádné takové spojení nevzniká.

+ diff --git a/src/client/process-studio/graph.css b/src/client/process-studio/graph.css new file mode 100644 index 000000000..7c8e36bed --- /dev/null +++ b/src/client/process-studio/graph.css @@ -0,0 +1,38 @@ +.lane-label { fill: #9aa8c0; font: 10px var(--mono); letter-spacing: 1.5px; } +.connection { fill: none; stroke: #6c4b683f; stroke-width: 1.5; } +.connection.transmitting { stroke: #f68dc492; stroke-dasharray: 5 7; animation: signal-flow 1.4s linear infinite; } +.connection.memory-return { stroke: #a183d35e; stroke-dasharray: 3 6; } +.process-card { cursor: pointer; outline: none; } +.card-surface { fill: #151d2a; stroke: #354258; stroke-width: 1; transition: fill .3s, stroke .3s; } +.card-accent { stroke: currentColor; stroke-width: 2; opacity: .65; } +.card-owner { fill: #a9b6cb; font: 8px var(--mono); letter-spacing: .8px; } +.card-title { fill: #e7edf8; font: 500 16px 'Segoe UI', sans-serif; } +.card-subtitle { fill: #8f9fb7; font: 10px var(--mono); } +.card-status { fill: #a5b4c9; font: 10px var(--mono); } +.state-light { fill: #44526a; } +.process-card.working .card-surface { fill: #291d31; stroke: currentColor; } +.process-card.working .state-light { fill: currentColor; filter: drop-shadow(0 0 4px currentColor); } +.process-card.done .card-surface { stroke: #3d6b58; } +.process-card.done .state-light { fill: #8bd7ae; } +.process-card.failed .card-surface, .process-card.blocked .card-surface { stroke: #e8ba76; fill: #2a241f; } +.process-card.failed .state-light, .process-card.blocked .state-light { fill: #e8ba76; } +.process-card.selected .card-surface { stroke: #b3f8f0; stroke-width: 2; } +.process-card:focus .card-surface, .process-card:hover .card-surface { stroke: #fff; stroke-width: 2; } +.spider { color: var(--spider); pointer-events: none; opacity: .6; } +.spider.engaged { opacity: 1; } +.spider-aura { fill: currentColor; opacity: .07; } +.spider-body { fill: currentColor; filter: drop-shadow(0 0 6px currentColor); } +.spider-head { fill: currentColor; } +.spider-leg { stroke: currentColor; stroke-width: 1.3; stroke-linecap: round; } +.network-dot { stroke: #d6e5f3; stroke-width: .7; fill: #b99aff; } +.network-dot.c0 { fill: #6fe2dc; }.network-dot.c3 { fill: #ff78b5; } +.network-label { fill: #bbc9dc; font: 11px var(--mono); paint-order: stroke; stroke: #10151e; stroke-width: 4; stroke-linejoin: round; } +.network-link { fill: none; stroke: #a68ad2; stroke-width: 1; opacity: .25; } +.network-link.c0 { stroke: #6fe2dc; }.network-link.c2 { stroke: #ff78b5; } +.network-note { fill: #8fabc2; font: 10px var(--mono); } +.memory-network[data-memory='recalled'] .c0, .memory-network[data-memory='proposed'] .c1, +.memory-network[data-memory='persisted'] .c2, .memory-network[data-memory='reused'] .c2 { opacity: 1; } +.memory-network[data-memory='reused'] .network-link.c2 { stroke-dasharray: 5 9; animation: signal-flow 1.6s linear infinite; } +.paused .connection, .paused .network-link { animation-play-state: paused !important; } +@keyframes signal-flow { to { stroke-dashoffset: -48; } } +@media (prefers-reduced-motion: reduce) { .connection, .network-link { animation: none !important; }.card-surface { transition: none; } } diff --git a/src/client/process-studio/graph.ts b/src/client/process-studio/graph.ts new file mode 100644 index 000000000..c0cae2d9a --- /dev/null +++ b/src/client/process-studio/graph.ts @@ -0,0 +1,154 @@ +import { ROLE_LABELS, type StudioState } from './model'; + +const NS = 'http://www.w3.org/2000/svg'; +export function svgEl(tag: K, attrs: Record, parent: Element): SVGElementTagNameMap[K] { + const el = document.createElementNS(NS, tag); + for (const [key, value] of Object.entries(attrs)) el.setAttribute(key, String(value)); + parent.append(el); return el; +} +export interface Position { x: number; y: number } +interface Card extends Position { id: string; title: string; subtitle: string; owner: string; color: string; height?: number } +const CARDS: Card[] = [ + { id: 'plan', x: 42, y: 65, title: 'Živý plán', subtitle: 'Cíl · rozsah · kroky · vlastníci', owner: 'OPORA / OP-128', color: 'pink' }, + { id: 'context', x: 318, y: 65, title: 'Kontext projektu', subtitle: 'Soubory · rozhraní · závislosti', owner: 'REPOZITÁŘE', color: 'cyan' }, + { id: 'recall', x: 594, y: 65, title: 'Načtení paměti', subtitle: 'Zkušenosti s doloženým původem', owner: 'OPENVIKING / RECALL', color: 'purple' }, + { id: 'contract', x: 870, y: 65, title: 'Podmínky dokončení', subtitle: 'Každé tvrzení potřebuje důkaz', owner: 'OPORA / KONTRAKT', color: 'pink' }, + { id: 'office', x: 42, y: 295, title: 'Kancelář agentů', subtitle: 'Předání revize · izolované větve', owner: 'AGENT-OFFICE', color: 'cyan' }, + { id: 'frontend', x: 318, y: 295, title: 'Zobrazení průběhu', subtitle: 'Vývojář A · vlastní worktree', owner: 'VÝVOJ / FRONTEND', color: 'pink' }, + { id: 'backend', x: 594, y: 295, title: 'Zpracování událostí', subtitle: 'Vývojář B · vlastní worktree', owner: 'VÝVOJ / BACKEND', color: 'cyan' }, + { id: 'checks', x: 870, y: 295, title: 'Kontroly kvality', subtitle: 'Typy · regrese · testy · build', owner: 'KONTROLOR / CI', color: 'amber' }, + { id: 'learn', x: 42, y: 565, title: 'Nová zkušenost', subtitle: 'Návrh → zápis → další použití', owner: 'OPENVIKING / PAMĚŤ', color: 'purple' }, + { id: 'merge', x: 318, y: 565, title: 'Potvrzený výsledek', subtitle: 'Rozhodnutí člověka · receipt', owner: 'GITHUB → OPORA', color: 'green' }, + { id: 'pr', x: 594, y: 565, title: 'Pull request', subtitle: 'Změny + důkazy + vazba na kartu', owner: 'GITHUB / PR #214 · DEMO', color: 'cyan' }, + { id: 'review', x: 870, y: 565, title: 'Revize vůči plánu', subtitle: 'Pokrytí podmínek a aktuální verze', owner: 'KONTROLOR / REVIEW', color: 'amber' }, +]; +const PATHS: [string, string, string][] = [ + ['plan', 'context', 'M277 126 H318'], ['context', 'recall', 'M553 126 H594'], ['recall', 'contract', 'M829 126 H870'], + ['contract', 'office', 'M987 187 V221 Q987 231 977 231 H169 Q159 231 159 241 V295'], + ['office', 'frontend', 'M277 356 H318'], ['office', 'backend', 'M159 417 V441 H711 V417'], + ['frontend', 'checks', 'M435 417 V433 H987 V417'], ['backend', 'checks', 'M829 356 H870'], + ['checks', 'review', 'M987 417 V565'], ['review', 'pr', 'M870 626 H829'], + ['pr', 'merge', 'M594 626 H553'], ['merge', 'learn', 'M318 626 H277'], + ['learn', 'recall', 'M42 626 H20 V206 H711 V187'], +]; +const statusNames = { queued: 'VE FRONTĚ', working: 'PRACUJE', done: 'DOLOŽENO', blocked: 'ČEKÁ', failed: 'CHYBA' }; +function label(parent: Element, x: number, y: number, text: string, cls: string) { + const el = svgEl('text', { x, y, class: cls }, parent); el.textContent = text; return el; +} +export class ProcessGraph { + readonly svg: SVGSVGElement; + readonly actors: SVGGElement; + private cards = new Map(); + private edges: { from: string; to: string; element: SVGPathElement }[] = []; + private nodeLayer: SVGGElement; + private edgeLayer: SVGGElement; + private topology = ''; + private network?: SVGGElement; + private flow: SVGGElement; + lens: 'process' | 'memory' = 'process'; + constructor(host: HTMLElement, private select: (id: string) => void) { + this.svg = svgEl('svg', { viewBox: '0 0 1150 740', role: 'group', 'aria-label': 'Živý model procesu, uzly lze otevřít klávesou Enter' }, host); + const defs = svgEl('defs', {}, this.svg); + const marker = svgEl('marker', { id: 'flow-arrow', viewBox: '0 0 8 8', refX: 7, refY: 4, markerWidth: 5, markerHeight: 5, orient: 'auto-start-reverse' }, defs); + svgEl('path', { d: 'M0 0 L8 4 L0 8', fill: 'context-stroke' }, marker); + this.flow = svgEl('g', {}, this.svg); + this.edgeLayer = svgEl('g', { class: 'connections' }, this.flow); + this.nodeLayer = svgEl('g', {}, this.flow); + label(this.flow, 42, 35, '01 / ZADÁNÍ, KONTEXT A PŘIJATÝ PLÁN', 'lane-label'); + label(this.flow, 42, 270, '02 / PARALELNÍ PRÁCE A OVĚŘENÍ', 'lane-label'); + label(this.flow, 42, 540, '03 / PAMĚŤ ← VÝSLEDEK ← PŘEDÁNÍ', 'lane-label'); + for (const card of CARDS) this.addCard(card); + for (const [from, to, d] of PATHS) this.addEdge(from, to, d); + this.actors = svgEl('g', { class: 'actors', 'aria-hidden': 'true' }, this.svg); + } + private addEdge(from: string, to: string, d: string) { + const element = svgEl('path', { d, class: 'connection', 'marker-end': 'url(#flow-arrow)' }, this.edgeLayer); + this.edges.push({ from, to, element }); + } + private addCard(spec: Card) { + const g = svgEl('g', { transform: `translate(${spec.x} ${spec.y})`, class: `process-card ${spec.color}`, tabindex: 0, role: 'button', 'aria-label': spec.title, 'data-node': spec.id }, this.nodeLayer); + const h = spec.height ?? 122; + svgEl('rect', { width: 235, height: h, rx: 9, class: 'card-surface' }, g); + svgEl('path', { d: 'M13 0 H58', class: 'card-accent' }, g); + label(g, 15, 23, spec.owner, 'card-owner'); label(g, 15, 49, spec.title, 'card-title'); + if (h > 90) label(g, 15, 69, spec.subtitle, 'card-subtitle'); + label(g, 15, h - 18, '', 'card-status'); + svgEl('circle', { cx: 217, cy: 20, r: 3, class: 'state-light' }, g); + g.addEventListener('click', () => this.select(spec.id)); + g.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.select(spec.id); } }); + this.cards.set(spec.id, { spec, group: g }); + } + position(id: string): Position { + const card = this.cards.get(id)?.spec ?? CARDS[0]; + return { x: card.x + 190, y: card.y + (card.height ?? 122) - 24 }; + } + update(s: StudioState, selected: string) { + const topology = s.repositories.map(r => r.id).join(',') + ':' + s.repairRequired; + if (topology !== this.topology) { + for (const [id, c] of this.cards) if (!CARDS.some(base => base.id === id)) { c.group.remove(); this.cards.delete(id); } + this.edges = this.edges.filter(e => { const keep = !e.from.startsWith('repo-') && e.from !== 'repair' && e.to !== 'repair'; if (!keep) e.element.remove(); return keep; }); + if (s.repairRequired) { + this.addCard({ id: 'repair', x: 594, y: 445, title: 'R-01 · oprava duplicity', subtitle: '', owner: 'NOVÝ KROK / REVIZE 3', color: 'pink', height: 90 }); + this.addEdge('checks', 'repair', 'M987 417 V488 H829'); + this.addEdge('repair', 'checks', 'M711 454 V445 H1124 V356 H1105'); + } + for (const [i, repo] of s.repositories.entries()) { + const x = 42 + 276 * i; + this.addCard({ id: repo.id, x, y: 755, title: repo.name.split('/').at(-1)!.slice(0, 24), subtitle: ROLE_LABELS[repo.role], owner: 'PŘIDANÝ MODUL / DEMO', color: 'cyan' }); + this.addEdge(repo.id, repo.role === 'context' ? 'context' : repo.role === 'check' ? 'checks' : 'office', `M${x + 117} 755 V720 H1134 V250 H${repo.role === 'check' ? 987 : repo.role === 'context' ? 435 : 159} V${repo.role === 'context' ? 187 : 295}`); + } + this.svg.setAttribute('viewBox', `0 0 1150 ${s.repositories.length ? 915 : 740}`); + this.topology = topology; + } + for (const [id, { group, spec }] of this.cards) { + const state = s.nodes[id]; if (!state) continue; + group.setAttribute('class', `process-card ${spec.color} ${state.status}${id === selected ? ' selected' : ''}`); + group.querySelector('.card-status')!.textContent = id === 'plan' ? `REVIZE ${s.revision} / ${statusNames[state.status]}` : state.detail; + group.setAttribute('aria-label', `${spec.title}: ${state.detail}`); + } + for (const e of this.edges) e.element.setAttribute('class', `connection${s.active === e.to || s.active === e.from ? ' transmitting' : ''}${e.from === 'learn' ? ' memory-return' : ''}`); + this.updateNetwork(s); + } + setLens(lens: 'process' | 'memory') { + this.lens = lens; this.flow.style.display = lens === 'process' ? '' : 'none'; + this.actors.style.display = lens === 'process' ? '' : 'none'; + if (this.network) this.network.style.display = lens === 'memory' ? '' : 'none'; + } + private updateNetwork(s: StudioState) { + if (!this.network) { + this.network = svgEl('g', { class: 'memory-network' }, this.svg); + const columns = [ + { x: 100, labels: ['OP-128', 'AC-01', 'AC-02', 'AC-03', 'AC-04', 'AC-05', 'M-017', 'M-023'], title: 'KONTEXT A PODMÍNKY' }, + { x: 405, labels: ['Plán', 'Frontend', 'Backend', 'R-01', 'Kontroly', 'Review', 'Pull request', 'Sloučení'], title: 'KROKY A VÝSLEDKY' }, + { x: 710, labels: ['Zdroj', 'Původní obsah', 'Oprava', 'Důkazy', 'Kandidát', 'Potvrzení'], title: 'KONSOLIDACE PAMĚTI' }, + { x: 1005, labels: ['M-024', 'OP-129', 'Načteno', 'Přínos nezměřen'], title: 'DALŠÍ POUŽITÍ' }, + ]; + const paths = svgEl('g', {}, this.network); + // Authored semantic relationships, not all-to-all decorative neural weights. + const relations = [ + [[0, 0], [0, 1], [0, 2], [1, 0], [1, 2], [2, 3], [2, 4], [3, 2], [3, 4], [4, 4], [4, 5], [5, 5], [5, 6], [5, 7], [6, 2], [6, 4], [7, 3], [7, 4]], + [[0, 0], [1, 0], [2, 0], [3, 2], [4, 3], [5, 3], [6, 0], [7, 3], [3, 4], [4, 4], [7, 5]], + [[0, 0], [1, 0], [2, 0], [3, 0], [4, 0], [5, 0], [5, 1], [5, 2]], + ]; + for (let c = 0; c < columns.length; c++) { + const col = columns[c]; + label(this.network, col.x - 50, 50, col.title, 'lane-label'); + col.labels.forEach((t, i) => { + const y = 110 + i * 65; + svgEl('circle', { cx: col.x, cy: y, r: 7, class: `network-dot c${c}` }, this.network!); + label(this.network!, col.x + 17, y + 4, t, 'network-label'); + if (c < columns.length - 1) { + const next = columns[c + 1]; + for (const [, j] of relations[c].filter(([from]) => from === i)) { + const endY = 110 + j * 65; + svgEl('path', { d: `M${col.x} ${y} C${col.x + 140} ${y} ${next.x - 140} ${endY} ${next.x} ${endY}`, class: `network-link c${c}` }, paths); + } + } + }); + } + label(this.network, 100, 690, 'Modelové vazby zkušenosti a jejích zdrojů. Zobrazení nepředstavuje neurony jazykového modelu.', 'network-note'); + this.network.style.display = this.lens === 'memory' ? '' : 'none'; + } + this.network.setAttribute('data-memory', s.memory); + } +} diff --git a/src/client/process-studio/index.ts b/src/client/process-studio/index.ts new file mode 100644 index 000000000..ac94b0046 --- /dev/null +++ b/src/client/process-studio/index.ts @@ -0,0 +1,130 @@ +import './style.css'; +import { initialState, parseRepository, reduceEvent, type Repository, type StudioEvent, type StudioState } from './model'; +import { eventFor, scenario } from './scenario'; +import { ProcessGraph } from './graph'; +import { SpiderMotion } from './motion'; +import { $, renderPanels, showPanel, type Panel } from './panels'; + +let repositories: Repository[] = []; +let runNumber = 42; +let state = initialState(); +let scenes = scenario(repositories); +let sceneIndex = 0; +let selected = 'plan'; +let events: StudioEvent[] = []; +let snapshots: StudioState[] = [structuredClone(state)]; +let replayIndex: number | null = null; +let running = !matchMedia('(prefers-reduced-motion: reduce)').matches; +let timer: ReturnType | undefined; +let follow = true; + +const graph = new ProcessGraph($('#graph'), id => { + selected = id; follow = false; showPanel('detail'); render(); +}); +const motion = new SpiderMotion(graph); + +function render() { + const displayed = replayIndex === null ? state : snapshots[replayIndex]; + graph.update(displayed, selected); + motion.update(displayed, running && replayIndex === null); + renderPanels(displayed, selected, events, replayIndex !== null); + $('#play').textContent = running ? 'Ⅱ Pozastavit' : state.finished && replayIndex === null ? '↺ Přehrát znovu' : '▶ Přehrát'; + $('#history-label').textContent = replayIndex === null ? `${events.length} událostí · doložená stopa ukázky` : `Historie ${replayIndex} / ${events.length}`; + const timeline = $('#timeline'); timeline.max = String(snapshots.length - 1); timeline.value = String(replayIndex ?? snapshots.length - 1); + timeline.disabled = events.length === 0; + $('#return-live').hidden = replayIndex === null; + $('#step').setAttribute('aria-disabled', String(state.waiting || state.finished)); + document.body.classList.toggle('paused', !running); +} +function stop() { running = false; clearTimeout(timer); } +function schedule() { + clearTimeout(timer); + if (running && !state.waiting && !state.finished && replayIndex === null) { + const speed = Number($('#speed').value); + timer = setTimeout(() => { advance(); schedule(); }, 3100 / speed); + } +} +function apply(event: StudioEvent): boolean { + const result = reduceEvent(state, event); + if (result.error) { stop(); $('#repo-feedback').textContent = result.error; return false; } + state = result.state; events.push(event); snapshots.push(structuredClone(state)); + if (follow) selected = state.active; + if (state.waiting || state.finished) stop(); + return true; +} +function advance() { + if (state.waiting || state.finished || replayIndex !== null || !scenes[sceneIndex]) { render(); return; } + if (apply(eventFor(scenes[sceneIndex], state.run, state.sequence + 1))) sceneIndex++; + render(); +} +function reset(autoplay = true) { + stop(); runNumber++; state = initialState(repositories, `DEMO-${String(runNumber).padStart(3, '0')}`); + scenes = scenario(repositories); sceneIndex = 0; events = []; snapshots = [structuredClone(state)]; + replayIndex = null; selected = 'plan'; follow = true; running = autoplay; + advance(); schedule(); +} +$('#play').addEventListener('click', () => { + if (running) stop(); + else if (state.finished && replayIndex === null) reset(); + else { replayIndex = null; running = !state.waiting && !state.finished; schedule(); } + render(); +}); +$('#step').addEventListener('click', () => { stop(); replayIndex = null; advance(); }); +$('#reset').addEventListener('click', () => reset(running)); +$('#speed').addEventListener('change', schedule); +$('#approve').addEventListener('click', () => { + if (replayIndex !== null) return; + if (apply(eventFor({ type: 'human.approve', message: 'Rozhodnutí člověka zaznamenáno pro aktuální revizi. Ukázka může pokračovat.' }, state.run, state.sequence + 1))) { + running = true; render(); schedule(); + } +}); +for (const name of ['detail', 'plan', 'memory', 'repos'] as const) { + $(`#tab-${name}`).addEventListener('click', () => showPanel(name)); + $(`#tab-${name}`).addEventListener('keydown', event => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + const names: Panel[] = ['detail', 'plan', 'memory', 'repos']; + const next = names[(names.indexOf(name) + (event.key === 'ArrowRight' ? 1 : 3)) % 4]; + showPanel(next); $(`#tab-${next}`).focus(); + }); +} +$('#show-plan').addEventListener('click', () => showPanel('plan')); +for (const [button, lens] of [['process-view', 'process'], ['memory-view', 'memory']] as const) { + $(`#${button}`).addEventListener('click', () => { + graph.setLens(lens); + for (const id of ['process-view', 'memory-view']) { $(`#${id}`).classList.toggle('active', id === button); $(`#${id}`).setAttribute('aria-pressed', String(id === button)); } + if (lens === 'memory') showPanel('memory'); + }); +} +$('#repo-form').addEventListener('submit', e => { + e.preventDefault(); + if (replayIndex !== null) { $('#repo-feedback').textContent = 'Nejprve se vraťte k poslední události.'; return; } + try { + const repository = parseRepository($('#repo-url').value, $('#repo-role').value, repositories); + repositories = [...repositories, repository]; + $('#repo-feedback').textContent = `${repository.name} je zahrnut do nového modelového běhu. Skutečné připojení nevzniklo.`; + $('#repo-url').value = ''; + reset(!matchMedia('(prefers-reduced-motion: reduce)').matches); + } catch (error) { $('#repo-feedback').textContent = error instanceof Error ? error.message : 'Repozitář nelze přidat.'; } +}); +$('#repo-role').addEventListener('change', () => { + const role = $('#repo-role').value; + $('#role-description').textContent = role === 'context' ? 'Přidá načtení zdrojů, jejich původ a ověření kontextu.' : role === 'module' ? 'Přidá předání vstupu, výstup modulu a společný test rozhraní.' : 'Přidá kontrolní službu a povinný výsledek do podmínek předání.'; +}); +$('#timeline').addEventListener('input', () => { stop(); replayIndex = Number($('#timeline').value); render(); }); +$('#return-live').addEventListener('click', () => { replayIndex = null; render(); }); +$('#export').addEventListener('click', () => { + const data = { schemaVersion: 1, mode: 'simulation', run: state.run, repositories, events }; + const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })); + const a = document.createElement('a'); a.href = url; a.download = `opora-${state.run.toLowerCase()}.json`; a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +}); +let resumeAfterDialog = false; +$('#about-open').addEventListener('click', () => { + resumeAfterDialog = running; stop(); render(); $('#about').showModal(); +}); +$('#about-close').addEventListener('click', () => $('#about').close()); +$('#about').addEventListener('close', () => { running = resumeAfterDialog && !state.waiting && !state.finished && replayIndex === null; render(); schedule(); }); +window.addEventListener('pagehide', () => { clearTimeout(timer); motion.destroy(); }); +window.addEventListener('pageshow', e => { if (e.persisted) location.reload(); }); +advance(); schedule(); diff --git a/src/client/process-studio/model.ts b/src/client/process-studio/model.ts new file mode 100644 index 000000000..ba5a37a67 --- /dev/null +++ b/src/client/process-studio/model.ts @@ -0,0 +1,184 @@ +/** Deterministic, browser-only demonstration. No provider or repository mutations. */ +export type Status = 'queued' | 'working' | 'done' | 'blocked' | 'failed'; +export type RepoRole = 'context' | 'module' | 'check'; +export interface Repository { id: string; name: string; url: string; role: RepoRole } +export interface NodeState { status: Status; detail: string } +export interface Evidence { id: string; node: string; title: string; detail: string; revision: number } +export interface StudioState { + run: string; sequence: number; revision: number; accepted: boolean; + missing: string[]; repositories: Repository[]; nodes: Record; + checksRevision: number; reviewRevision: number; prRevision: number; approvalRevision: number; + repaired: boolean; repairRequired: boolean; waiting: boolean; merged: boolean; finished: boolean; + memory: 'empty' | 'recalled' | 'proposed' | 'persisted' | 'reused'; + evidence: Evidence[]; seen: string[]; headline: string; active: string; actor: string; +} +export type EventType = 'plan.draft' | 'memory.recall' | 'plan.gap' | 'plan.refine' | 'plan.accept' + | 'repo.inspect' | 'repo.verified' | 'work.assign' | 'work.start' | 'work.complete' + | 'checks.start' | 'checks.fail' | 'repair.plan' | 'repair.start' | 'repair.complete' + | 'checks.pass' | 'review.pass' | 'pr.open' | 'human.wait' | 'human.approve' + | 'merge.confirm' | 'memory.propose' | 'memory.persist' | 'memory.reuse' | 'run.finish'; +export interface StudioEvent { + id: string; run: string; sequence: number; type: EventType; message: string; + node?: string; repositoryIds?: string[]; +} +export const BASE_NODES = ['plan', 'context', 'recall', 'contract', 'office', 'frontend', 'backend', 'checks', 'learn', 'merge', 'pr', 'review']; +export const ROLE_LABELS: Record = { context: 'Zdroj kontextu', module: 'Vývojový modul', check: 'Kontrolní služba' }; +export const ROLE_CONTRACTS: Record = { + context: { input: 'Dotaz, rozsah úkolu a revize zdrojů', output: 'Vybrané zdroje s původem předané do kontextu', check: 'Relevantnost zdrojů, dostupnost a dohledatelná verze' }, + module: { input: 'Přijatá karta, datové rozhraní a závislosti', output: 'Změna v izolované větvi a verzovaný výstup modulu', check: 'Test rozhraní, regrese a kompatibilita s navazujícími kroky' }, + check: { input: 'Konkrétní změna a aktuální revize podmínek', output: 'Výsledek kontroly se stavem a odkazem na důkaz', check: 'Povinný úspěšný výsledek pro stejný rozsah a revizi' }, +}; +export const REQUIREMENTS = [ + { id: 'AC-01', title: 'Každý krok patří ke kartě a revizi plánu', proof: 'Test vazby taskId + planRevision', owner: 'Plánovač + backend', expected: 'Každá událost nese OP-128 a revizi svého zadání.' }, + { id: 'AC-02', title: 'Dvojí událost nevytvoří druhý výsledek', proof: 'Regresní test opakované události', owner: 'Backend', expected: 'Dvě doručení stejného eventId vytvoří právě jeden záznam.' }, + { id: 'AC-03', title: 'Obnovení spojení zachová skutečný stav', proof: 'Test obnovy ze snapshotu a událostí', owner: 'Frontend + backend', expected: 'Snapshot s následnými událostmi dává stejný stav jako souvislý běh.' }, + { id: 'AC-04', title: 'Změna plánu zneplatní staré kontroly', proof: 'Test kontroly vůči aktuální revizi', owner: 'Kontrolor', expected: 'Kontrola revize 2 nesmí umožnit předání revize 3.' }, + { id: 'AC-05', title: 'Výsledek má testy, revizi a potvrzené sloučení', proof: 'Kontroly + review + receipt GitHubu', owner: 'Kontrolor + člověk', expected: 'Karta má aktuální důkazy a samostatné potvrzení sloučení.' }, +]; +export function initialState(repositories: Repository[] = [], run = 'DEMO-042'): StudioState { + return { + run, sequence: 0, revision: 1, accepted: false, missing: ['AC-03'], repositories: structuredClone(repositories), + nodes: Object.fromEntries([...BASE_NODES, ...repositories.map(r => r.id)].map(id => [id, { status: 'queued', detail: 'Čeká na vstup' }])), + checksRevision: 0, reviewRevision: 0, prRevision: 0, approvalRevision: 0, + repaired: false, repairRequired: false, waiting: false, merged: false, finished: false, + memory: 'empty', evidence: [], seen: [], headline: 'Připravujeme kartu Opory a první verzi plánu.', active: 'plan', actor: 'planner', + }; +} +export function readyForReview(s: StudioState): boolean { + return s.accepted && s.checksRevision === s.revision && s.nodes.frontend.status === 'done' + && s.nodes.backend.status === 'done' && (!s.repairRequired || s.repaired); +} +export function canMerge(s: StudioState): boolean { + return readyForReview(s) && s.reviewRevision === s.revision && s.prRevision === s.revision && s.approvalRevision === s.revision; +} +function invalidate(s: StudioState) { + s.revision++; s.accepted = false; s.checksRevision = s.reviewRevision = s.prRevision = s.approvalRevision = 0; + s.waiting = false; + for (const id of ['checks', 'review', 'pr', 'merge']) s.nodes[id] = { status: 'queued', detail: 'Vyžaduje novou revizi důkazu' }; +} +/** Reject duplicates, out-of-order observations and unsupported transitions before changing state. */ +export function reduceEvent(previous: StudioState, event: StudioEvent): { state: StudioState; error?: string } { + const reject = (error: string) => ({ state: previous, error }); + if (event.run !== previous.run) return reject('Událost patří jinému běhu.'); + if (previous.seen.includes(event.id)) return reject('Duplicitní událost byla ignorována.'); + if (event.sequence !== previous.sequence + 1) return reject('Chybí předchozí událost.'); + if (previous.finished) return reject('Běh je již dokončen.'); + const s = structuredClone(previous); + const node = (id: string, status: Status, detail: string) => { s.nodes[id] = { status, detail }; s.active = id; }; + const proof = (id: string, title: string, detail: string) => s.evidence.push({ id: event.id, node: id, title, detail, revision: s.revision }); + const workersDone = s.nodes.frontend.status === 'done' && s.nodes.backend.status === 'done'; + switch (event.type) { + case 'plan.draft': + if (s.sequence !== 0) return reject('Plán už existuje.'); + node('plan', 'working', 'OP-128 · revize 1'); s.actor = 'planner'; + proof('plan', 'Zadání OP-128', 'Cíl: dohledatelný průběh práce napříč Oporou, kanceláří a GitHubem. Výstup: otestovaná změna a propojené PR.'); break; + case 'memory.recall': + if (s.nodes.plan.status === 'queued') return reject('Nejdříve je potřeba zadání.'); + node('context', 'done', '3 zdroje · původ dohledán'); node('recall', 'done', '2 zkušenosti načteny'); s.memory = 'recalled'; s.actor = 'memory'; + proof('recall', 'Načtený kontext', 'M-017: při reconnectu obnov snapshot. M-023: klíč události musí být idempotentní. Zdroj: dva dřívější modelové běhy; vyhledáno a předáno do kontextu.'); break; + case 'plan.gap': + node('contract', 'blocked', 'Chybí AC-03'); s.actor = 'planner'; + proof('contract', 'Mezera v plánu', 'Není popsáno chování po přerušení spojení. Bez očekávaného výsledku a testu nelze předat práci.'); break; + case 'plan.refine': + if (s.nodes.contract.status !== 'blocked') return reject('Plán nemá identifikovanou mezeru.'); + invalidate(s); s.missing = []; node('plan', 'working', `Doplněna revize ${s.revision}`); node('contract', 'working', '5 podmínek + důkazy'); + proof('plan', 'Doplněný plán', 'AC-03: reconnect obnoví snapshot, následné události se aplikují právě jednou. Přidán test obnovy, vlastník a výstup kroku.'); break; + case 'plan.accept': + if (s.missing.length) return reject('Plánu chybí podmínky dokončení.'); + s.accepted = true; s.actor = 'planner'; node('plan', 'done', `Přijata revize ${s.revision}`); node('contract', 'done', '5/5 podmínek popsáno'); break; + case 'repo.inspect': + case 'repo.verified': { + if (!s.accepted || !s.repositories.some(r => r.id === event.node)) return reject('Repozitář není v přijatém plánu.'); + const r = s.repositories.find(r => r.id === event.node)!; + node(r.id, event.type === 'repo.inspect' ? 'working' : 'done', event.type === 'repo.inspect' ? 'Příprava rozhraní v modelu' : 'Rozhraní zahrnuto do plánu'); + s.actor = 'planner'; + const contract = ROLE_CONTRACTS[r.role]; + if (event.type === 'repo.verified') proof(r.id, `${r.name} · ${ROLE_LABELS[r.role]}`, `Modelové rozhraní. Vstup: ${contract.input}. Výstup: ${contract.output}. Kontrola: ${contract.check}. Repozitář nebyl stažen ani kontaktován.`); + break; + } + case 'work.assign': + if (!s.accepted || s.repositories.some(r => s.nodes[r.id].status !== 'done')) return reject('Nejsou připravené všechny části plánu.'); + node('office', 'done', '2 pracovníci · 2 worktrees'); s.actor = 'developer'; break; + case 'work.start': + case 'work.complete': { + if (!s.accepted || s.nodes.office.status !== 'done' || !['frontend', 'backend'].includes(event.node ?? '')) return reject('Práci nelze přidělit.'); + const id = event.node!; + if (event.type === 'work.complete' && s.nodes[id].status !== 'working') return reject('Práce ještě nezačala.'); + node(id, event.type === 'work.start' ? 'working' : 'done', event.type === 'work.start' ? 'Izolovaná větev · běží' : 'Změna připravena ke kontrole'); + s.actor = id === 'frontend' ? 'developer' : 'reviewer'; + if (event.type === 'work.complete') proof(id, 'Změny přiřazené ke kartě', `${id}: modelový diff a seznam změněných souborů. Vazba: OP-128 / revize ${s.revision}. Konec práce není důkazem správnosti.`); break; + } + case 'checks.start': + if (!s.accepted || !workersDone || (s.repairRequired && !s.repaired)) return reject('Kontroly čekají na dokončené změny.'); + node('checks', 'working', 'typecheck · test · build'); s.actor = 'reviewer'; break; + case 'checks.fail': + if (s.nodes.checks.status !== 'working') return reject('Kontroly neběží.'); + node('checks', 'failed', 'AC-02: dvojí událost'); s.checksRevision = 0; + proof('checks', 'Regrese zachycena', 'Opakované doručení eventId vytvořilo druhý výsledek. Očekáváno 1, obdrženo 2. Předání je blokováno.'); break; + case 'repair.plan': + if (s.nodes.checks.status !== 'failed') return reject('Chybí selhání, které opravujeme.'); + invalidate(s); s.repairRequired = true; s.repaired = false; s.actor = 'planner'; + node('repair', 'queued', 'Nový krok plánu: deduplikace'); node('plan', 'working', `Revize ${s.revision} · oprava AC-02`); + proof('plan', 'Workflow rozšířeno', 'Přidán krok R-01: deduplikovat eventId, pokrýt opakované doručení a zopakovat všechny kontroly. Staré výsledky kontrol jsou neplatné.'); break; + case 'repair.start': + if (!s.accepted || !s.repairRequired) return reject('Oprava není přijata v plánu.'); + node('repair', 'working', 'R-01 · oprava idempotence'); s.actor = 'developer'; break; + case 'repair.complete': + if (s.nodes.repair?.status !== 'working') return reject('Oprava neběží.'); + s.repaired = true; node('repair', 'done', 'Opraveno · čeká na test'); + proof('repair', 'Oprava R-01', 'Přidána kontrola eventId a regresní případ pro duplicitní doručení. Správnost musí potvrdit nový běh kontrol.'); break; + case 'checks.pass': + if (!s.accepted || s.nodes.checks.status !== 'working' || !workersDone || (s.repairRequired && !s.repaired)) return reject('Chybí platný běh kontrol.'); + if (s.repositories.some(r => !event.repositoryIds?.includes(r.id))) return reject('Kontroly nepokrývají přidané repozitáře.'); + s.checksRevision = s.revision; node('checks', 'done', '42/42 · typy ✓ · build ✓'); + proof('checks', 'Kontroly aktuální revize', `Modelový výsledek: 42 testů prošlo, typecheck i build exit 0. AC-01 až AC-04 pokryty. Rozšíření: ${s.repositories.map(r => r.name).join(', ') || 'žádné'}.`); break; + case 'review.pass': + if (!readyForReview(s)) return reject('Review vyžaduje platné kontroly.'); + s.reviewRevision = s.revision; node('review', 'done', 'Plán ↔ změny ↔ důkazy'); s.actor = 'reviewer'; + proof('review', 'Revize výsledku', 'Kontrolor porovnal změnu s AC-01 až AC-05. Důkazy patří aktuální revizi plánu. Sloučení zbývá potvrdit.'); break; + case 'pr.open': + if (!readyForReview(s) || s.reviewRevision !== s.revision) return reject('PR čeká na kontroly a review.'); + s.prRevision = s.revision; node('pr', 'done', 'Ukázkový PR #214'); s.actor = 'developer'; + proof('pr', 'PR #214 · modelový záznam', `OP-128 / revize ${s.revision}. Diff, testy, omezení a odkazy na související repozitáře jsou připojené.`); break; + case 'human.wait': + if (s.prRevision !== s.revision) return reject('Chybí aktuální PR.'); + s.waiting = true; node('merge', 'blocked', 'Čeká na vaše rozhodnutí'); break; + case 'human.approve': + if (!s.waiting || !readyForReview(s) || s.reviewRevision !== s.revision || s.prRevision !== s.revision) return reject('Nelze schválit neověřený výsledek.'); + s.approvalRevision = s.revision; s.waiting = false; node('merge', 'working', 'Schváleno v ukázce'); + proof('merge', 'Rozhodnutí člověka', 'Uživatel schválil pokračování modelového běhu. Žádný skutečný pull request se neslučuje.'); break; + case 'merge.confirm': + if (!canMerge(s)) return reject('Sloučení vyžaduje aktuální důkazy a rozhodnutí.'); + s.merged = true; node('merge', 'done', 'Potvrzené sloučení · demo'); + proof('merge', 'Receipt sloučení', 'Modelový GitHub receipt: PR #214 sloučen do main; výsledek připojen ke kartě OP-128.'); break; + case 'memory.propose': + if (!s.merged) return reject('Zkušenost potřebuje doložený výsledek.'); + s.memory = 'proposed'; s.actor = 'memory'; node('learn', 'working', 'Kandidát M-024 · původ OP-128'); + proof('learn', 'Návrh zkušenosti', 'PŘED: deduplikuj eventId. PO: deduplikuj eventId i při obnově po reconnectu; ověř kombinaci snapshot + opakovaný event. Zdroj: AC-02, AC-03, R-01 a nový běh kontrol.'); break; + case 'memory.persist': + if (s.memory !== 'proposed') return reject('Chybí kandidát paměti.'); + s.memory = 'persisted'; node('learn', 'done', 'M-024 uložena · modelově'); + proof('learn', 'Potvrzení paměti', 'Ukázkové potvrzení zápisu M-024 s odkazem na OP-128. Samotné odeslání session by ještě nepotvrzovalo dokončení asynchronního zpracování.'); break; + case 'memory.reuse': + if (s.memory !== 'persisted') return reject('Zkušenost ještě není uložena.'); + s.memory = 'reused'; node('recall', 'done', 'M-024 načtena pro OP-129'); + proof('recall', 'Opětovné použití', 'Nový modelový úkol OP-129 dostal M-024 do kontextu. Přínos zatím nebyl změřen srovnávacím vyhodnocením.'); break; + case 'run.finish': + if (!s.merged || s.memory !== 'reused') return reject('Průchod ještě není úplný.'); + s.finished = true; break; + default: return reject('Neznámý typ události.'); + } + s.sequence = event.sequence; s.seen.push(event.id); s.headline = event.message; + return { state: s }; +} +export function parseRepository(raw: string, role: string, existing: Repository[]): Repository { + if (!['context', 'module', 'check'].includes(role)) throw new Error('Vyberte roli repozitáře.'); + if (existing.length >= 4) throw new Error('Ukázka pojme nejvýše čtyři další repozitáře.'); + const url = new URL(raw.trim()); + if (url.protocol !== 'https:' || url.hostname !== 'github.com' || url.username || url.password || url.port || url.search || url.hash) throw new Error('Použijte HTTPS adresu github.com/vlastník/repozitář.'); + const parts = url.pathname.replace(/\/$/, '').replace(/\.git$/, '').split('/').filter(Boolean); + if (parts.length !== 2 || parts.some(p => !/^[\w.-]+$/.test(p) || p === '.' || p === '..')) throw new Error('Zadejte přesnou adresu repozitáře bez dalších cest.'); + const canonical = `https://github.com/${parts.join('/')}`; + if (existing.some(r => r.url.toLowerCase() === canonical.toLowerCase())) throw new Error('Tento repozitář už je zahrnutý.'); + return { id: `repo-${existing.length + 1}`, name: parts.join('/'), url: canonical, role: role as RepoRole }; +} diff --git a/src/client/process-studio/motion.ts b/src/client/process-studio/motion.ts new file mode 100644 index 000000000..6974edeab --- /dev/null +++ b/src/client/process-studio/motion.ts @@ -0,0 +1,60 @@ +import { ProcessGraph, svgEl, type Position } from './graph'; +import type { StudioState } from './model'; + +interface Spider { group: SVGGElement; legs: SVGPathElement[]; at: Position; target: Position; phase: number; moving: boolean } +/** Visual interpolation never delays, advances or approves a workflow event. */ +export class SpiderMotion { + private spiders = new Map(); + private last = 0; + private reduced = matchMedia('(prefers-reduced-motion: reduce)'); + private running = true; + private frame = 0; + constructor(private graph: ProcessGraph) { + for (const [index, [id, color, start]] of [ + ['planner', '#ff78b5', 'plan'], ['developer', '#6fe2dc', 'office'], ['reviewer', '#edc180', 'checks'], ['memory', '#b99aff', 'recall'], + ].entries()) { + const g = svgEl('g', { class: 'spider', style: `--spider:${color}` }, graph.actors); + svgEl('circle', { r: 20, class: 'spider-aura' }, g); + const legs = Array.from({ length: 8 }, () => svgEl('path', { class: 'spider-leg', fill: 'none' }, g)); + svgEl('ellipse', { rx: 8, ry: 10, class: 'spider-body' }, g); + svgEl('circle', { cy: -10, r: 5, class: 'spider-head' }, g); + svgEl('circle', { cx: -2, cy: -12, r: 1.1, fill: '#0b0d15' }, g); + svgEl('circle', { cx: 2, cy: -12, r: 1.1, fill: '#0b0d15' }, g); + const at = graph.position(start); + this.spiders.set(id, { group: g, legs, at: { ...at }, target: at, phase: index, moving: false }); + } + this.frame = requestAnimationFrame(t => this.tick(t)); + } + update(s: StudioState, running: boolean) { + this.running = running; + const spider = this.spiders.get(s.actor); + if (spider) spider.target = this.graph.position(s.active); + // The second worker stays active while the first completes its own branch. + if (s.nodes.backend.status === 'working') this.spiders.get('reviewer')!.target = this.graph.position('backend'); + if (s.nodes.frontend.status === 'working') this.spiders.get('developer')!.target = this.graph.position('frontend'); + for (const [id, actor] of this.spiders) actor.group.classList.toggle('engaged', id === s.actor); + if (!running || this.reduced.matches) for (const actor of this.spiders.values()) actor.at = { ...actor.target }; + } + private tick(t: number) { + const dt = Math.min((t - this.last) / 1000, 0.05); this.last = t; + for (const spider of this.spiders.values()) { + const dx = spider.target.x - spider.at.x, dy = spider.target.y - spider.at.y; + const distance = Math.hypot(dx, dy); + spider.moving = distance > 1 && this.running && !this.reduced.matches; + if (spider.moving) { + const step = Math.min(distance, 260 * dt); + spider.at.x += dx / distance * step; spider.at.y += dy / distance * step; + } + if (this.running && !this.reduced.matches) spider.phase += dt * (spider.moving ? 14 : 2); + spider.group.setAttribute('transform', `translate(${spider.at.x.toFixed(2)} ${spider.at.y.toFixed(2)})`); + spider.legs.forEach((leg, i) => { + const side = i < 4 ? -1 : 1, n = i % 4; + const gait = Math.sin(spider.phase + n * 1.6 + (side > 0 ? Math.PI : 0)) * (spider.moving ? 5 : 1.2); + const y = (n - 1.5) * 6; + leg.setAttribute('d', `M${side * 5} ${y} Q${side * (15 + gait)} ${y - 8} ${side * (23 + gait)} ${y + (n - 1.5) * 7 + gait}`); + }); + } + this.frame = requestAnimationFrame(time => this.tick(time)); + } + destroy() { cancelAnimationFrame(this.frame); } +} diff --git a/src/client/process-studio/panels.ts b/src/client/process-studio/panels.ts new file mode 100644 index 000000000..9b1c1644e --- /dev/null +++ b/src/client/process-studio/panels.ts @@ -0,0 +1,99 @@ +import { REQUIREMENTS, ROLE_LABELS, ROLE_CONTRACTS, type StudioState, type StudioEvent } from './model'; + +export const $ = (selector: string) => document.querySelector(selector)!; +export function dom(tag: K, cls = '', text = '') { + const e = document.createElement(tag); e.className = cls; e.textContent = text; return e; +} +export type Panel = 'detail' | 'plan' | 'memory' | 'repos'; +export function showPanel(panel: Panel) { + for (const name of ['detail', 'plan', 'memory', 'repos']) { + $(`#panel-${name}`).hidden = name !== panel; + $(`#tab-${name}`).classList.toggle('active', name === panel); + $(`#tab-${name}`).setAttribute('aria-selected', String(name === panel)); + } +} +const TITLES: Record = { plan: 'Živý plán Opory', context: 'Kontext projektu', recall: 'Načtení paměti', contract: 'Podmínky dokončení', office: 'Kancelář agentů', frontend: 'Zobrazení průběhu', backend: 'Zpracování událostí', checks: 'Kontroly kvality', learn: 'Nová zkušenost', merge: 'Potvrzený výsledek', pr: 'Pull request', review: 'Revize vůči plánu', repair: 'Opravný krok R-01' }; +const STATUS: Record = { queued: 'Čeká na vstup', working: 'Právě probíhá', done: 'Doloženo v modelu', blocked: 'Vyžaduje doplnění', failed: 'Kontrola selhala' }; +function evidenceCard(title: string, body: string, meta: string) { + const el = dom('article', 'evidence-card'); + el.append(dom('span', 'evidence-meta', meta), dom('h3', '', title), dom('p', '', body)); return el; +} +export function renderPanels(s: StudioState, selected: string, events: StudioEvent[], replay: boolean) { + $('#metric-plan').textContent = `revize ${s.revision}`; + $('#metric-plan-note').textContent = s.accepted ? 'přijatá · podmínky popsány' : 'čeká na doplnění / přijetí'; + $('#metric-checks').textContent = s.checksRevision === s.revision ? '3 / 3' : '0 / 3'; + $('#metric-repos').textContent = String(4 + s.repositories.length); + $('#metric-memory').textContent = ({ empty: 'čeká', recalled: '2 načtené', proposed: '1 kandidát', persisted: '1 uložená', reused: '1 znovu použitá' })[s.memory]; + $('#run-label').textContent = s.run; + $('#headline').textContent = s.headline; + $('#phase-label').textContent = replay ? `HISTORIE / UDÁLOST ${s.sequence}` : s.finished ? 'PRŮCHOD DOKONČEN' : s.waiting ? 'ČEKÁ NA VAŠE ROZHODNUTÍ' : 'ŽIVÁ UKÁZKA'; + $('#gate').hidden = !s.waiting || replay; + $('#narrative-marker').className = s.waiting || s.nodes.checks.status === 'failed' || s.nodes.contract.status === 'blocked' ? 'amber' : 'pink'; + renderQuality(s); renderAgents(s); renderDetail(s, selected); renderPlan(s); renderMemory(s); renderRepositories(s); + const feed = $('#event-feed'); feed.replaceChildren(); + for (const event of events.slice(0, s.sequence).slice(-4).reverse()) { + const row = dom('div', 'event-row'); + row.append(dom('span', 'event-sequence', '#' + String(event.sequence).padStart(3, '0')), dom('span', 'event-type', event.type), dom('span', 'event-message', event.message)); feed.append(row); + } +} +function renderQuality(s: StudioState) { + const host = $('#quality-list'); host.replaceChildren(); + for (const r of REQUIREMENTS) { + const verified = r.id === 'AC-05' ? s.merged : s.checksRevision === s.revision; + const missing = s.missing.includes(r.id); + const row = dom('div', `quality-row ${missing ? 'missing' : verified ? 'verified' : ''}`); + row.append(dom('span', 'quality-icon', missing ? '!' : verified ? '✓' : '○'), dom('span', '', r.id), dom('span', '', missing ? 'chybí popis' : verified ? 'doloženo' : 'čeká na důkaz')); + row.title = r.title; host.append(row); + } +} +function renderAgents(s: StudioState) { + const host = $('#agents'); host.replaceChildren(); + for (const [id, name, color] of [['planner', 'Plánovač', 'pink'], ['developer', 'Vývojář', 'cyan'], ['reviewer', 'Kontrolor', 'amber'], ['memory', 'Paměť', 'purple']]) { + const row = dom('div', 'agent-row'); const glyph = dom('span', 'agent-glyph ' + color, '✳'); + row.append(glyph, dom('strong', '', name), dom('span', '', s.finished ? 'hotovo' : id === s.actor ? 'aktivní' : 'připraven')); host.append(row); + } +} +function renderDetail(s: StudioState, selected: string) { + const host = $('#detail-content'); host.replaceChildren(); const state = s.nodes[selected]; + host.append(dom('p', 'eyebrow', 'INSPEKTOR / DOHLEDATELNOST'), dom('h2', '', TITLES[selected] ?? s.repositories.find(r => r.id === selected)?.name ?? selected)); + host.append(dom('p', 'detail-status', state ? STATUS[state.status] : 'Uzel ještě nevznikl v této části historie.')); + const info = dom('dl', 'detail-meta'); + for (const [label, value] of [['Karta', 'OP-128'], ['Revize plánu', String(s.revision)], ['Zdroj', 'Modelový scénář'], ['Stav', state?.detail ?? '—']]) info.append(dom('dt', '', label), dom('dd', '', value)); + host.append(info, dom('h3', 'subheading', 'DŮKAZY TOHOTO KROKU')); + const proofs = s.evidence.filter(e => e.node === selected).reverse(); + if (!proofs.length) host.append(dom('p', 'empty-state', 'Zatím žádný důkaz. Pohyb pavouka ani začátek práce nejsou potvrzením výsledku.')); + for (const e of proofs) host.append(evidenceCard(e.title, e.detail, `${e.id} · revize ${e.revision}${e.revision !== s.revision ? ' · starší kontext' : ''}`)); +} +function renderPlan(s: StudioState) { + const host = $('#plan-content'); host.replaceChildren(); + host.append(dom('p', 'eyebrow', 'OPORA / PLÁN A PODMÍNKY'), dom('h2', '', `Plán, revize ${s.revision}`), dom('p', 'muted', 'Přesná podmínka → odpovědný krok → ověřitelný důkaz.')); + for (const r of REQUIREMENTS) { + const row = evidenceCard(r.title, `Očekáváno: ${r.expected} Vlastník: ${r.owner}. Důkaz: ${r.proof}.`, r.id + (s.missing.includes(r.id) ? ' · CHYBÍ V PLÁNU' : ' · POPSÁNO')); + if (s.missing.includes(r.id)) row.classList.add('warning'); host.append(row); + } + if (s.repairRequired) host.append(evidenceCard('R-01 · nový opravný krok', 'Vývojář opraví deduplikaci, doplní regresní test a znovu spustí kontroly. Předchozí výsledky neposkytují platný důkaz.', 'PŘIDÁNO ZA BĚHU · REVIZE 3')); + for (const repo of s.repositories) { + const contract = ROLE_CONTRACTS[repo.role]; + host.append(evidenceCard(repo.name, `Role: ${ROLE_LABELS[repo.role]}. Vstup: ${contract.input}. Výstup: ${contract.output}. Kontrola: ${contract.check}.`, 'ROZŠÍŘENÍ ROZSAHU')); + } +} +function renderMemory(s: StudioState) { + const host = $('#memory-content'); host.replaceChildren(); + host.append(dom('p', 'eyebrow', 'OPENVIKING / ŽIVOTNÍ CYKLUS'), dom('h2', '', 'Zkušenost s původem'), dom('p', 'muted', 'Každá změna má zdrojový úkol a důkaz. Opětovné použití se sleduje odděleně od přínosu.')); + const stages = ['recalled', 'proposed', 'persisted', 'reused']; const at = stages.indexOf(s.memory); + for (const [i, title] of ['Načíst relevantní zkušenost', 'Navrhnout změnu paměti', 'Potvrdit uložení', 'Použít při dalším úkolu'].entries()) host.append(dom('div', 'memory-stage ' + (at >= i ? 'complete' : ''), `${at >= i ? '✓' : '○'} ${title}`)); + if (at >= 1) { + host.append(evidenceCard('Před', 'Deduplikuj eventId.', 'M-023 / PŮVODNÍ ZKUŠENOST')); + host.append(evidenceCard('Po', 'Deduplikuj eventId i při obnově spojení. Ověř kombinaci snapshot + opakovaný event. Zdroj: OP-128 / R-01 / AC-02 / AC-03.', 'M-024 / NÁVRH ZPŘESNĚNÍ')); + } + if (at >= 3) host.append(evidenceCard('Další úkol OP-129', 'Zkušenost M-024 byla předána do kontextu. Úspěšnější řešení zatím nepotvrdilo srovnávací vyhodnocení.', 'OPĚTOVNÉ POUŽITÍ DOLOŽENO · PŘÍNOS NEZMĚŘEN')); +} +function renderRepositories(s: StudioState) { + const host = $('#repo-list'); host.replaceChildren(); + for (const [name, label, color] of [['Opora', 'plán a podmínky', 'pink'], ['agent-office', 'pracovníci a nástroje', 'cyan'], ['OpenViking', 'kontext a paměť', 'purple'], ['GitHub', 'změny a výsledky', 'green']]) { + const row = dom('div', 'repo-row'); row.append(dom('span', color, '◇'), dom('strong', '', name), dom('small', '', label)); host.append(row); + } + for (const repo of s.repositories) { + const row = dom('div', 'repo-row added'); row.append(dom('span', 'cyan', '+'), dom('strong', '', repo.name), dom('small', '', ROLE_LABELS[repo.role])); host.append(row); + } +} diff --git a/src/client/process-studio/scenario.ts b/src/client/process-studio/scenario.ts new file mode 100644 index 000000000..3e778febb --- /dev/null +++ b/src/client/process-studio/scenario.ts @@ -0,0 +1,40 @@ +import type { EventType, Repository, StudioEvent } from './model'; + +export interface Scene { type: EventType; message: string; node?: string; repositoryIds?: string[] } +export function scenario(repositories: Repository[]): Scene[] { + return [ + { type: 'plan.draft', message: 'Opora vytváří kartu OP-128: cíl, rozsah, vlastníci a očekávaný výstup.' }, + { type: 'memory.recall', message: 'OpenViking předává dvě dřívější zkušenosti a zdrojový kontext.' }, + { type: 'plan.gap', message: 'Kontrola plánu našla mezeru: chybí popis a test obnovení spojení.' }, + { type: 'plan.refine', message: 'Plán roste do revize 2. Přibývá AC-03, jeho test a očekávaný výsledek.' }, + { type: 'plan.accept', message: 'Přijatý plán má pět podmínek dokončení, vlastníky a dohledatelné důkazy.' }, + ...repositories.flatMap(r => [ + { type: 'repo.inspect' as const, node: r.id, message: `${r.name}: zapracováváme zvolenou roli do modelového workflow.` }, + { type: 'repo.verified' as const, node: r.id, message: `${r.name}: vstup, výstup a kontrola rozhraní jsou součástí plánu.` }, + ]), + { type: 'work.assign', message: 'Kancelář přiděluje dvě oddělené větve pracovníkům. Každý dostává stejnou revizi plánu.' }, + { type: 'work.start', node: 'frontend', message: 'Vývojář začíná UI průběhu. Změna patří OP-128 / revizi 2.' }, + { type: 'work.start', node: 'backend', message: 'Druhý pracovník souběžně připravuje zpracování událostí a obnovu stavu.' }, + { type: 'work.complete', node: 'frontend', message: 'UI má modelový diff a hotové soubory. Správnost ještě nepotvrdily kontroly.' }, + { type: 'work.complete', node: 'backend', message: 'Obě větve předávají výstupy. Začíná společná kontrola smluveného chování.' }, + { type: 'checks.start', message: 'Spouštíme typovou kontrolu, testy a sestavení vůči přijatému plánu.' }, + { type: 'checks.fail', message: 'Zachycena chyba AC-02: duplicitní událost vytvořila druhý výsledek. Předání je zastaveno.' }, + { type: 'repair.plan', message: 'Workflow se rozšiřuje o opravu R-01. Revize 3 zneplatňuje staré kontroly.' }, + { type: 'plan.accept', message: 'Revize 3 s opravou R-01 je v modelovém scénáři znovu přijata.' }, + { type: 'repair.start', message: 'Pavouk vývojáře se vrací k opravě. Doplňuje deduplikaci a regresní případ.' }, + { type: 'repair.complete', message: 'Oprava je připravená. Výsledek musí projít novými kontrolami.' }, + { type: 'checks.start', message: 'Nový běh ověřuje revizi 3 a rozhraní všech připojených modulů.' }, + { type: 'checks.pass', repositoryIds: repositories.map(r => r.id), message: '42/42 modelových testů prošlo; typy i sestavení jsou v pořádku pro aktuální revizi.' }, + { type: 'review.pass', message: 'Kontrolor propojuje podmínky plánu, změněné soubory a výsledky kontrol.' }, + { type: 'pr.open', message: 'Ukázkový PR #214 obsahuje diff, testy a vazby na kartu i související repozitáře.' }, + { type: 'human.wait', message: 'Vše je připravené k revizi člověkem. Ukázka pokračuje až po vašem rozhodnutí.' }, + { type: 'merge.confirm', message: 'Modelové sloučení je potvrzené. Opora připojuje výsledek ke kartě.' }, + { type: 'memory.propose', message: 'Z doložené opravy vzniká kandidát zkušenosti M-024 se zdroji a změnou obsahu.' }, + { type: 'memory.persist', message: 'Přichází modelové potvrzení zápisu M-024. Zkušenost je připravená pro další úkol.' }, + { type: 'memory.reuse', message: 'Úkol OP-129 znovu načítá M-024. Vidíme použití paměti; zlepšení kvality zatím není změřené.' }, + { type: 'run.finish', message: 'Průchod dokončen: plán → práce → oprava → důkazy → výsledek → opětovně použitá zkušenost.' }, + ]; +} +export function eventFor(scene: Scene, run: string, sequence: number): StudioEvent { + return { ...scene, run, sequence, id: `${run}:${sequence}` }; +} diff --git a/src/client/process-studio/style.css b/src/client/process-studio/style.css new file mode 100644 index 000000000..8c77c74d3 --- /dev/null +++ b/src/client/process-studio/style.css @@ -0,0 +1,141 @@ +@import './graph.css'; + +:root { + color-scheme: dark; + --bg: #090c14; --surface: #101620; --border: #283245; --muted: #8c9ab0; --text: #edf0f8; + --pink: #ff78b5; --cyan: #6fe2dc; --purple: #b99aff; --amber: #edc180; --green: #8bd7ae; + --mono: 'Cascadia Code', Consolas, monospace; + font-family: 'Segoe UI', system-ui, sans-serif; +} +* { box-sizing: border-box; } +body { margin: 0; background: radial-gradient(ellipse at 48% 0%, #30203638, transparent 50%), var(--bg); color: var(--text); font-size: 13px; } +button, input, select { font: inherit; } +button { cursor: pointer; color: inherit; } +button:disabled { opacity: .45; cursor: default; } +button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible { outline: 2px solid var(--cyan); outline-offset: 3px; } +[hidden] { display: none !important; } +a { color: inherit; text-decoration: none; } +.studio { max-width: 2200px; margin: auto; padding: 20px 26px; } +.masthead { display: flex; align-items: center; gap: 17px; padding-bottom: 20px; border-bottom: 1px solid #27304399; } +.wordmark { font-weight: 750; letter-spacing: 4px; font-size: 23px; } +.wordmark span { color: var(--pink); font-weight: 300; } +.product-name { font: 10px var(--mono); letter-spacing: 1.6px; } +.product-name small { display: block; font-size: 10px; letter-spacing: 0; color: var(--muted); margin-top: 5px; } +.mode-badge { margin-left: auto; font: 10px var(--mono); letter-spacing: 1px; border: 1px solid #735638; color: var(--amber); padding: 7px 12px; border-radius: 18px; } +.status-dot { display: inline-block; background: currentColor; width: 5px; height: 5px; border-radius: 50%; margin-right: 5px; } +.hero { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin: 23px 0 22px; } +.eyebrow { color: var(--pink); font: 10px/1.5 var(--mono); letter-spacing: 1.6px; margin: 0 0 8px; } +h1 { font-size: clamp(25px, 2.1vw, 38px); font-weight: 500; letter-spacing: -1.2px; margin: 0 0 7px; } +h1 span { color: var(--pink); } +.hero-copy { color: var(--muted); margin: 0; font-size: 12px; } +.play-controls { display: flex; gap: 8px; align-items: center; flex-shrink: 0; } +.primary-button, .quiet-button, .icon-button { padding: 9px 13px; border-radius: 6px; border: 1px solid #38465f; background: #182131; font-size: 11px; } +.primary-button { color: #26121e; background: var(--pink); border-color: var(--pink); font-weight: 650; } +.primary-button:hover { background: #ff9cca; } +.quiet-button:hover, .icon-button:hover { border-color: var(--pink); } +.speed-control { display: flex; align-items: center; gap: 7px; color: var(--muted); font: 10px var(--mono); } +select, input[type='url'] { background: #0c121d; color: var(--text); border: 1px solid #35415a; border-radius: 5px; padding: 9px; min-width: 0; } +.speed-control select { padding: 8px; } +.metrics { display: grid; grid-template-columns: repeat(4, 1fr); border: 1px solid var(--border); border-radius: 9px; background: linear-gradient(110deg, #18202c, #111721); margin-bottom: 18px; } +.metrics > div { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 4px 12px; padding: 15px 21px; border-right: 1px solid var(--border); } +.metrics > div:last-child { border: 0; } +.metrics span { font: 9px var(--mono); letter-spacing: 1.2px; color: var(--muted); } +.metrics strong { font-weight: 500; font-size: 21px; grid-row: span 2; color: var(--cyan); } +.metrics small { color: var(--muted); font-size: 10px; } +.workspace { display: grid; grid-template-columns: 211px minmax(0, 1fr) 295px; gap: 15px; align-items: start; } +.context-pane, .inspector, .graph-pane { border: 1px solid var(--border); border-radius: 9px; background: #10151ee8; min-width: 0; } +.context-pane { padding: 17px; } +.pane-label { display: flex; align-items: center; justify-content: space-between; font: 9px var(--mono); letter-spacing: 1px; color: #bbc7d9; } +.tiny-pill { font: 8px var(--mono); padding: 3px 5px; border: 1px solid #5e4834; color: var(--amber); border-radius: 4px; } +h2 { margin: 13px 0 9px; font-size: 20px; letter-spacing: -.4px; font-weight: 500; } +.context-pane h2 { font-size: 18px; } +.muted { color: var(--muted); font-size: 11px; line-height: 1.65; margin: 0; } +.brief { margin-top: 20px; border-block: 1px solid var(--border); padding: 15px 0 3px; } +.brief > span { font: 8px var(--mono); letter-spacing: 1px; color: var(--cyan); } +.brief p { color: #bac5d6; font-size: 11px; line-height: 1.6; margin: 6px 0 14px; } +.spaced { margin-top: 20px; } +.text-button { background: none; border: 0; padding: 0; font: 10px var(--mono); color: var(--cyan); } +.text-button:hover { color: var(--text); } +.quality-list { margin-top: 10px; } +.quality-row { display: grid; grid-template-columns: 14px 42px 1fr; gap: 5px; padding: 7px 0; font: 9px var(--mono); color: #a2b0c5; } +.quality-row span:last-child { text-align: right; } +.quality-row.missing { color: var(--amber); } +.quality-row.verified { color: var(--green); } +.agent-row { display: grid; grid-template-columns: 21px 1fr auto; gap: 5px; align-items: center; padding: 9px 0; border-bottom: 1px solid #28324580; } +.agent-glyph { font-size: 22px; } +.agent-row strong { font-weight: 400; font-size: 11px; } +.agent-row > span:last-child { font: 8px var(--mono); color: var(--muted); } +.source-note { color: #8d9bb0; font-size: 10px; line-height: 1.6; margin-top: 18px; } +.graph-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 13px; border-bottom: 1px solid var(--border); } +.view-tabs { display: flex; gap: 4px; } +.view-tabs button { border: 0; background: transparent; color: var(--muted); padding: 7px 10px; border-radius: 5px; font-size: 11px; } +.view-tabs button.active { background: #283044; color: var(--text); } +.graph-counter { color: var(--muted); font: 9px var(--mono); } +.graph-stage { background: radial-gradient(ellipse at 50% 40%, #40233e33, transparent 70%), radial-gradient(#7f8ca922 .8px, transparent .8px); background-size: auto, 17px 17px; } +.graph-stage svg { width: 100%; display: block; } +.graph-legend { display: flex; flex-wrap: wrap; gap: 13px; border-top: 1px solid var(--border); padding: 10px 15px; font: 9px var(--mono); color: var(--muted); } +.graph-legend span:last-child { margin-left: auto; } +.narrative { display: flex; gap: 12px; align-items: center; padding: 16px; background: #171b28; border-top: 1px solid var(--border); border-radius: 0 0 9px 9px; } +#narrative-marker { font-size: 18px; color: var(--pink); } +.narrative span:not(#narrative-marker) { font: 8px var(--mono); letter-spacing: 1px; color: var(--pink); } +.narrative p { font-size: 12px; line-height: 1.5; margin: 5px 0 0; min-height: 36px; } +.gate-banner { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 15px; background: #33291d; border-top: 1px solid #71573a; } +.gate-banner strong { font-size: 12px; color: var(--amber); display: block; } +.gate-banner span { font-size: 10px; color: #d9bd96; display: block; margin-top: 5px; } +.gate-banner button { flex-shrink: 0; } +.inspector-tabs { display: flex; padding: 5px; gap: 3px; border-bottom: 1px solid var(--border); } +.inspector-tabs button { flex: 1; background: none; border: 0; border-radius: 5px; color: var(--muted); font-size: 10px; padding: 11px 6px; } +.inspector-tabs button.active { background: #293247; color: var(--text); } +.inspector-tabs button span { color: var(--pink); } +.inspector > section { padding: 19px 17px; max-height: 740px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #39445a transparent; } +.inspector .eyebrow { font-size: 8px; } +.inspector h2 { font-size: 21px; margin-top: 0; } +.repo-list { margin: 18px 0; } +.repo-row { display: grid; grid-template-columns: 16px 1fr; gap: 3px 6px; padding: 9px 0; border-bottom: 1px solid #28324588; } +.repo-row > span { grid-row: span 2; font-size: 16px; } +.repo-row strong { font-size: 11px; font-weight: 500; overflow-wrap: anywhere; } +.repo-row small { color: var(--muted); font-size: 10px; } +.repo-row.added { border-color: #395b5e; } +form label { display: block; margin: 13px 0 6px; color: #c9d3e1; font-size: 11px; } +form input, form select { width: 100%; font-size: 11px; } +form .primary-button { width: 100%; margin-top: 10px; } +.role-description { font-size: 10px; color: var(--muted); line-height: 1.6; margin: 9px 0 0; min-height: 32px; } +.form-feedback { color: var(--amber); font-size: 11px; line-height: 1.6; overflow-wrap: anywhere; } +.form-feedback:empty { display: none; } +.integration-note { padding: 12px; background: #161e2b; border: 1px dashed #36455d; border-radius: 6px; margin-top: 17px; } +.integration-note strong { font-size: 11px; font-weight: 500; color: var(--cyan); } +.integration-note p { font-size: 10px; color: var(--muted); line-height: 1.65; margin: 6px 0 0; } +.evidence-card { border: 1px solid #2b374d; background: #101824; padding: 12px; border-radius: 6px; margin-top: 12px; } +.evidence-card.warning { border-color: #8f6e44; } +.evidence-meta { color: var(--purple); font: 8px/1.7 var(--mono); overflow-wrap: anywhere; } +.evidence-card h3 { margin: 7px 0; font-size: 12px; font-weight: 500; } +.evidence-card p { color: #aab9ce; font-size: 11px; line-height: 1.65; margin: 0; } +.detail-status { color: var(--pink); border-block: 1px solid var(--border); padding: 12px 0; font-size: 11px; } +.detail-meta { display: grid; grid-template-columns: 80px 1fr; gap: 10px; font-size: 10px; } +.detail-meta dt { color: var(--muted); } +.detail-meta dd { margin: 0; overflow-wrap: anywhere; } +.subheading { font: 9px var(--mono); color: var(--muted); margin: 23px 0 12px; } +.empty-state { color: var(--muted); font-size: 11px; line-height: 1.7; } +.memory-stage { padding: 11px 0; border-bottom: 1px solid var(--border); color: var(--muted); font-size: 11px; } +.memory-stage.complete { color: var(--green); } +.history-panel { border: 1px solid var(--border); border-radius: 8px; padding: 15px 18px; margin-top: 17px; background: #10151e; } +.history-top, .history-top > div { display: flex; align-items: center; justify-content: space-between; gap: 20px; } +#history-label { font: 9px var(--mono); color: var(--muted); } +.timeline-label { display: block; margin-top: 14px; font-size: 9px; color: var(--muted); } +#timeline { width: 100%; margin: 6px 0 12px; accent-color: var(--pink); height: 4px; } +.event-feed { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 25px; min-height: 44px; } +.event-row { display: grid; grid-template-columns: 31px 105px 1fr; gap: 8px; align-items: start; font: 9px/1.5 var(--mono); } +.event-sequence { color: #7d8ba1; } +.event-type { color: var(--pink); } +.event-message { color: #bcc7d7; } +footer { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; color: #8391a7; font: 9px var(--mono); margin-top: 18px; } +footer b { color: var(--pink); } +.pink { color: var(--pink); }.cyan { color: var(--cyan); }.purple { color: var(--purple); }.amber { color: var(--amber); }.green { color: var(--green); } +dialog { width: min(600px, 90vw); background: #141c29; color: var(--text); border: 1px solid #41506a; border-radius: 11px; padding: 30px; } +dialog::backdrop { background: #03050cba; backdrop-filter: blur(6px); } +dialog p:not(.eyebrow) { color: #bcc7d8; font-size: 13px; line-height: 1.75; } +.dialog-close { position: absolute; right: 13px; top: 11px; padding: 4px; border: 0; background: none; font-size: 19px; } +@media (min-width: 1850px) { .workspace { grid-template-columns: 230px minmax(0, 1fr) 320px; gap: 20px; }.studio { padding: 25px 35px; }.inspector > section { max-height: 920px; } } +@media (max-width: 1250px) { .workspace { grid-template-columns: minmax(0, 1fr) 290px; }.context-pane { display: none; }.hero-copy { max-width: 430px; }.metrics > div { padding: 12px; }.metrics strong { font-size: 18px; } } +@media (max-width: 900px) { .studio { padding: 14px; }.workspace { grid-template-columns: 1fr; }.inspector > section { max-height: none; overflow: visible; }.hero { display: block; }.play-controls { margin-top: 18px; }.product-name { display: none; }.metrics { grid-template-columns: 1fr 1fr; }.metrics > div:nth-child(2) { border-right: 0; }.event-feed { grid-template-columns: 1fr; }.history-top, .history-top > div { flex-wrap: wrap; gap: 12px; }.graph-stage { min-height: 300px; }.graph-legend { gap: 9px; }.masthead { gap: 8px; }.mode-badge { font-size: 8px; }.masthead .quiet-button { padding: 7px; font-size: 10px; } } +@media (max-width: 500px) { .wordmark { font-size: 17px; letter-spacing: 2px; }.mode-badge { padding: 6px; }.hero h1 { font-size: 27px; }.metrics span { font-size: 8px; }.metrics strong { font-size: 17px; }.metrics small { font-size: 9px; }.play-controls { gap: 5px; }.speed-control > span { display: none; }.graph-counter { font-size: 8px; }.graph-legend span:last-child { margin-left: 0; }.gate-banner { flex-direction: column; align-items: stretch; }.event-row { grid-template-columns: 28px 94px 1fr; }.context-pane { display: block; }.workspace { gap: 12px; } } diff --git a/src/server/http/routes/index.ts b/src/server/http/routes/index.ts index 982acdd1b..179472262 100644 --- a/src/server/http/routes/index.ts +++ b/src/server/http/routes/index.ts @@ -7,6 +7,7 @@ import { authRoutes } from './auth.js'; import { fileRoutes } from './files.js'; import { githubRoutes } from './github.js'; import { pageRoutes } from './pages.js'; +import { processStudioRoute } from './process-studio.js'; import { searchRoutes } from './search.js'; import { serviceRoutes } from './services.js'; @@ -38,5 +39,6 @@ export const routes: readonly Route[] = [ githubRoutes.github, pageRoutes.office, pageRoutes.lite, + processStudioRoute, pageRoutes.bundle, ]; diff --git a/src/server/http/routes/process-studio.ts b/src/server/http/routes/process-studio.ts new file mode 100644 index 000000000..89565a321 --- /dev/null +++ b/src/server/http/routes/process-studio.ts @@ -0,0 +1,13 @@ +import path from 'node:path'; +import type { Route } from '../router.js'; +import { serveFile } from '../static.js'; + +/** A separate simulation page; it exposes no provider calls or mutation endpoint. */ +export const processStudioRoute: Route = { + path: ['/process-studio', '/process-studio.html'], + method: 'GET', + auth: 'session', + handle(ctx, { res }) { + return serveFile(res, path.join(ctx.publicDir, 'process-studio.html'), false); + }, +}; diff --git a/tests/process-studio.test.ts b/tests/process-studio.test.ts new file mode 100644 index 000000000..150e30a66 --- /dev/null +++ b/tests/process-studio.test.ts @@ -0,0 +1,84 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { initialState, reduceEvent, canMerge, parseRepository, type StudioState, type EventType, type StudioEvent } from '../src/client/process-studio/model.js'; +import { eventFor, scenario } from '../src/client/process-studio/scenario.js'; +import { processStudioRoute } from '../src/server/http/routes/process-studio.js'; + +function apply(s: StudioState, type: EventType, extras: Partial = {}): StudioState { + const result = reduceEvent(s, { ...eventFor({ type, message: type }, s.run, s.sequence + 1), ...extras }); + assert.equal(result.error, undefined, `${type}: ${result.error}`); return result.state; +} +function until(type: EventType, repos: ReturnType[] = []): StudioState { + let state = initialState(repos); + for (const scene of scenario(repos)) { + if (state.waiting) state = apply(state, 'human.approve'); + state = apply(state, scene.type, scene); + if (scene.type === type) return state; + } + throw new Error(`Missing ${type}`); +} +test('complete scenario repairs a regression, waits for a person and reuses evidenced memory', () => { + let state = initialState(); let invalidated = false; + for (const scene of scenario([])) { + if (state.waiting) { + assert.equal(canMerge(state), false); + assert.ok(reduceEvent(state, eventFor({ type: 'merge.confirm', message: '' }, state.run, state.sequence + 1)).error); + state = apply(state, 'human.approve'); + } + state = apply(state, scene.type, scene); + if (scene.type === 'repair.plan') { invalidated = true; assert.equal(state.checksRevision, 0); assert.equal(state.accepted, false); } + } + assert.ok(invalidated); assert.equal(state.revision, 3); assert.equal(state.finished, true); + assert.equal(state.memory, 'reused'); assert.equal(state.merged, true); +}); +test('a plan gap blocks acceptance and work dispatch without changing state', () => { + const state = until('plan.gap'); + for (const type of ['plan.accept', 'work.assign', 'checks.pass'] as const) { + const result = reduceEvent(state, eventFor({ type, message: '' }, state.run, state.sequence + 1)); + assert.ok(result.error); assert.equal(result.state, state); + } +}); +test('merge refuses stale checks or review and memory refuses premature writes', () => { + const approved = apply(until('human.wait'), 'human.approve'); + assert.ok(canMerge(approved)); + for (const field of ['checksRevision', 'reviewRevision', 'prRevision', 'approvalRevision'] as const) { + const stale = { ...approved, [field]: approved.revision - 1 }; + assert.equal(canMerge(stale), false); + assert.ok(reduceEvent(stale, eventFor({ type: 'merge.confirm', message: '' }, stale.run, stale.sequence + 1)).error); + } + for (const type of ['memory.propose', 'memory.persist', 'memory.reuse'] as const) { + assert.ok(reduceEvent(approved, eventFor({ type, message: '' }, approved.run, approved.sequence + 1)).error); + } +}); +test('duplicate, out-of-order and foreign-run events never alter the current snapshot', () => { + const state = initialState(); const event = eventFor({ type: 'plan.draft', message: '' }, state.run, 1); + const next = reduceEvent(state, event).state; + for (const bad of [event, { ...event, id: 'other', sequence: 3 }, { ...event, id: 'foreign', run: 'other', sequence: 2 }]) { + const result = reduceEvent(next, bad); assert.ok(result.error); assert.equal(result.state, next); + } + assert.equal(state.nodes.plan.status, 'queued'); +}); +test('a repository extension creates a plan node and cannot be omitted from checks', () => { + const repo = parseRepository('https://github.com/example/quality-kit.git', 'check', []); + const all = scenario([repo]); let state = initialState([repo]); + for (const scene of all) { + if (scene.type === 'checks.pass') { + const omitted = reduceEvent(state, eventFor({ ...scene, repositoryIds: [] }, state.run, state.sequence + 1)); + assert.ok(omitted.error); assert.equal(omitted.state, state); break; + } + state = apply(state, scene.type, scene); + } + const finished = until('run.finish', [repo]); assert.equal(finished.nodes[repo.id].status, 'done'); + assert.ok(finished.evidence.some(e => e.node === repo.id)); +}); +test('repository identity rejects unsafe, duplicate and unsupported inputs', () => { + const repo = parseRepository('https://github.com/team/module/', 'module', []); + assert.equal(repo.url, 'https://github.com/team/module'); + for (const bad of ['javascript:alert(1)', 'https://github.com.evil.test/team/repo', 'https://user:secret@github.com/team/repo', 'https://github.com/team/repo/tree/main', 'https://github.com/team/repo?token=x', 'https://github.com/team/']) assert.throws(() => parseRepository(bad, 'module', [])); + assert.throws(() => parseRepository(repo.url.toUpperCase(), 'context', [repo])); + assert.throws(() => parseRepository(repo.url, 'unknown', [])); + assert.throws(() => parseRepository('https://github.com/team/next', 'module', [repo, repo, repo, repo])); +}); +test('the standalone studio uses the session-authenticated route registry', () => { + assert.equal(processStudioRoute.auth, 'session'); assert.equal(processStudioRoute.method, 'GET'); +}); diff --git a/vite.config.ts b/vite.config.ts index 38f15039c..082f977c4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -57,6 +57,7 @@ export default defineConfig({ login: resolve(import.meta.dirname, 'src/client/login.html'), claim: resolve(import.meta.dirname, 'src/client/claim.html'), join: resolve(import.meta.dirname, 'src/client/join.html'), + processStudio: resolve(import.meta.dirname, 'src/client/process-studio.html'), }, }, }, From 533d3dd0cfa3a2040b0e7c50abfe54e9b85006b9 Mon Sep 17 00:00:00 2001 From: Pletor Date: Wed, 21 Oct 2026 23:39:03 +0200 Subject: [PATCH 2/5] fix: restore all actors from replay snapshots --- src/client/process-studio/motion.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/client/process-studio/motion.ts b/src/client/process-studio/motion.ts index 6974edeab..8840dc866 100644 --- a/src/client/process-studio/motion.ts +++ b/src/client/process-studio/motion.ts @@ -27,11 +27,15 @@ export class SpiderMotion { } update(s: StudioState, running: boolean) { this.running = running; - const spider = this.spiders.get(s.actor); - if (spider) spider.target = this.graph.position(s.active); - // The second worker stays active while the first completes its own branch. - if (s.nodes.backend.status === 'working') this.spiders.get('reviewer')!.target = this.graph.position('backend'); - if (s.nodes.frontend.status === 'working') this.spiders.get('developer')!.target = this.graph.position('frontend'); + // Derive every actor from this snapshot, so replay never retains a future position. + const destinations: Record = { + planner: s.nodes.contract.status === 'done' ? 'contract' : 'plan', + developer: s.repairRequired && (s.repaired || s.nodes.repair?.status === 'working') ? 'repair' : s.nodes.frontend.status !== 'queued' ? 'frontend' : 'office', + reviewer: s.nodes.review.status === 'done' ? 'review' : s.nodes.checks.status !== 'queued' ? 'checks' : s.nodes.backend.status !== 'queued' ? 'backend' : 'checks', + memory: s.memory === 'proposed' || s.memory === 'persisted' ? 'learn' : 'recall', + }; + destinations[s.actor] = s.active; + for (const [id, actor] of this.spiders) actor.target = this.graph.position(destinations[id]); for (const [id, actor] of this.spiders) actor.group.classList.toggle('engaged', id === s.actor); if (!running || this.reduced.matches) for (const actor of this.spiders.values()) actor.at = { ...actor.target }; } From 44dc74f8ef2d7f850b5c0830a2069e44af7b81e8 Mon Sep 17 00:00:00 2001 From: Pletor Date: Thu, 22 Oct 2026 00:45:47 +0200 Subject: [PATCH 3/5] feat: expand delivery workflow pipeline --- README.md | 2 +- docs/process-studio.md | 20 ++++++++ src/client/process-studio.html | 10 +++- src/client/process-studio/index.ts | 3 ++ src/client/process-studio/model.ts | 24 +++++++++- src/client/process-studio/pipeline-model.ts | 38 +++++++++++++++ src/client/process-studio/pipeline.css | 25 ++++++++++ src/client/process-studio/pipeline.ts | 52 +++++++++++++++++++++ src/client/process-studio/scenario.ts | 19 +++++++- tests/process-studio.test.ts | 38 +++++++++++++++ 10 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 src/client/process-studio/pipeline-model.ts create mode 100644 src/client/process-studio/pipeline.css create mode 100644 src/client/process-studio/pipeline.ts diff --git a/README.md b/README.md index 2946144ff..0913668e9 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/i ## What it is -- **Process Observatory (interactive demo).** Open `/process-studio` after signing in to explore a Czech-language simulation of an Opora plan, parallel office workers, failed checks and repair, GitHub delivery, and memory reuse. Animated agents follow recorded events; repository-role inputs extend the local scenario. It uses sample data and does not connect accounts or change repositories. See [the scenario, controls and integration boundary](docs/process-studio.md). +- **Process Observatory (interactive demo).** Open `/process-studio` after signing in to explore a Czech-language simulation of an Opora plan, parallel office workers, failed checks and repair, GitHub delivery, and memory reuse. A six-phase delivery pipeline exposes 17 handoffs, from source investigation and worktrees through counterexamples, review, PR dependencies, staging and evaluation. Animated agents follow recorded events; repository-role inputs extend the local scenario. It uses sample data and does not connect accounts or change repositories. See [the scenario, controls and integration boundary](docs/process-studio.md). - **A floor per project.** Ride the elevator, pick one of your GitHub repos, and the office clones it (showing how far along it is) and opens a floor for it. Every worker, board and queue on that floor works in that checkout. - **Workers at desks.** Walk up to an empty desk, press **E**, and pick Claude Code, Codex, OpenCode, Grok, Muse, DeepSeek Harness, Pi or Cursor, each with its model and reasoning effort. The agent's live terminal shows on its laptop, and anyone can open it and type. - **Talk instead of typing.** Hold **Ctrl+Space** (or the **🎤**) in a worker's terminal or a prompt box and say what you want: it's typed in for you to send. Your browser does the listening, so there's nothing to install. diff --git a/docs/process-studio.md b/docs/process-studio.md index 87effc310..fdc3b34aa 100644 --- a/docs/process-studio.md +++ b/docs/process-studio.md @@ -18,6 +18,25 @@ The precise acceptance conditions and evidence are available under **Plán** and ## Controls +### Delivery pipeline + +The six-lane pipeline below the graph exposes 17 ordered handoffs. Select a step to inspect its owner, input, output and handoff condition; **Sledovat další předání** restores automatic selection. Its progress and selected-step status derive from the same snapshot as the graph, including history replay. The next highlighted handoff is pending, not a claim that a tool is currently running. + +| Phase | Intermediate work | +| --- | --- | +| Opora | Intake and risks; source provenance and repository instructions; acceptance contract and state model | +| Office | Fresh main and baseline results; isolated worktrees and file ownership; minimal counterexample and regression test | +| Evidence | Integration of worker changes; model-to-code correspondence; independent review; simplification decision | +| GitHub | Documentation and PR package; CI for the current head and dependency ordering; human approval and merge queue | +| Delivery | Versioned artifact; staging smoke checks and rollback plan; evidence-linked task closure | +| Memory | Recall, propose, persist, reuse and a separate plan for paired evaluation | + +This adapts the supplied verification/review/PR video into the office workflow, without copying its numeric results. The video inspires counterexample-driven repair, independent implementation review, simplification, dependent PRs and a final evidence summary. The demo does **not** execute Lean, prove a program, open multiple real PRs, run CI or deploy. Its dependency strip is a proposed contract → UI → extension ordering; only sample PR #214 receives a modeled merge. Production rollout remains a separate decision outside the scenario. + +The simplification step explicitly makes no code change in this run. A real follow-up diff would invalidate checks and review for the previous SHA and require another cycle. The existing repair loop shows failure → revised plan → repair → new checks. Merge, staging verification, task closure and memory writing are distinct handoffs; they cannot be skipped in the modeled run. Evaluation is only a plan, with no claimed improvement percentage. + +### Playback and inspection + - **Tok procesu / Vazby paměti** switches between the workflow and an illustrative context/evidence relationship view. The latter does not depict an LLM's internal neurons. - **Pozastavit**, **Krok**, speed and restart control the deterministic scenario. Reduced-motion preference starts it paused and suppresses procedural motion. - Select a node with a pointer or Enter/Space to inspect its status, revision and evidence. @@ -37,6 +56,7 @@ This describes how an integration contract could appear. It does not infer an un - `src/client/process-studio/model.ts`: pure state reducer, event identity/order, plan revisions and evidence gates. - `scenario.ts`: the explicitly simulated producer; repository extensions insert their steps here. +- `pipeline-model.ts`: ordered handoff registry and prerequisites; `pipeline.ts` / `pipeline.css`: accessible six-lane projection and contract inspector. - `graph.ts` / `motion.ts`: graph projection and separate visual interpolation. - `panels.ts` / `index.ts`: inspection, playback, history, extension form and trace export. - The Vite input registry and `http/routes/index.ts` register this entry independently of the 3D office and its state store. diff --git a/src/client/process-studio.html b/src/client/process-studio.html index 427a9201e..015ca0971 100644 --- a/src/client/process-studio.html +++ b/src/client/process-studio.html @@ -70,12 +70,20 @@

Dohledatelný průběh práce

Od přesného zadání a +

+

OPORA → AGENT-OFFICE → GITHUB → ZPĚTNÁ VAZBA

Od zadání k ověřenému doručení.

Každé předání má vstup, výstup a podmínku. Vyberte krok a prohlédněte jeho kontrakt.

+ +
+
+

+

Simulace procesu inspirovaná videem. Lean, GitHub CI ani nasazení se zde nespouštějí; navazující PR jsou plán závislostí.

+
STOPA BĚHU0 událostí
-
OPORA / PROCESS OBSERVATORY 02Plán → důkazy → výsledek → paměť → další úkolSimulovaný zdroj událostí · žádné připojené účty
+
OPORA / PROCESS OBSERVATORY 03Plán → důkazy → výsledek → paměť → další úkolSimulovaný zdroj událostí · žádné připojené účty

JAK UKÁZKA FUNGUJE

Pohyb následuje události.

Čtyři pavouci představují plánovače, vývojáře, kontrolora a správu paměti. Každý se přesouvá podle konkrétní události scénáře. Pohyb neřídí dokončení práce.

Plán obsahuje podmínky a důkazy. Změna revize zneplatní staré kontroly. Při chybě vznikne opravný krok. Výsledek čeká na rozhodnutí člověka.

OpenViking je zobrazen jako navržená paměťová integrace: načtení → návrh zkušenosti → potvrzení zápisu → opětovné použití. To samo nedokazuje vyšší kvalitu agenta.

Moduly rozšiřují tento lokální model. Skutečné napojení vyžaduje adaptéry služeb, jejich autentizaci a ověřené události. V této verzi žádné takové spojení nevzniká.

diff --git a/src/client/process-studio/index.ts b/src/client/process-studio/index.ts index ac94b0046..0863fc45b 100644 --- a/src/client/process-studio/index.ts +++ b/src/client/process-studio/index.ts @@ -3,6 +3,7 @@ import { initialState, parseRepository, reduceEvent, type Repository, type Studi import { eventFor, scenario } from './scenario'; import { ProcessGraph } from './graph'; import { SpiderMotion } from './motion'; +import { DeliveryPipeline } from './pipeline'; import { $, renderPanels, showPanel, type Panel } from './panels'; let repositories: Repository[] = []; @@ -22,10 +23,12 @@ const graph = new ProcessGraph($('#graph'), id => { selected = id; follow = false; showPanel('detail'); render(); }); const motion = new SpiderMotion(graph); +const pipeline = new DeliveryPipeline(); function render() { const displayed = replayIndex === null ? state : snapshots[replayIndex]; graph.update(displayed, selected); + pipeline.update(displayed); motion.update(displayed, running && replayIndex === null); renderPanels(displayed, selected, events, replayIndex !== null); $('#play').textContent = running ? 'Ⅱ Pozastavit' : state.finished && replayIndex === null ? '↺ Přehrát znovu' : '▶ Přehrát'; diff --git a/src/client/process-studio/model.ts b/src/client/process-studio/model.ts index ba5a37a67..7002cdb95 100644 --- a/src/client/process-studio/model.ts +++ b/src/client/process-studio/model.ts @@ -1,3 +1,4 @@ +import { PIPELINE, pipelineError, type PipelineId } from './pipeline-model'; /** Deterministic, browser-only demonstration. No provider or repository mutations. */ export type Status = 'queued' | 'working' | 'done' | 'blocked' | 'failed'; export type RepoRole = 'context' | 'module' | 'check'; @@ -11,15 +12,17 @@ export interface StudioState { repaired: boolean; repairRequired: boolean; waiting: boolean; merged: boolean; finished: boolean; memory: 'empty' | 'recalled' | 'proposed' | 'persisted' | 'reused'; evidence: Evidence[]; seen: string[]; headline: string; active: string; actor: string; + pipeline: PipelineId[]; } export type EventType = 'plan.draft' | 'memory.recall' | 'plan.gap' | 'plan.refine' | 'plan.accept' | 'repo.inspect' | 'repo.verified' | 'work.assign' | 'work.start' | 'work.complete' | 'checks.start' | 'checks.fail' | 'repair.plan' | 'repair.start' | 'repair.complete' | 'checks.pass' | 'review.pass' | 'pr.open' | 'human.wait' | 'human.approve' - | 'merge.confirm' | 'memory.propose' | 'memory.persist' | 'memory.reuse' | 'run.finish'; + | 'merge.confirm' | 'memory.propose' | 'memory.persist' | 'memory.reuse' | 'run.finish' | 'pipeline.observe'; export interface StudioEvent { id: string; run: string; sequence: number; type: EventType; message: string; node?: string; repositoryIds?: string[]; + stage?: PipelineId; } export const BASE_NODES = ['plan', 'context', 'recall', 'contract', 'office', 'frontend', 'backend', 'checks', 'learn', 'merge', 'pr', 'review']; export const ROLE_LABELS: Record = { context: 'Zdroj kontextu', module: 'Vývojový modul', check: 'Kontrolní služba' }; @@ -41,7 +44,7 @@ export function initialState(repositories: Repository[] = [], run = 'DEMO-042'): nodes: Object.fromEntries([...BASE_NODES, ...repositories.map(r => r.id)].map(id => [id, { status: 'queued', detail: 'Čeká na vstup' }])), checksRevision: 0, reviewRevision: 0, prRevision: 0, approvalRevision: 0, repaired: false, repairRequired: false, waiting: false, merged: false, finished: false, - memory: 'empty', evidence: [], seen: [], headline: 'Připravujeme kartu Opory a první verzi plánu.', active: 'plan', actor: 'planner', + memory: 'empty', evidence: [], seen: [], pipeline: [], headline: 'Připravujeme kartu Opory a první verzi plánu.', active: 'plan', actor: 'planner', }; } export function readyForReview(s: StudioState): boolean { @@ -68,6 +71,14 @@ export function reduceEvent(previous: StudioState, event: StudioEvent): { state: const proof = (id: string, title: string, detail: string) => s.evidence.push({ id: event.id, node: id, title, detail, revision: s.revision }); const workersDone = s.nodes.frontend.status === 'done' && s.nodes.backend.status === 'done'; switch (event.type) { + case 'pipeline.observe': { + const error = pipelineError(s, event.stage); + if (error) return reject(error); + const step = PIPELINE.find(step => step.id === event.stage)!; + s.pipeline.push(step.id); s.active = step.node; + s.actor = step.owner.includes('Paměť') ? 'memory' : step.owner.includes('Kontrolor') ? 'reviewer' : step.owner.includes('Plánovač') ? 'planner' : 'developer'; + proof(step.node, step.title, `Modelové předání. Vstup: ${step.input}. Výstup: ${step.output}. Podmínka: ${step.gate}`); break; + } case 'plan.draft': if (s.sequence !== 0) return reject('Plán už existuje.'); node('plan', 'working', 'OP-128 · revize 1'); s.actor = 'planner'; @@ -97,10 +108,12 @@ export function reduceEvent(previous: StudioState, event: StudioEvent): { state: break; } case 'work.assign': + if (!s.pipeline.includes('worktrees')) return reject('Chybí výchozí stav a izolované pracovní větve.'); if (!s.accepted || s.repositories.some(r => s.nodes[r.id].status !== 'done')) return reject('Nejsou připravené všechny části plánu.'); node('office', 'done', '2 pracovníci · 2 worktrees'); s.actor = 'developer'; break; case 'work.start': case 'work.complete': { + if (!s.pipeline.includes('counterexample')) return reject('Nejdříve připravte reprodukci a očekávané chování.'); if (!s.accepted || s.nodes.office.status !== 'done' || !['frontend', 'backend'].includes(event.node ?? '')) return reject('Práci nelze přidělit.'); const id = event.node!; if (event.type === 'work.complete' && s.nodes[id].status !== 'working') return reject('Práce ještě nezačala.'); @@ -109,6 +122,7 @@ export function reduceEvent(previous: StudioState, event: StudioEvent): { state: if (event.type === 'work.complete') proof(id, 'Změny přiřazené ke kartě', `${id}: modelový diff a seznam změněných souborů. Vazba: OP-128 / revize ${s.revision}. Konec práce není důkazem správnosti.`); break; } case 'checks.start': + if (!s.pipeline.includes('integration')) return reject('Kontroly vyžadují integrovanou změnu.'); if (!s.accepted || !workersDone || (s.repairRequired && !s.repaired)) return reject('Kontroly čekají na dokončené změny.'); node('checks', 'working', 'typecheck · test · build'); s.actor = 'reviewer'; break; case 'checks.fail': @@ -133,14 +147,17 @@ export function reduceEvent(previous: StudioState, event: StudioEvent): { state: s.checksRevision = s.revision; node('checks', 'done', '42/42 · typy ✓ · build ✓'); proof('checks', 'Kontroly aktuální revize', `Modelový výsledek: 42 testů prošlo, typecheck i build exit 0. AC-01 až AC-04 pokryty. Rozšíření: ${s.repositories.map(r => r.name).join(', ') || 'žádné'}.`); break; case 'review.pass': + if (!s.pipeline.includes('independent')) return reject('Chybí nezávislé review implementace.'); if (!readyForReview(s)) return reject('Review vyžaduje platné kontroly.'); s.reviewRevision = s.revision; node('review', 'done', 'Plán ↔ změny ↔ důkazy'); s.actor = 'reviewer'; proof('review', 'Revize výsledku', 'Kontrolor porovnal změnu s AC-01 až AC-05. Důkazy patří aktuální revizi plánu. Sloučení zbývá potvrdit.'); break; case 'pr.open': + if (!s.pipeline.includes('package')) return reject('Chybí dokumentace a balíček předání.'); if (!readyForReview(s) || s.reviewRevision !== s.revision) return reject('PR čeká na kontroly a review.'); s.prRevision = s.revision; node('pr', 'done', 'Ukázkový PR #214'); s.actor = 'developer'; proof('pr', 'PR #214 · modelový záznam', `OP-128 / revize ${s.revision}. Diff, testy, omezení a odkazy na související repozitáře jsou připojené.`); break; case 'human.wait': + if (!s.pipeline.includes('ci')) return reject('Čekáme na kontroly publikovaného PR.'); if (s.prRevision !== s.revision) return reject('Chybí aktuální PR.'); s.waiting = true; node('merge', 'blocked', 'Čeká na vaše rozhodnutí'); break; case 'human.approve': @@ -148,10 +165,12 @@ export function reduceEvent(previous: StudioState, event: StudioEvent): { state: s.approvalRevision = s.revision; s.waiting = false; node('merge', 'working', 'Schváleno v ukázce'); proof('merge', 'Rozhodnutí člověka', 'Uživatel schválil pokračování modelového běhu. Žádný skutečný pull request se neslučuje.'); break; case 'merge.confirm': + if (!s.pipeline.includes('queue')) return reject('Chybí vyhodnocení fronty a závislostí.'); if (!canMerge(s)) return reject('Sloučení vyžaduje aktuální důkazy a rozhodnutí.'); s.merged = true; node('merge', 'done', 'Potvrzené sloučení · demo'); proof('merge', 'Receipt sloučení', 'Modelový GitHub receipt: PR #214 sloučen do main; výsledek připojen ke kartě OP-128.'); break; case 'memory.propose': + if (!s.pipeline.includes('close')) return reject('Chybí výsledný souhrn doručení v Opoře.'); if (!s.merged) return reject('Zkušenost potřebuje doložený výsledek.'); s.memory = 'proposed'; s.actor = 'memory'; node('learn', 'working', 'Kandidát M-024 · původ OP-128'); proof('learn', 'Návrh zkušenosti', 'PŘED: deduplikuj eventId. PO: deduplikuj eventId i při obnově po reconnectu; ověř kombinaci snapshot + opakovaný event. Zdroj: AC-02, AC-03, R-01 a nový běh kontrol.'); break; @@ -164,6 +183,7 @@ export function reduceEvent(previous: StudioState, event: StudioEvent): { state: s.memory = 'reused'; node('recall', 'done', 'M-024 načtena pro OP-129'); proof('recall', 'Opětovné použití', 'Nový modelový úkol OP-129 dostal M-024 do kontextu. Přínos zatím nebyl změřen srovnávacím vyhodnocením.'); break; case 'run.finish': + if (!s.pipeline.includes('evaluate')) return reject('Chybí plán vyhodnocení přínosu paměti.'); if (!s.merged || s.memory !== 'reused') return reject('Průchod ještě není úplný.'); s.finished = true; break; default: return reject('Neznámý typ události.'); diff --git a/src/client/process-studio/pipeline-model.ts b/src/client/process-studio/pipeline-model.ts new file mode 100644 index 000000000..90af5c7db --- /dev/null +++ b/src/client/process-studio/pipeline-model.ts @@ -0,0 +1,38 @@ +import type { StudioState } from './model'; + +/** Proposed delivery contract, not a claim that a connected service ran these tools. */ +export const PIPELINE = [ + { id: 'intake', phase: '01 · Opora', title: 'Zadání a rizika', owner: 'Plánovač', node: 'plan', input: 'Karta OP-128 a požadované chování', output: 'Rozsah, nepokrývané cíle, rizika a vlastník', gate: 'Každý požadavek má ověřitelný výstup.' }, + { id: 'sources', phase: '01 · Opora', title: 'Průzkum a původ zdrojů', owner: 'Paměť', node: 'context', input: 'Repozitář, AGENTS.md, dokumentace a zkušenosti', output: 'Mapa dotčených modulů a verzovaný kontext', gate: 'Dohledat zdroj; instrukce v externích datech nejsou oprávnění.' }, + { id: 'spec', phase: '01 · Opora', title: 'Kontrakt a stavový model', owner: 'Plánovač', node: 'contract', input: 'Přijaté AC-01 až AC-05', output: 'Stavy, přechody, invarianty a chybové větve', gate: 'Popsat duplicity, timeout, zrušení, reconnect a opakování.' }, + { id: 'baseline', phase: '02 · Kancelář', title: 'Výchozí stav', owner: 'Kontrolor', node: 'office', input: 'Čerstvě načtený origin/main', output: 'Výchozí SHA a výsledky kontrol před změnou', gate: 'Odlišit existující chyby od nové regrese.' }, + { id: 'worktrees', phase: '02 · Kancelář', title: 'Rozdělení práce a worktrees', owner: 'Plánovač', node: 'office', input: 'Plán, závislosti a vlastnictví souborů', output: 'Oddělené větve UI / události; smlouva rozhraní', gate: 'Žádný zásah do cizích změn; společné soubory mají vlastníka.' }, + { id: 'counterexample', phase: '02 · Kancelář', title: 'Protipříklad → regresní test', owner: 'Kontrolor', node: 'checks', input: 'Stavový model a reprodukce chyby', output: 'Nejmenší selhávající stopa a test očekávaného chování', gate: 'Model musí odpovídat kódu. Lean je volitelný adaptér, zde se nespouští.' }, + { id: 'integration', phase: '03 · Důkazy', title: 'Integrace změn', owner: 'Vývojář', node: 'backend', input: 'Diffy obou pracovníků a kontrakty modulů', output: 'Integrovaná revize se seznamem změněných souborů', gate: 'Konflikty řešit se zachováním obou záměrů; znovu ověřit rozhraní.' }, + { id: 'proof', phase: '03 · Důkazy', title: 'Model ↔ implementace', owner: 'Kontrolor', node: 'checks', input: 'Oprava R-01 a protipříklad', output: 'Vazba invariant → implementace → regresní test', gate: 'Úspěch modelu není automaticky důkaz správnosti celého programu.' }, + { id: 'independent', phase: '03 · Důkazy', title: 'Nezávislé review', owner: 'Kontrolor', node: 'review', input: 'Aktuální diff, kontrakt a výsledky kontrol', output: 'Nálezy s prioritou, souborem a způsobem ověření', gate: 'Blokující nález vrací práci k opravě; žádné odhadované úspěchy.' }, + { id: 'simplify', phase: '03 · Důkazy', title: 'Zjednodušení a nové kontroly', owner: 'Vývojář + kontrolor', node: 'review', input: 'Prověřená implementace a návrhy zjednodušení', output: 'V demo scénáři beze změny kódu; doporučení pro samostatný PR', gate: 'Každá skutečná změna SHA ruší staré CI a review; vrátit se na kontroly.' }, + { id: 'package', phase: '04 · GitHub', title: 'Dokumentace a balíček PR', owner: 'Vývojář', node: 'pr', input: 'Ověřená revize, README a příslušná docs stránka', output: 'PR: problém, změna chování, testy, limity, vazba na OP-128', gate: 'Bez tajemství a nesouvisejících souborů; screenshot u vizuální změny.' }, + { id: 'ci', phase: '04 · GitHub', title: 'CI a závislosti PR', owner: 'GitHub / kontrolor', node: 'pr', input: 'Modelový PR #214 a jeho aktuální head', output: 'Povinné kontroly a graf navazujících změn', gate: 'UI a další modul čekají na kontrakt. Úspěch starého head nestačí.' }, + { id: 'queue', phase: '04 · GitHub', title: 'Fronta sloučení', owner: 'GitHub / člověk', node: 'merge', input: 'Schválení člověka, aktuální kontroly a vyřešené diskuse', output: 'Pořadí: kontrakt → UI → navazující moduly', gate: 'Po změně base znovu ověřit merge výsledek; závislý PR čeká.' }, + { id: 'release', phase: '05 · Doručení', title: 'Verzovaný artefakt', owner: 'Doručovací pipeline', node: 'merge', input: 'Potvrzení merge a výsledné SHA', output: 'Modelový release manifest a kandidát do stagingu', gate: 'Sloučení není nasazení. Artefakt musí odpovídat sloučené revizi.' }, + { id: 'smoke', phase: '05 · Doručení', title: 'Staging, smoke a rollback', owner: 'Kontrolor', node: 'checks', input: 'Kandidát a poslední funkční verze', output: 'Modelový smoke výsledek a popsaný návrat na předchozí verzi', gate: 'Ověřit start, reconnect a klíčový tok. Produkční rollout je samostatné rozhodnutí.' }, + { id: 'close', phase: '05 · Doručení', title: 'Uzavření karty Opory', owner: 'Plánovač', node: 'plan', input: 'Merge receipt, CI, smoke a otevřené follow-upy', output: 'Souhrn výsledků, zbývající rizika a návazné úkoly', gate: 'Metriky z diffů a běhů; počet řádků sám neříká kvalitu.' }, + { id: 'evaluate', phase: '06 · Paměť', title: 'Vyhodnocení přínosu', owner: 'Paměť + kontrolor', node: 'learn', input: 'Zkušenost M-024 použitá v OP-129', output: 'Návrh párového vyhodnocení s pamětí a bez ní', gate: 'Stejná sada úloh a podmínky; zatím žádný změřený nárůst kvality.' }, +] as const; +export type PipelineId = typeof PIPELINE[number]['id']; +export function pipelineError(s: StudioState, id: string | undefined): string | undefined { + const next = PIPELINE[s.pipeline.length]; + if (!next || next.id !== id) return 'Pipeline čeká na předchozí krok; opakování ani přeskočení není platné.'; + const eligible: Record = { + intake: s.nodes.plan.status === 'working', sources: s.memory === 'recalled', spec: s.accepted, + baseline: s.accepted, worktrees: s.accepted, counterexample: s.nodes.office.status === 'done', + integration: s.nodes.frontend.status === 'done' && s.nodes.backend.status === 'done', + proof: s.repaired && s.checksRevision === s.revision, + independent: s.checksRevision === s.revision, simplify: s.reviewRevision === s.revision, + package: s.reviewRevision === s.revision, ci: s.prRevision === s.revision, + queue: s.approvalRevision === s.revision, release: s.merged, smoke: s.merged, + close: s.merged, evaluate: s.memory === 'reused', + }; + return eligible[next.id] ? undefined : 'Chybí vstup nebo aktuální důkaz kroku pipeline.'; +} diff --git a/src/client/process-studio/pipeline.css b/src/client/process-studio/pipeline.css new file mode 100644 index 000000000..2e0788dc5 --- /dev/null +++ b/src/client/process-studio/pipeline.css @@ -0,0 +1,25 @@ +.delivery-panel { margin-top: 18px; padding: 22px; background: linear-gradient(125deg, #151b2b, #101820); border: 1px solid #344257; border-radius: 10px; } +.delivery-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.delivery-heading h2 { margin: 7px 0; font-size: 23px; } +.delivery-heading p { color: var(--muted); font-size: 12px; } +#pipeline-progress { color: var(--cyan); font: 11px var(--mono); } +#pipeline-meter { display: block; width: 100%; height: 5px; margin: 18px 0 22px; accent-color: var(--cyan); } +.delivery-lanes { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; } +.delivery-lane { min-width: 0; border-left: 1px solid #334258; padding-left: 12px; } +.delivery-lane h3 { color: #b9c8df; font: 11px var(--mono); margin: 0 0 14px; } +.delivery-step { width: 100%; text-align: left; display: flex; align-items: start; gap: 8px; background: #111826; border: 1px solid #2e3a4c; border-radius: 6px; padding: 11px 9px; margin-bottom: 8px; color: #9aaac0; font-size: 11px; line-height: 1.5; min-height: 53px; } +.delivery-status { color: #77869b; } +.delivery-step.done .delivery-status { color: var(--green); } +.delivery-step.current { border-color: #378e95; background: #142e36; color: #e0ffff; } +.delivery-step.current .delivery-status { color: var(--cyan); animation: delivery-pulse 1.6s ease-in-out infinite; } +.delivery-step.selected { outline: 1px solid var(--pink); outline-offset: 2px; } +.delivery-step:focus-visible { outline: 2px solid white; outline-offset: 2px; } +.delivery-detail { display: grid; grid-template-columns: 1fr 1fr 1fr 1.3fr; gap: 22px; padding: 20px; margin-top: 18px; background: #0b121e; border: 1px solid #2c3c52; border-radius: 7px; } +.delivery-detail h3 { font-size: 16px; margin: 9px 0; } +.delivery-detail p { font-size: 12px; line-height: 1.7; color: #b3c1d3; margin: 9px 0 0; } +.delivery-chain { margin: 18px 0 0; font: 11px/1.8 var(--mono); color: var(--purple); overflow-wrap: anywhere; } +.paused .delivery-status { animation-play-state: paused; } +@keyframes delivery-pulse { 50% { opacity: .35; } } +@media (max-width: 1250px) { .delivery-lanes { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px 12px; }.delivery-detail { grid-template-columns: 1fr 1fr; } } +@media (max-width: 600px) { .delivery-panel { padding: 16px; }.delivery-heading { display: block; }.delivery-lanes { grid-template-columns: 1fr 1fr; }.delivery-detail { grid-template-columns: 1fr; gap: 14px; }.delivery-heading h2 { font-size: 20px; } } +@media (prefers-reduced-motion: reduce) { .delivery-step.current .delivery-status { animation: none; } } diff --git a/src/client/process-studio/pipeline.ts b/src/client/process-studio/pipeline.ts new file mode 100644 index 000000000..15f6ce537 --- /dev/null +++ b/src/client/process-studio/pipeline.ts @@ -0,0 +1,52 @@ +import { PIPELINE } from './pipeline-model'; +import type { StudioState } from './model'; +import { $, dom } from './panels'; +import './pipeline.css'; + +/** Projection of the same replayable state as the graph, never a second timer. */ +export class DeliveryPipeline { + private selected: string | null = null; + private state?: StudioState; + private buttons = new Map(); + constructor() { + const host = $('#delivery-lanes'); + for (const phase of new Set(PIPELINE.map(step => step.phase))) { + const lane = dom('section', 'delivery-lane'); lane.append(dom('h3', '', phase)); + for (const step of PIPELINE.filter(step => step.phase === phase)) { + const button = dom('button', 'delivery-step'); button.type = 'button'; button.dataset.stage = step.id; + button.append(dom('span', 'delivery-status', '○'), dom('span', '', step.title)); + button.addEventListener('click', () => { this.selected = step.id; if (this.state) this.update(this.state); }); + this.buttons.set(step.id, button); lane.append(button); + } + host.append(lane); + } + $('#pipeline-follow').addEventListener('click', () => { this.selected = null; if (this.state) this.update(this.state); }); + } + update(s: StudioState) { + this.state = s; + const next = PIPELINE[s.pipeline.length]; + const selected = PIPELINE.find(step => step.id === this.selected) ?? next ?? PIPELINE[PIPELINE.length - 1]; + $('#pipeline-progress').textContent = `${s.pipeline.length} / ${PIPELINE.length} předání doloženo v ukázce`; + $('#pipeline-meter').value = s.pipeline.length; + $('#pipeline-meter').max = PIPELINE.length; + for (const step of PIPELINE) { + const button = this.buttons.get(step.id)!; + const done = s.pipeline.includes(step.id), current = next?.id === step.id; + button.classList.toggle('done', done); button.classList.toggle('current', current); + button.classList.toggle('selected', selected.id === step.id); + button.setAttribute('aria-pressed', String(selected.id === step.id)); + button.setAttribute('aria-label', `${step.title}: ${done ? 'doloženo v modelu' : current ? 'čeká na vstup / další událost' : 'čeká na předchozí kroky'}`); + button.firstElementChild!.textContent = done ? '✓' : current ? '◉' : '○'; + } + const detail = $('#pipeline-detail'); detail.replaceChildren(); + const heading = dom('div'); heading.append(dom('p', 'eyebrow', selected.phase + ' / ' + selected.owner), dom('h3', '', selected.title)); + detail.append(heading); + for (const [label, value] of [['VSTUP', selected.input], ['VÝSTUP', selected.output], ['PODMÍNKA PŘEDÁNÍ', selected.gate]]) { + const block = dom('div'); block.append(dom('span', 'pane-label', label), dom('p', '', value)); detail.append(block); + } + $('#delivery-chain').textContent = s.merged + ? 'PR #214 · modelově sloučeno → UI / další moduly · navazující PR nejsou v této ukázce provedené' + : s.prRevision ? 'PR #214 · kontrakt → UI · čeká na kontrakt → další modul · čeká na rozhraní' + : 'Plán závislostí: kontrakt událostí → UI kanceláře → další repozitáře'; + } +} diff --git a/src/client/process-studio/scenario.ts b/src/client/process-studio/scenario.ts index 3e778febb..2d7c8cb5b 100644 --- a/src/client/process-studio/scenario.ts +++ b/src/client/process-studio/scenario.ts @@ -1,22 +1,33 @@ import type { EventType, Repository, StudioEvent } from './model'; +import { PIPELINE, type PipelineId } from './pipeline-model'; -export interface Scene { type: EventType; message: string; node?: string; repositoryIds?: string[] } +export interface Scene { type: EventType; message: string; node?: string; repositoryIds?: string[]; stage?: PipelineId } +function delivery(stage: PipelineId): Scene { + const step = PIPELINE.find(step => step.id === stage)!; + return { type: 'pipeline.observe', stage, message: `${step.title}: ${step.output}. ${step.gate}` }; +} export function scenario(repositories: Repository[]): Scene[] { return [ { type: 'plan.draft', message: 'Opora vytváří kartu OP-128: cíl, rozsah, vlastníci a očekávaný výstup.' }, + delivery('intake'), { type: 'memory.recall', message: 'OpenViking předává dvě dřívější zkušenosti a zdrojový kontext.' }, + delivery('sources'), { type: 'plan.gap', message: 'Kontrola plánu našla mezeru: chybí popis a test obnovení spojení.' }, { type: 'plan.refine', message: 'Plán roste do revize 2. Přibývá AC-03, jeho test a očekávaný výsledek.' }, { type: 'plan.accept', message: 'Přijatý plán má pět podmínek dokončení, vlastníky a dohledatelné důkazy.' }, + delivery('spec'), ...repositories.flatMap(r => [ { type: 'repo.inspect' as const, node: r.id, message: `${r.name}: zapracováváme zvolenou roli do modelového workflow.` }, { type: 'repo.verified' as const, node: r.id, message: `${r.name}: vstup, výstup a kontrola rozhraní jsou součástí plánu.` }, ]), + delivery('baseline'), delivery('worktrees'), { type: 'work.assign', message: 'Kancelář přiděluje dvě oddělené větve pracovníkům. Každý dostává stejnou revizi plánu.' }, + delivery('counterexample'), { type: 'work.start', node: 'frontend', message: 'Vývojář začíná UI průběhu. Změna patří OP-128 / revizi 2.' }, { type: 'work.start', node: 'backend', message: 'Druhý pracovník souběžně připravuje zpracování událostí a obnovu stavu.' }, { type: 'work.complete', node: 'frontend', message: 'UI má modelový diff a hotové soubory. Správnost ještě nepotvrdily kontroly.' }, { type: 'work.complete', node: 'backend', message: 'Obě větve předávají výstupy. Začíná společná kontrola smluveného chování.' }, + delivery('integration'), { type: 'checks.start', message: 'Spouštíme typovou kontrolu, testy a sestavení vůči přijatému plánu.' }, { type: 'checks.fail', message: 'Zachycena chyba AC-02: duplicitní událost vytvořila druhý výsledek. Předání je zastaveno.' }, { type: 'repair.plan', message: 'Workflow se rozšiřuje o opravu R-01. Revize 3 zneplatňuje staré kontroly.' }, @@ -25,13 +36,19 @@ export function scenario(repositories: Repository[]): Scene[] { { type: 'repair.complete', message: 'Oprava je připravená. Výsledek musí projít novými kontrolami.' }, { type: 'checks.start', message: 'Nový běh ověřuje revizi 3 a rozhraní všech připojených modulů.' }, { type: 'checks.pass', repositoryIds: repositories.map(r => r.id), message: '42/42 modelových testů prošlo; typy i sestavení jsou v pořádku pro aktuální revizi.' }, + delivery('proof'), delivery('independent'), { type: 'review.pass', message: 'Kontrolor propojuje podmínky plánu, změněné soubory a výsledky kontrol.' }, + delivery('simplify'), delivery('package'), { type: 'pr.open', message: 'Ukázkový PR #214 obsahuje diff, testy a vazby na kartu i související repozitáře.' }, + delivery('ci'), { type: 'human.wait', message: 'Vše je připravené k revizi člověkem. Ukázka pokračuje až po vašem rozhodnutí.' }, + delivery('queue'), { type: 'merge.confirm', message: 'Modelové sloučení je potvrzené. Opora připojuje výsledek ke kartě.' }, + delivery('release'), delivery('smoke'), delivery('close'), { type: 'memory.propose', message: 'Z doložené opravy vzniká kandidát zkušenosti M-024 se zdroji a změnou obsahu.' }, { type: 'memory.persist', message: 'Přichází modelové potvrzení zápisu M-024. Zkušenost je připravená pro další úkol.' }, { type: 'memory.reuse', message: 'Úkol OP-129 znovu načítá M-024. Vidíme použití paměti; zlepšení kvality zatím není změřené.' }, + delivery('evaluate'), { type: 'run.finish', message: 'Průchod dokončen: plán → práce → oprava → důkazy → výsledek → opětovně použitá zkušenost.' }, ]; } diff --git a/tests/process-studio.test.ts b/tests/process-studio.test.ts index 150e30a66..4b2a3bb0f 100644 --- a/tests/process-studio.test.ts +++ b/tests/process-studio.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { initialState, reduceEvent, canMerge, parseRepository, type StudioState, type EventType, type StudioEvent } from '../src/client/process-studio/model.js'; import { eventFor, scenario } from '../src/client/process-studio/scenario.js'; import { processStudioRoute } from '../src/server/http/routes/process-studio.js'; +import { PIPELINE } from '../src/client/process-studio/pipeline-model.js'; function apply(s: StudioState, type: EventType, extras: Partial = {}): StudioState { const result = reduceEvent(s, { ...eventFor({ type, message: type }, s.run, s.sequence + 1), ...extras }); @@ -82,3 +83,40 @@ test('repository identity rejects unsafe, duplicate and unsupported inputs', () test('the standalone studio uses the session-authenticated route registry', () => { assert.equal(processStudioRoute.auth, 'session'); assert.equal(processStudioRoute.method, 'GET'); }); + +test('delivery handoffs cannot skip predecessors, repeat or complete without their inputs', () => { + let state = initialState(); + for (const scene of scenario([])) { + if (state.waiting) state = apply(state, 'human.approve'); + if (scene.type === 'pipeline.observe') { + const next = PIPELINE[state.pipeline.length + 1]; + if (next) assert.ok(reduceEvent(state, eventFor({ ...scene, stage: next.id }, state.run, state.sequence + 1)).error); + if (scene.stage === 'queue') { + const stale = { ...state, approvalRevision: 0 }; + assert.ok(reduceEvent(stale, eventFor(scene, stale.run, stale.sequence + 1)).error); + } + if (scene.stage === 'release') { + const unmerged = { ...state, merged: false }; + assert.ok(reduceEvent(unmerged, eventFor(scene, unmerged.run, unmerged.sequence + 1)).error); + } + state = apply(state, scene.type, scene); + assert.ok(reduceEvent(state, eventFor(scene, state.run, state.sequence + 1)).error); + } else state = apply(state, scene.type, scene); + } + assert.deepEqual(state.pipeline, PIPELINE.map(step => step.id)); +}); + +test('review, human handoff, merge and memory require delivery evidence as well as old gates', () => { + for (const [target, missing] of [['review.pass', 'independent'], ['human.wait', 'ci'], ['merge.confirm', 'queue'], ['memory.propose', 'close'], ['run.finish', 'evaluate']] as const) { + let state = initialState(); + for (const scene of scenario([])) { + if (state.waiting) state = apply(state, 'human.approve'); + if (scene.type === target) { + const incomplete = { ...state, pipeline: state.pipeline.filter(id => id !== missing) }; + const result = reduceEvent(incomplete, eventFor(scene, incomplete.run, incomplete.sequence + 1)); + assert.ok(result.error, target); assert.equal(result.state, incomplete); break; + } + state = apply(state, scene.type, scene); + } + } +}); From 019736b51ac76909f7999d1c3a1bfb567bca52e8 Mon Sep 17 00:00:00 2001 From: Pletor Date: Thu, 22 Oct 2026 01:15:37 +0200 Subject: [PATCH 4/5] feat: add live office process wall --- README.md | 1 + docs/process-studio.md | 2 + docs/process-wall.md | 28 +++++ src/client/features/process-wall/index.ts | 46 +++++++++ src/client/features/process-wall/model.ts | 107 ++++++++++++++++++++ src/client/features/process-wall/texture.ts | 49 +++++++++ src/client/features/process-wall/ui.css | 24 +++++ src/client/features/process-wall/ui.ts | 95 +++++++++++++++++ src/client/features/process-wall/world.ts | 23 +++++ src/client/main.ts | 2 + src/client/ui/help.ts | 1 + src/client/world/office/build.ts | 2 + src/server/http/routes/process-studio.ts | 2 +- src/server/http/static.ts | 5 +- tests/process-wall-framing.test.ts | 26 +++++ tests/process-wall.test.ts | 43 ++++++++ 16 files changed, 453 insertions(+), 3 deletions(-) create mode 100644 docs/process-wall.md create mode 100644 src/client/features/process-wall/index.ts create mode 100644 src/client/features/process-wall/model.ts create mode 100644 src/client/features/process-wall/texture.ts create mode 100644 src/client/features/process-wall/ui.css create mode 100644 src/client/features/process-wall/ui.ts create mode 100644 src/client/features/process-wall/world.ts create mode 100644 tests/process-wall-framing.test.ts create mode 100644 tests/process-wall.test.ts diff --git a/README.md b/README.md index 0913668e9..2e574224c 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/i ## What it is +- **Opora process wall.** Above the north-wall boards, aim and press E for a live view of workers, queue tasks, branches, GitHub checks and blockers. Switch the wall's view, inspect recent observations or open the detailed demo inside the same window. See [controls and data boundaries](docs/process-wall.md). - **Process Observatory (interactive demo).** Open `/process-studio` after signing in to explore a Czech-language simulation of an Opora plan, parallel office workers, failed checks and repair, GitHub delivery, and memory reuse. A six-phase delivery pipeline exposes 17 handoffs, from source investigation and worktrees through counterexamples, review, PR dependencies, staging and evaluation. Animated agents follow recorded events; repository-role inputs extend the local scenario. It uses sample data and does not connect accounts or change repositories. See [the scenario, controls and integration boundary](docs/process-studio.md). - **A floor per project.** Ride the elevator, pick one of your GitHub repos, and the office clones it (showing how far along it is) and opens a floor for it. Every worker, board and queue on that floor works in that checkout. - **Workers at desks.** Walk up to an empty desk, press **E**, and pick Claude Code, Codex, OpenCode, Grok, Muse, DeepSeek Harness, Pi or Cursor, each with its model and reasoning effort. The agent's live terminal shows on its laptop, and anyone can open it and type. diff --git a/docs/process-studio.md b/docs/process-studio.md index fdc3b34aa..b58d34a46 100644 --- a/docs/process-studio.md +++ b/docs/process-studio.md @@ -4,6 +4,8 @@ The separate `/process-studio` page is an interactive Czech-language **simulatio It demonstrates how an Opora task, office workers, memory and GitHub could share an observable workflow. It does not connect to Canvas, OpenViking, GitHub or a running worker. Every event, test result, memory record, PR and merge displayed on the page is model data. +The [office process wall](process-wall.md) embeds this demo alongside its separately labeled live views. Only this session-authenticated page allows same-origin framing (`X-Frame-Options: SAMEORIGIN` and CSP `frame-ancestors 'self'`). Other static pages retain denied framing. + ## What the scenario demonstrates 1. A card has a goal, scope, expected output and acceptance conditions. Recall brings earlier experiences into the plan. diff --git a/docs/process-wall.md b/docs/process-wall.md new file mode 100644 index 000000000..d14fb78eb --- /dev/null +++ b/docs/process-wall.md @@ -0,0 +1,28 @@ +# Opora process wall + +The 3D office has a wide, illuminated **OPORA · Řízení práce** display above the north-wall Issues, Task queue and Pull Requests boards. Aim at it and press **E** or click to open its readable detail window. The wall occupies the upper wall above the existing board labels; it does not replace the task boards or obstruct the floor. + +## Views and controls + +- **Celý proces** joins queue tasks, workers and PRs using worker identities, PR numbers and branch names. Three prioritized rows fit the physical screen; **Další strana na stěně** cycles additional rows. +- **Pracovníci** filters to rows with an observed worker. **Blokace** shows requests for input, lost worktrees, failed tasks, failed GitHub checks and requested review changes. Selecting these views also changes the physical wall, locally for that viewer. +- The window shows each row's branch, observed phase, head SHA when supplied, checks, review and a GitHub link to inspect the PR evidence. Its phase marker is a projection, not a completed-step checklist. +- **Historie pozorování** keeps the latest 80 changed row observations in browser memory. It clears on a fresh welcome/floor snapshot and page reload. It is not a durable or complete server audit. +- **Podrobný plán · DEMO** embeds the existing [Process Observatory](process-studio.md), including the six-phase pipeline, repository-role form, memory proposal/reuse and replay. It is clearly separated from live office data. +- The top-right **✕** and **Esc** close the window using the office's normal modal/focus lifecycle. Escape also works from the embedded demo; if that demo's own information dialog is open, the first Escape closes that inner dialog. + +The small animated spider on each row stays at the observed phase. Its legs move only when the worker reports `working`, the socket is connected and the current snapshot is ready. Reduced-motion preferences suppress that motion. There is no invented automatic advancement when the office is idle, and the wall never launches work or changes a PR. + +## Live data and limits + +The wall reuses the existing authenticated office connection and store, without another socket or credentials. Worker updates, queue updates and GitHub PR snapshots refresh its projection. Disconnects show **ODPOJENO** and freeze motion; reconnects show **SYNCHRONIZACE** until the floor snapshot arrives. The detail displays the last relevant observation time separately from GitHub's fetch time and errors. GitHub snapshots older than three minutes are flagged as potentially stale. + +A worker's `done` means its turn ended, not that tests passed. A test tool call means a check was invoked, not that it succeeded. Passing CI and an approved review still do not mean merged. Only the PR snapshot's `MERGED` state produces the merged phase, and it does not imply deployment. + +Opora's external plan, OpenViking memory, releases and deployment are not connected by this change. Their proposed contracts remain in the clearly labeled demo. The live view does not claim complete coverage of background mechanisms or provide elapsed-time comparisons across runs. Durable correlated events and adapters are required for those capabilities. + +## Implementation and validation + +`src/client/features/process-wall/` owns the pure projection/history model, canvas texture, fixture, modal and subscription lifecycle. The feature joins the main install list and office fixture registry; no global store or protocol additions are needed. It is available on the office map, not on alternate castle/station maps or `/lite`. + +Run `npm run typecheck`, `npm test`, and `npm run build`. `tests/process-wall.test.ts` verifies association, blockers, status/evidence distinctions, stale connection labels and bounded history. Visual verification should cover the real 3D fixture, live projection with controlled worker messages, view switching, nested demo Escape, mouse-look restoration, disconnect and mobile detail. Synthetic test messages are browser fixtures, not claims of real agent execution. diff --git a/src/client/features/process-wall/index.ts b/src/client/features/process-wall/index.ts new file mode 100644 index 000000000..a8bc75fdd --- /dev/null +++ b/src/client/features/process-wall/index.ts @@ -0,0 +1,46 @@ +import type * as THREE from 'three'; +import type { Ctx } from '../../core/context'; +import { hintTitle, key, onE } from '../../core/hint'; +import { store } from '../../state'; +import { projectWall, WallHistory, type WallView } from './model'; +import { ProcessTexture } from './texture'; +import { ProcessWallPanel } from './ui'; + +declare module '../../world/types' { + interface InteractKinds { + processwall: true; + } +} + +export function installProcessWall(ctx: Ctx) { + const texture = new ProcessTexture(), history = new WallHistory(); + const mat = ctx.office.processWall.screen.material as THREE.MeshBasicMaterial; + mat.map = texture.texture; mat.needsUpdate = true; + let view: WallView = 'overview', page = 0, ready = false, at = 0, lastDraw = 0, lastPanel = 0; + const snapshot = () => projectWall({ + floor: store.currentFloor()?.name || 'Čeká na patro', workers: [...store.workers.values()], tasks: store.queue.tasks, + pulls: store.pulls, connected: ctx.net.up, ready, at, + }); + let current = snapshot(); + const panel = new ProcessWallPanel(history, next => { view = next; page = 0; lastDraw = 0; }, () => { page++; lastDraw = 0; }); + const refresh = () => { current = snapshot(); panel.update(current); lastDraw = 0; }; + ctx.interactions.define('processwall', { + reach: 18, + hint: () => ({ k: 'process-wall', parts: [hintTitle('OPORA · Řízení práce'), key('E', 'Sledovat proces')] }), + use: onE(() => panel.open(current)), + }); + ctx.net.onStatus(() => { ready = false; refresh(); }); + ctx.messages.onAny(message => { + if (message.t === 'welcome' || message.t === 'floor.enter') { ready = true; history.clear(); page = 0; } + if (!['welcome', 'floor.enter', 'worker.update', 'worker.remove', 'worker.worktree', 'queue', 'gh.pulls'].includes(message.t)) return; + at = Date.now(); refresh(); history.observe(current); panel.update(current); + }); + ctx.ticks.add('world', frame => { + if (document.hidden) return; + if (frame.now - lastPanel > 1000) { current = snapshot(); panel.update(current); lastPanel = frame.now; } + if (!ctx.inOffice() || ctx.upTop()) return; + if (frame.now - lastDraw < (current.running && current.connected && ready && !ctx.reduceMotion.matches ? 100 : 1000)) return; + texture.draw(current, view, page, frame.t, ctx.reduceMotion.matches); lastDraw = frame.now; + }); + texture.draw(current, view, page, 0, true); +} diff --git a/src/client/features/process-wall/model.ts b/src/client/features/process-wall/model.ts new file mode 100644 index 000000000..761edca8c --- /dev/null +++ b/src/client/features/process-wall/model.ts @@ -0,0 +1,107 @@ +import type { GhPull, GhState, QueueTask, WorkerInfo } from '../../../shared/protocol'; + +export const PHASES = ['Fronta', 'Práce', 'Kontroly', 'Review / PR', 'Výstup', 'Sloučeno'] as const; +export type WallView = 'overview' | 'workers' | 'blockers'; +export interface ProcessRow { + id: string; title: string; owner: string; detail: string; branch: string; phase: number; + blocked: boolean; moving: boolean; pr?: GhPull; workerId?: string; +} +export interface WallSnapshot { + floor: string; connected: boolean; ready: boolean; at: number; + rows: ProcessRow[]; workers: number; queued: number; running: number; blocked: number; + githubAt: number; githubError?: string; githubLoading: boolean; +} +export interface WallInput { + floor: string; workers: WorkerInfo[]; tasks: QueueTask[]; pulls: GhState; + connected: boolean; ready: boolean; at: number; +} +const ACTION = { read: 'Čte zdroje', edit: 'Upravuje kód', test: 'Spustil kontrolu · výsledek nepotvrzen', web: 'Hledá zdroje', failing: 'Opakovaná selhání nástroje' }; + +/** Observed status only: a finished agent turn never implies passing CI or a merged PR. */ +export function processRow(task?: QueueTask, worker?: WorkerInfo, pr?: GhPull): ProcessRow { + const row: ProcessRow = { + id: task ? `task:${task.id}` : worker ? `worker:${worker.id}` : `pr:${pr!.number}`, + title: task?.title || worker?.task?.name || worker?.title || pr?.title || worker?.name || 'Práce', + owner: worker?.name || task?.workerName || pr?.author || 'Nepřiděleno', + branch: worker?.worktree?.branch || task?.branch || pr?.headRefName || 'Větev není doložena', + phase: 0, blocked: false, moving: false, pr, workerId: worker?.id, + detail: 'Čeká na přidělení pracovníka', + }; + if (worker) { + row.phase = 1; + row.detail = worker.status === 'working' ? (worker.action ? ACTION[worker.action] : 'Pracovník hlásí práci') + : worker.status === 'starting' ? 'Pracovník startuje' : worker.status === 'idle' ? 'Pracovník je připraven' + : worker.status === 'done' ? 'Tah agenta skončil · správnost ani merge tím nejsou potvrzené' + : worker.status === 'needs_input' ? 'Čeká na odpověď člověka' : 'Pracovník neběží'; + row.moving = worker.status === 'working'; + if (worker.action === 'test' && row.moving) row.phase = 2; + if (worker.status === 'done') row.phase = 4; + row.blocked = worker.status === 'needs_input' || !!worker.lost || (worker.status === 'working' && worker.action === 'failing'); + if (worker.lost) row.detail = 'Chybí pracovní adresář'; + } else if (task?.status === 'running') { + row.phase = 1; row.detail = 'Fronta hlásí běh · pracovník není v aktuálním snapshotu'; + } + if (task?.status === 'done' && !worker) { + row.phase = 4; row.detail = 'Úloha ve frontě skončila · merge není doložen'; + } + if (task?.outcome && task.outcome !== 'done') { row.blocked = true; row.detail = task.error || `Úloha skončila: ${task.outcome}`; } + if (pr) { + if (pr.state === 'MERGED') { row.phase = 5; row.blocked = false; row.moving = false; row.detail = `GitHub potvrzuje sloučení #${pr.number} · nasazení nesledujeme`; } + else if (pr.state === 'CLOSED') { row.phase = 4; row.moving = false; row.detail = `PR #${pr.number} uzavřen bez sloučení`; } + else if (pr.checks === 'fail' || pr.reviewDecision === 'CHANGES_REQUESTED') { + row.blocked = true; row.phase = pr.checks === 'fail' ? 2 : 3; + row.detail = pr.checks === 'fail' ? 'GitHub kontroly selhaly' : 'Review požaduje změny'; + } else if (!row.blocked) { + row.phase = pr.checks === 'pending' ? 2 : 3; + row.detail = pr.isDraft ? 'Rozpracovaný PR' : pr.checks === 'pending' ? 'Čeká na GitHub kontroly' + : pr.checks === 'none' ? 'PR existuje · kontroly nejsou doložené' + : pr.reviewDecision === 'APPROVED' ? 'Kontroly prošly a review schválilo · merge čeká' : 'Kontroly prošly · čeká na review / merge'; + } + } + return row; +} +export function projectWall(input: WallInput): WallSnapshot { + const usedWorkers = new Set(), usedPulls = new Set(); + const rows: ProcessRow[] = []; + const matchPull = (task?: QueueTask, worker?: WorkerInfo) => input.pulls.items.find(p => + p.number === task?.pr?.number || p.number === worker?.pr?.number || + (!!p.headRefName && p.headRefName === (worker?.worktree?.branch || task?.branch))); + for (const task of input.tasks) { + const worker = input.workers.find(w => w.id === task.workerId); + const pr = matchPull(task, worker); + if (worker) usedWorkers.add(worker.id); + if (pr) usedPulls.add(pr.number); + rows.push(processRow(task, worker, pr)); + } + for (const worker of input.workers.filter(w => !usedWorkers.has(w.id))) { + const pr = matchPull(undefined, worker); + if (pr) usedPulls.add(pr.number); + rows.push(processRow(undefined, worker, pr)); + } + for (const pr of input.pulls.items.filter(p => !usedPulls.has(p.number))) rows.push(processRow(undefined, undefined, pr)); + rows.sort((a, b) => Number(b.blocked) - Number(a.blocked) || Number(b.moving) - Number(a.moving) || a.title.localeCompare(b.title)); + return { floor: input.floor, connected: input.connected, ready: input.ready, at: input.at, rows, + workers: input.workers.length, queued: input.tasks.filter(t => t.status === 'queued').length, + running: input.workers.filter(w => w.status === 'working').length, blocked: rows.filter(r => r.blocked).length, + githubAt: input.pulls.fetchedAt, githubError: input.pulls.error, githubLoading: input.pulls.loading }; +} +export function visibleRows(s: WallSnapshot, view: WallView) { + return s.rows.filter(row => view === 'workers' ? !!row.workerId : view === 'blockers' ? row.blocked : true); +} +export function connectionLabel(s: WallSnapshot): string { + return !s.connected ? 'ODPOJENO · poslední známý stav' : !s.ready ? 'SYNCHRONIZACE · čeká na snapshot' : 'ŽIVĚ · agent-office'; +} +/** The local history stores observations, never terminal output or an invented backend event log. */ +export class WallHistory { + entries: { at: number; text: string }[] = []; + private previous = new Map(); + clear() { this.entries = []; this.previous.clear(); } + observe(s: WallSnapshot) { + const next = new Map(); + for (const row of s.rows) { + const signature = JSON.stringify([row.title, row.detail, row.branch, row.phase, row.pr?.headRefOid]); next.set(row.id, signature); + if (this.previous.get(row.id) !== signature) this.entries.unshift({ at: s.at, text: `${row.title} · ${row.detail}` }); + } + this.previous = next; this.entries.length = Math.min(this.entries.length, 80); + } +} diff --git a/src/client/features/process-wall/texture.ts b/src/client/features/process-wall/texture.ts new file mode 100644 index 000000000..dc7083576 --- /dev/null +++ b/src/client/features/process-wall/texture.ts @@ -0,0 +1,49 @@ +import * as THREE from 'three'; +import { connectionLabel, PHASES, visibleRows, type WallSnapshot, type WallView } from './model'; + +export class ProcessTexture { + readonly canvas = document.createElement('canvas'); + readonly texture: THREE.CanvasTexture; + constructor() { + this.canvas.width = 1920; this.canvas.height = 320; + this.texture = new THREE.CanvasTexture(this.canvas); this.texture.colorSpace = THREE.SRGBColorSpace; + this.texture.anisotropy = 4; + } + draw(s: WallSnapshot, view: WallView, page: number, t: number, reduced: boolean) { + const g = this.canvas.getContext('2d')!; + g.fillStyle = '#091523'; g.fillRect(0, 0, 1920, 320); + const text = (v: string, x: number, y: number, size = 24, color = '#d9e9f8', width?: number) => { + g.fillStyle = color; g.font = `600 ${size}px system-ui, sans-serif`; + let shown = v; + if (width) while (shown.length > 1 && g.measureText(shown).width > width) shown = shown.slice(0, -2) + '…'; + g.fillText(shown, x, y); + }; + text('OPORA / ' + (view === 'workers' ? 'PRACOVNÍCI' : view === 'blockers' ? 'BLOKACE' : 'CELÝ PROCES'), 28, 42, 29); + text(connectionLabel(s), 1330, 40, 23, s.connected && s.ready ? '#6ce9cf' : '#ffcb82'); + text(`${s.workers} agentů / ${s.running} pracuje / ${s.queued} ve frontě / ${s.blocked} blokací`, 28, 84, 25, '#9bb6d1'); + text(s.floor, 1330, 82, 23, '#9bb6d1', 560); + const rows = visibleRows(s, view), count = Math.max(1, Math.ceil(rows.length / 3)); + const part = rows.slice((page % count) * 3, (page % count) * 3 + 3); + PHASES.forEach((name, i) => text(name, 825 + i * 172, 118, 19, '#8ba5c0')); + if (!part.length) text(view === 'blockers' ? 'Žádná doložená blokace v aktuálních datech.' : 'Zatím žádná práce. Stěna čeká na skutečné události kanceláře.', 32, 190, 30); + part.forEach((row, n) => { + const y = 150 + n * 52; + text(row.title, 28, y, 23, row.blocked ? '#ffcb82' : '#e6f2ff', 470); + text(row.owner + ' · ' + row.detail, 28, y + 22, 16, '#9bb6d1', 735); + g.strokeStyle = '#2b4157'; g.lineWidth = 3; g.beginPath(); g.moveTo(850, y); g.lineTo(1710, y); g.stroke(); + PHASES.forEach((_, i) => { + g.fillStyle = i === row.phase ? row.blocked ? '#ffcb82' : '#6ce9cf' : '#304357'; + g.beginPath(); g.arc(850 + i * 172, y, i === row.phase ? 10 : 5, 0, Math.PI * 2); g.fill(); + }); + // One small spider at the observed stage. Legs move only while a connected worker reports work. + const x = 850 + row.phase * 172, walk = row.moving && s.connected && s.ready && !reduced ? Math.sin(t * 7) * 4 : 0; + g.strokeStyle = row.blocked ? '#ffcb82' : '#6ce9cf'; g.lineWidth = 2; + for (const side of [-1, 1]) for (let leg = 0; leg < 4; leg++) { + g.beginPath(); g.moveTo(x + side * 5, y + (leg - 1.5) * 3); g.lineTo(x + side * 14, y + (leg - 1.5) * 7 + walk); g.lineTo(x + side * 20, y + (leg - 1.5) * 10); g.stroke(); + } + }); + text(`E / klik → otevřít · Strana ${(page % count) + 1}/${count} · Přepnutí pohledu v detailu`, 28, 300, 20, '#74d9d5'); + text('Paměť / nasazení: bez adaptéru', 1440, 300, 20, '#b5a5d9'); + this.texture.needsUpdate = true; + } +} diff --git a/src/client/features/process-wall/ui.css b/src/client/features/process-wall/ui.css new file mode 100644 index 000000000..906284a2d --- /dev/null +++ b/src/client/features/process-wall/ui.css @@ -0,0 +1,24 @@ +.process-wall-modal { width: min(1400px, 96vw); height: min(900px, 92vh); display: flex; flex-direction: column; background: #0b1624; color: #d9e9f8; border: 1px solid #36546a; border-radius: 14px; padding: 22px; box-shadow: 0 25px 100px #0009; } +.process-wall-modal > header { display: flex; justify-content: space-between; align-items: center; } +.process-wall-modal h2 { margin: 6px 0 10px; font-size: 27px; color: #e9f8ff; } +.process-wall-eyebrow { color: #73dfcf; font-size: 11px; letter-spacing: 2px; } +.process-wall-modal .close { align-self: start; color: white; background: #233950; } +.process-wall-status { color: #9fbed4; font-size: 12px; } +.process-wall-tabs { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 0; } +.process-wall-modal button { cursor: pointer; border: 1px solid #36546a; border-radius: 7px; background: #14283a; color: #d9e9f8; padding: 9px 13px; font: inherit; font-size: 12px; } +.process-wall-modal button.active { background: #25625d; border-color: #73dfcf; } +.process-wall-modal button:focus-visible, .process-wall-modal a:focus-visible { outline: 2px solid #ffcf87; outline-offset: 2px; } +.process-wall-controls { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px; font-size: 12px; color: #9fbed4; padding: 6px 0 12px; } +.process-wall-body { overflow-y: auto; min-height: 0; flex: 1; padding-right: 5px; } +.process-wall-metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } +.process-wall-metrics > div { padding: 16px; background: #14283a; border-radius: 8px; } +.process-wall-metrics strong { display: block; font-size: 27px; color: #73dfcf; }.process-wall-metrics span { font-size: 12px; } +.process-wall-notice, .process-wall-boundary { color: #acbfd1; font-size: 12px; line-height: 1.65; padding: 12px; border-left: 2px solid #ab94d4; background: #111e30; } +.process-wall-card { background: #122437; padding: 18px; margin: 10px 0; border: 1px solid #2c435a; border-radius: 9px; overflow-wrap: anywhere; } +.process-wall-card.blocked { border-color: #b58b51; }.process-wall-card h3 { margin: 0; font-size: 17px; }.process-wall-card p { font-size: 13px; }.process-wall-card code { font-size: 12px; color: #a1b8d1; } +.process-wall-card-title { display: flex; justify-content: space-between; gap: 15px; }.process-wall-card-title > span { font-size: 12px; color: #73dfcf; } +.process-wall-phases { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }.process-wall-phases span { padding: 5px 9px; background: #0b1827; color: #8eacc6; font-size: 11px; border-radius: 5px; }.process-wall-phases .current { color: #83f2dd; background: #204c4b; } +.process-wall-card a { color: #83f2dd; font-size: 13px; }.process-wall-empty { padding: 30px; color: #b5c8d8; } +.process-wall-event { display: flex; gap: 20px; border-bottom: 1px solid #2c435a; padding: 12px 0; font-size: 13px; }.process-wall-event time { color: #73dfcf; white-space: nowrap; } +.process-wall-demo { width: 100%; height: 65vh; border: 1px solid #36546a; border-radius: 9px; background: #0b1624; } +@media (max-width: 600px) { .process-wall-modal { padding: 12px; }.process-wall-modal h2 { font-size: 21px; }.process-wall-metrics { grid-template-columns: 1fr 1fr; }.process-wall-card-title { display: block; } } diff --git a/src/client/features/process-wall/ui.ts b/src/client/features/process-wall/ui.ts new file mode 100644 index 000000000..b088f4c97 --- /dev/null +++ b/src/client/features/process-wall/ui.ts @@ -0,0 +1,95 @@ +import { h, openModal, type Modal } from '../../ui/dom'; +import { connectionLabel, PHASES, visibleRows, type WallSnapshot, type WallView, type WallHistory } from './model'; +import './ui.css'; + +type Tab = WallView | 'history' | 'demo'; +export class ProcessWallPanel { + private modal?: Modal; + private tab: Tab = 'overview'; + private body = h('div.process-wall-body'); + private status = h('p.process-wall-status'); + private buttons = new Map(); + private frame?: HTMLIFrameElement; + private snapshot?: WallSnapshot; + private rendered = ''; + constructor(private history: WallHistory, private changeView: (view: WallView) => void, private nextPage: () => void) {} + open(s: WallSnapshot) { + if (this.modal) return; + this.tab = 'overview'; this.changeView('overview'); + this.rendered = ''; + const tabs = h('nav.process-wall-tabs', { 'aria-label': 'Pohled na proces' }); + this.buttons.clear(); + for (const [id, label] of [['overview', 'Celý proces'], ['workers', 'Pracovníci'], ['blockers', 'Blokace'], ['history', 'Historie pozorování'], ['demo', 'Podrobný plán · DEMO']] as const) { + const button = h('button', { type: 'button', onclick: () => { + this.tab = id; + if (id === 'overview' || id === 'workers' || id === 'blockers') this.changeView(id); + this.render(); + } }, label); + this.buttons.set(id, button); tabs.append(button); + } + const panel = h('section.process-wall-modal', { role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Opora – řízení práce' }, + h('header', {}, h('div', {}, h('span.process-wall-eyebrow', {}, 'CELÝ PROCES POD JEDNOU STŘECHOU'), h('h2', {}, 'OPORA · Řízení práce'))), + this.status, tabs, + h('div.process-wall-controls', {}, h('span', {}, 'Zvolený živý pohled se promítá také na stěnu.'), h('button', { type: 'button', onclick: () => this.nextPage() }, 'Další strana na stěně →')), + this.body); + this.modal = openModal(panel, { doing: 'sleduje proces práce', onClose: () => { this.frame = undefined; this.body.replaceChildren(); this.modal = undefined; } }); + this.update(s); this.buttons.get('overview')!.focus(); + } + update(s: WallSnapshot) { this.snapshot = s; if (this.modal) this.render(); } + private render() { + const s = this.snapshot; + if (!s || !this.modal) return; + const age = s.at ? Math.max(0, Math.floor((Date.now() - s.at) / 1000)) : null; + this.status.textContent = `${connectionLabel(s)} · ${s.floor} · poslední změna ${age === null ? 'zatím nepřijata' : `před ${age} s`}`; + for (const [id, button] of this.buttons) { button.classList.toggle('active', id === this.tab); button.setAttribute('aria-pressed', String(id === this.tab)); } + const githubStale = !!s.githubAt && Date.now() - s.githubAt > 180000; + const signature = JSON.stringify([this.tab, s, githubStale, this.tab === 'history' ? this.history.entries : null]); + if (signature === this.rendered) return; + this.rendered = signature; + if (this.tab === 'demo') { + if (this.frame) return; + this.body.replaceChildren(h('p.process-wall-notice', {}, 'DEMO · Modelový plán, paměť a pipeline. Tato animace nepopisuje právě běžící pracovníky kanceláře.')); + this.frame = h('iframe', { title: 'Modelová vizualizace procesu Opory', src: '/process-studio.html', class: 'process-wall-demo' }); + this.frame.addEventListener('load', () => { + // Same-origin child keeps Escape routed through the office modal/focus lifecycle. + this.frame?.contentDocument?.addEventListener('keydown', event => { + if (event.key !== 'Escape' || this.frame?.contentDocument?.querySelector('dialog[open]')) return; + event.preventDefault(); event.stopImmediatePropagation(); this.modal?.close(); + }, true); + }); + this.body.append(this.frame); return; + } + this.frame = undefined; this.body.replaceChildren(); + if (this.tab === 'history') { + this.body.append(h('p.process-wall-notice', {}, 'Posledních 80 změn pozorovaných v tomto okně a na tomto patře. Obnovení stránky historii smaže; nejde o úplný serverový audit.')); + if (!this.history.entries.length) this.body.append(h('p', {}, 'Čekáme na první pozorování.')); + for (const entry of this.history.entries) this.body.append(h('p.process-wall-event', {}, h('time', {}, new Date(entry.at).toLocaleTimeString('cs-CZ')), entry.text)); + return; + } + const summary = h('div.process-wall-metrics'); + for (const [label, value] of [['Pracovníci', s.workers], ['Pracuje', s.running], ['Ve frontě', s.queued], ['Blokace', s.blocked]]) summary.append(h('div', {}, h('strong', {}, value), h('span', {}, label))); + this.body.append(summary); + const ghAge = s.githubAt ? Math.max(0, Math.floor((Date.now() - s.githubAt) / 1000)) : null; + this.body.append(h('p.process-wall-notice', {}, s.githubError ? `GitHub: ${s.githubError} · zobrazen poslední známý stav` + : `GitHub: ${s.githubLoading ? 'obnovuje se · ' : ''}${ghAge === null ? 'data zatím nepřijata' : `snapshot ${new Date(s.githubAt).toLocaleTimeString('cs-CZ')}${githubStale ? ' · může být zastaralý' : ''}`}. Dokončený tah agenta neznamená úspěšné testy nebo merge.`)); + const rows = visibleRows(s, this.tab); + if (!rows.length) this.body.append(h('p.process-wall-empty', {}, this.tab === 'blockers' ? 'V přijatých datech není doložená blokace.' : 'Zatím žádná práce. Nové úkoly a pracovníci se objeví automaticky.')); + for (const row of rows) { + const phases = h('div.process-wall-phases', { 'aria-label': `Pozorovaná fáze: ${PHASES[row.phase]}` }); + PHASES.forEach((phase, i) => phases.append(h('span', { class: i === row.phase ? 'current' : '' }, i === row.phase ? '◉ ' + phase : phase))); + const article = h('article.process-wall-card', { class: row.blocked ? 'blocked' : '' }, + h('div.process-wall-card-title', {}, h('h3', {}, row.title), h('span', {}, row.owner)), phases, + h('p', {}, row.detail), h('code', {}, row.branch)); + if (row.pr) { + article.append(h('p', {}, `PR #${row.pr.number} · ${row.pr.headRefOid?.slice(0, 10) || 'SHA nepřijato'} · CI: ${row.pr.checks} · review: ${row.pr.reviewDecision || 'neuvedeno'}`)); + try { + const url = new URL(row.pr.url); + if (url.protocol === 'https:' && url.hostname === 'github.com' && !url.username && !url.password) article.append(h('a', { href: url.href, target: '_blank', rel: 'noopener noreferrer' }, 'Otevřít PR a důkazy ↗')); + } catch { /* Invalid service URL stays plain text. */ } + } + this.body.append(article); + } + this.body.append(h('div.process-wall-boundary', {}, h('strong', {}, 'Další části procesu'), + h('p', {}, 'Opora plán · OpenViking paměť · nasazení: živé adaptéry zatím nejsou připojené. Jejich navržené kroky, podmínky a rozšíření repozitářů najdete v Podrobném plánu · DEMO.'))); + } +} diff --git a/src/client/features/process-wall/world.ts b/src/client/features/process-wall/world.ts new file mode 100644 index 000000000..a74b581eb --- /dev/null +++ b/src/client/features/process-wall/world.ts @@ -0,0 +1,23 @@ +import * as THREE from 'three'; +import type { Fixture } from '../../world/office/fixture'; +import type { Interactable } from '../../world/types'; +import { textPlane } from '../../world/toon'; + +/** Above the existing north-wall boards and their labels, below the 6.8 m ceiling. */ +export const PROCESS_WALL = { x: -3.9, y: 5.45, z: -12.72, width: 12, height: 2 } as const; +export interface ProcessWall { screen: THREE.Mesh; group: THREE.Group } +declare module '../../world/types' { interface OfficeHandles { processWall: ProcessWall } } + +export const processWall: Fixture<'processWall'> = site => { + const p = PROCESS_WALL, group = new THREE.Group(); + group.position.set(p.x, p.y, p.z); + const frame = new THREE.Mesh(new THREE.BoxGeometry(p.width + .18, p.height + .18, .14), new THREE.MeshStandardMaterial({ color: '#16283b', metalness: .5, roughness: .45 })); + const screen = new THREE.Mesh(new THREE.PlaneGeometry(p.width, p.height), new THREE.MeshBasicMaterial({ color: '#ffffff', toneMapped: false, fog: false })); + screen.position.z = .08; group.add(frame, screen); + const label = textPlane('OPORA · ŘÍZENÍ PRÁCE', { bg: '#d4fff3', size: 60 }); + label.scale.multiplyScalar(.65); label.position.set(0, p.height / 2 + .18, .1); group.add(label); + const it: Interactable = { kind: 'processwall', x: p.x, z: p.z + 5, radius: 2.5 }; + group.userData.interact = it; + site.wall('north', p.x, p.y, p.width + .2, p.height + .5); + return { group, interactables: [it], handle: { processWall: { screen, group } } }; +}; diff --git a/src/client/main.ts b/src/client/main.ts index 7cc7da7d8..9f12433e8 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -62,6 +62,7 @@ import { installVoice } from './features/voice'; import { installWaiting } from './features/waiting'; import { installWalking } from './features/walking'; import { installWhiteboard } from './features/whiteboard'; +import { installProcessWall } from './features/process-wall'; import { installWorkerActions } from './features/workers/actions'; import { installWorkerViews } from './features/workers/views'; @@ -178,6 +179,7 @@ parts.pointer = installPointer(ctx, core, parts); installChat(ctx); parts.talk = installVoice(ctx, { tv: parts.tv }); installDictation(ctx); +installProcessWall(ctx); parts.hud = installHud(ctx, core, parts); // ---- Main loop --------------------------------------------------------------------------------------- diff --git a/src/client/ui/help.ts b/src/client/ui/help.ts index d1d9fc809..4823323a8 100644 --- a/src/client/ui/help.ts +++ b/src/client/ui/help.ts @@ -4,6 +4,7 @@ import { IS_MAC } from './termkeys'; export const HELP_ROWS: readonly (readonly [string, string])[] = [ + ['OPORA', 'The process wall above the north-wall boards: aim and press E for live work, blockers and local observation history. Switch its view in the window; the detailed pipeline is explicitly a demo.'], ['W A S D', 'Walk (hold Shift to run)'], ['Space', 'Jump'], ['☕', 'Press E at the coffee machine in the kitchen for a minute of quicker walking and higher jumps. Three cups in a row gives you the jitters'], diff --git a/src/client/world/office/build.ts b/src/client/world/office/build.ts index 15c0e6237..b2bf3801f 100644 --- a/src/client/world/office/build.ts +++ b/src/client/world/office/build.ts @@ -11,6 +11,7 @@ import { jukebox } from '../../features/jukebox/world'; import { bookshelf } from '../../features/bookshelf/world'; import { cabinet } from '../../features/cabinet/world'; import { whiteboard } from '../../features/whiteboard/world'; +import { processWall } from '../../features/process-wall/world'; import { green, tee } from '../../features/golf/world'; import { stack } from '../stack'; import { tower } from '../tower'; @@ -71,6 +72,7 @@ function floorPlan() { gong, hoop, whiteboard, + processWall, clearOfStairs, ] as const; } diff --git a/src/server/http/routes/process-studio.ts b/src/server/http/routes/process-studio.ts index 89565a321..5621a43de 100644 --- a/src/server/http/routes/process-studio.ts +++ b/src/server/http/routes/process-studio.ts @@ -8,6 +8,6 @@ export const processStudioRoute: Route = { method: 'GET', auth: 'session', handle(ctx, { res }) { - return serveFile(res, path.join(ctx.publicDir, 'process-studio.html'), false); + return serveFile(res, path.join(ctx.publicDir, 'process-studio.html'), false, 'SAMEORIGIN'); }, }; diff --git a/src/server/http/static.ts b/src/server/http/static.ts index d73085bc9..6711079e2 100644 --- a/src/server/http/static.ts +++ b/src/server/http/static.ts @@ -28,13 +28,14 @@ export function findPublicDir(): string { throw new Error(`Client bundle not found (looked in ${candidates.join(', ')}). Run \`npm run build\`.`); } -export function serveFile(res: http.ServerResponse, file: string, cache: boolean) { +export function serveFile(res: http.ServerResponse, file: string, cache: boolean, framing: 'DENY' | 'SAMEORIGIN' = 'DENY') { const ext = path.extname(file); res.writeHead(200, { 'content-type': MIME[ext] ?? 'application/octet-stream', 'cache-control': cache ? 'public, max-age=31536000, immutable' : 'no-store', 'x-content-type-options': 'nosniff', - 'x-frame-options': 'DENY', + 'x-frame-options': framing, + 'content-security-policy': `frame-ancestors ${framing === 'SAMEORIGIN' ? "'self'" : "'none'"}`, 'referrer-policy': 'no-referrer', }); createReadStream(file).pipe(res); diff --git a/tests/process-wall-framing.test.ts b/tests/process-wall-framing.test.ts new file mode 100644 index 000000000..e3681ee30 --- /dev/null +++ b/tests/process-wall-framing.test.ts @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import path from 'node:path'; +import { serveFile } from '../src/server/http/static.js'; +import { processStudioRoute } from '../src/server/http/routes/process-studio.js'; +import type { Ctx } from '../src/server/office/context.js'; + +test('only the session-protected studio opts into same-origin framing', async t => { + const publicDir = path.resolve(import.meta.dirname, '../src/client'); + const server = http.createServer((req, res) => { + if (req.url === '/studio') processStudioRoute.handle({ publicDir } as Ctx, { res } as Parameters[1]); + else serveFile(res, path.join(publicDir, 'index.html'), false); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + t.after(() => { server.closeAllConnections(); server.close(); }); + const { port } = server.address() as { port: number }; + for (const [route, framing, ancestors] of [['/studio', 'SAMEORIGIN', "'self'"], ['/', 'DENY', "'none'"]]) { + const response = await fetch(`http://127.0.0.1:${port}${route}`); + assert.equal(response.status, 200); + assert.equal(response.headers.get('x-frame-options'), framing); + assert.equal(response.headers.get('content-security-policy'), `frame-ancestors ${ancestors}`); + await response.text(); + } + assert.equal(processStudioRoute.auth, 'session'); +}); diff --git a/tests/process-wall.test.ts b/tests/process-wall.test.ts new file mode 100644 index 000000000..881c127d9 --- /dev/null +++ b/tests/process-wall.test.ts @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { WorkerInfo, GhPull, QueueTask } from '../src/shared/protocol.js'; +import { projectWall, processRow, visibleRows, connectionLabel, WallHistory, type WallInput } from '../src/client/features/process-wall/model.js'; + +const worker = (changes: Partial = {}): WorkerInfo => ({ id: 'w1', kind: 'agent', deskId: 'desk-1', name: 'Developer', color: '#06d6a0', status: 'working', acked: false, createdBy: 'test', createdAt: 1, cols: 80, rows: 24, viewers: [], viewerIds: [], worktree: { path: 'work/w1', branch: 'office/w1', base: 'main' }, ...changes }); +const pull = (changes: Partial = {}): GhPull => ({ number: 12, title: 'Change', state: 'OPEN', isDraft: false, url: 'https://github.com/team/repo/pull/12', author: 'test', labels: [], reviewDecision: '', headRefName: 'office/w1', headRefOid: 'abc', baseRefName: 'main', createdAt: '', updatedAt: '', additions: 2, deletions: 1, checks: 'none', body: '', closes: [], ...changes }); +const task = (changes: Partial = {}): QueueTask => ({ id: 't1', title: 'Task', prompt: '', addedBy: 'test', addedAt: 1, status: 'running', workerId: 'w1', ...changes }); +const input = (changes: Partial = {}): WallInput => ({ floor: 'Project', workers: [worker()], tasks: [task()], pulls: { items: [pull()], fetchedAt: 123, loading: false }, connected: true, ready: true, at: 200, ...changes }); + +test('queue, worker and branch-matched PR form one traceable process row', () => { + const state = projectWall(input()); + assert.equal(state.rows.length, 1); assert.equal(state.rows[0].pr?.number, 12); + assert.equal(state.rows[0].branch, 'office/w1'); assert.equal(state.rows[0].phase, 3); + assert.match(state.rows[0].detail, /nejsou doložené/); +}); +test('finished agent, pending checks and approval never imply merge', () => { + assert.equal(processRow(undefined, worker({ status: 'done' })).phase, 4); + assert.equal(processRow(undefined, worker(), pull({ checks: 'pending' })).phase, 2); + assert.equal(processRow(undefined, worker(), pull({ checks: 'pass', reviewDecision: 'APPROVED' })).phase, 3); + const merged = processRow(undefined, worker({ status: 'needs_input' }), pull({ state: 'MERGED' })); + assert.equal(merged.phase, 5); assert.equal(merged.blocked, false); assert.equal(merged.moving, false); + assert.match(merged.detail, /nasazení nesledujeme/); +}); +test('blocking inputs, failed checks, lost worktrees and failed queue outcomes remain visible', () => { + for (const row of [processRow(undefined, worker({ status: 'needs_input' })), processRow(undefined, worker(), pull({ checks: 'fail' })), processRow(undefined, worker(), pull({ reviewDecision: 'CHANGES_REQUESTED' })), processRow(task({ outcome: 'failed', error: 'failed' })), processRow(undefined, worker({ lost: { branch: 'here' } }))]) assert.equal(row.blocked, true); + const s = projectWall(input({ workers: [worker({ status: 'needs_input' }), worker({ id: 'w2', name: 'Second', worktree: undefined })] })); + assert.equal(visibleRows(s, 'blockers').length, 1); assert.equal(visibleRows(s, 'workers').length, 2); +}); +test('disconnect and reconnect show stale state until a fresh floor snapshot', () => { + assert.match(connectionLabel(projectWall(input({ connected: false }))), /ODPOJENO/); + assert.match(connectionLabel(projectWall(input({ connected: true, ready: false }))), /SYNCHRONIZACE/); + assert.match(connectionLabel(projectWall(input())), /ŽIVĚ/); + const empty = projectWall(input({ workers: [], tasks: [], pulls: { items: [], fetchedAt: 0, loading: true } })); + assert.equal(empty.rows.length, 0); assert.equal(empty.running, 0); +}); +test('history deduplicates snapshots, caps memory and clears between floors', () => { + const history = new WallHistory(); const s = projectWall(input()); + history.observe(s); history.observe({ ...s, at: 300 }); assert.equal(history.entries.length, 1); + for (let i = 0; i < 100; i++) history.observe({ ...s, at: i, rows: s.rows.map(r => ({ ...r, detail: 'change ' + i })) }); + assert.equal(history.entries.length, 80); history.clear(); assert.equal(history.entries.length, 0); + history.observe(s); assert.equal(history.entries.length, 1); +}); From 7afe30d8abe189ad7c3754ff51f460e9842aa2b1 Mon Sep 17 00:00:00 2001 From: Pletor Date: Thu, 22 Oct 2026 01:27:20 +0200 Subject: [PATCH 5/5] feat: connect Opora memory to process wall --- README.md | 2 +- docs/process-wall.md | 13 +++- src/client/features/process-wall/index.ts | 5 +- src/client/features/process-wall/memory.ts | 72 +++++++++++++++++++++ src/client/features/process-wall/texture.ts | 4 +- src/client/features/process-wall/ui.css | 5 ++ src/client/features/process-wall/ui.ts | 17 +++-- src/server/http/routes/index.ts | 2 + src/server/http/routes/process-memory.ts | 26 ++++++++ src/server/process-memory/client.ts | 56 ++++++++++++++++ src/shared/process-memory.ts | 10 +++ tests/process-memory.test.ts | 53 +++++++++++++++ 12 files changed, 254 insertions(+), 11 deletions(-) create mode 100644 src/client/features/process-wall/memory.ts create mode 100644 src/server/http/routes/process-memory.ts create mode 100644 src/server/process-memory/client.ts create mode 100644 src/shared/process-memory.ts create mode 100644 tests/process-memory.test.ts diff --git a/README.md b/README.md index 2e574224c..70310c5ae 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/i ## What it is -- **Opora process wall.** Above the north-wall boards, aim and press E for a live view of workers, queue tasks, branches, GitHub checks and blockers. Switch the wall's view, inspect recent observations or open the detailed demo inside the same window. See [controls and data boundaries](docs/process-wall.md). +- **Opora process wall.** Above the north-wall boards, aim and press E for a live view of workers, queue tasks, branches, GitHub checks and blockers. Switch views, inspect observations, query actual OpenViking role memories through Opora (`OPORA_CONTROL_CENTER_URL`), or open the detailed demo inside the same window. See [connection setup, controls and data boundaries](docs/process-wall.md). - **Process Observatory (interactive demo).** Open `/process-studio` after signing in to explore a Czech-language simulation of an Opora plan, parallel office workers, failed checks and repair, GitHub delivery, and memory reuse. A six-phase delivery pipeline exposes 17 handoffs, from source investigation and worktrees through counterexamples, review, PR dependencies, staging and evaluation. Animated agents follow recorded events; repository-role inputs extend the local scenario. It uses sample data and does not connect accounts or change repositories. See [the scenario, controls and integration boundary](docs/process-studio.md). - **A floor per project.** Ride the elevator, pick one of your GitHub repos, and the office clones it (showing how far along it is) and opens a floor for it. Every worker, board and queue on that floor works in that checkout. - **Workers at desks.** Walk up to an empty desk, press **E**, and pick Claude Code, Codex, OpenCode, Grok, Muse, DeepSeek Harness, Pi or Cursor, each with its model and reasoning effort. The agent's live terminal shows on its laptop, and anyone can open it and type. diff --git a/docs/process-wall.md b/docs/process-wall.md index d14fb78eb..54ed9cb79 100644 --- a/docs/process-wall.md +++ b/docs/process-wall.md @@ -8,6 +8,7 @@ The 3D office has a wide, illuminated **OPORA · Řízení práce** display abov - **Pracovníci** filters to rows with an observed worker. **Blokace** shows requests for input, lost worktrees, failed tasks, failed GitHub checks and requested review changes. Selecting these views also changes the physical wall, locally for that viewer. - The window shows each row's branch, observed phase, head SHA when supplied, checks, review and a GitHub link to inspect the PR evidence. Its phase marker is a projection, not a completed-step checklist. - **Historie pozorování** keeps the latest 80 changed row observations in browser memory. It clears on a fresh welcome/floor snapshot and page reload. It is not a durable or complete server audit. +- **OpenViking · živě** reads the real memory status and role registry through Opora. Choose a role and a query to recall existing memories, with source URI, origin and retrieval score. An optional project field takes an exact Opora project ID; an office branch/repository name is never silently treated as that identity. - **Podrobný plán · DEMO** embeds the existing [Process Observatory](process-studio.md), including the six-phase pipeline, repository-role form, memory proposal/reuse and replay. It is clearly separated from live office data. - The top-right **✕** and **Esc** close the window using the office's normal modal/focus lifecycle. Escape also works from the embedded demo; if that demo's own information dialog is open, the first Escape closes that inner dialog. @@ -19,7 +20,17 @@ The wall reuses the existing authenticated office connection and store, without A worker's `done` means its turn ended, not that tests passed. A test tool call means a check was invoked, not that it succeeded. Passing CI and an approved review still do not mean merged. Only the PR snapshot's `MERGED` state produces the merged phase, and it does not imply deployment. -Opora's external plan, OpenViking memory, releases and deployment are not connected by this change. Their proposed contracts remain in the clearly labeled demo. The live view does not claim complete coverage of background mechanisms or provide elapsed-time comparisons across runs. Durable correlated events and adapters are required for those capabilities. +Opora's external plan, releases and deployment do not yet have live event adapters. Their proposed contracts remain in the clearly labeled demo. OpenViking now has real status and recall through the existing Opora API, but no memory-write or learning-event stream. The live view does not claim complete coverage of background mechanisms or provide elapsed-time comparisons across runs. Durable correlated events are required for those capabilities. + +## Connecting the existing Opora/OpenViking service + +Set `OPORA_CONTROL_CENTER_URL=http://127.0.0.1:8740` in the **agent-office server** environment, then start/restart that server. Use your actual Opora port if different. Without this explicit setting the adapter is disabled. The URL must be an HTTP loopback origin without credentials, path, query or fragment. Opora keeps its existing OpenViking address, enablement and role keys; agent-office never reads or returns those keys. + +The authenticated `/api/process-memory` and `/api/process-memory/recall` endpoints permit only administrator accounts or the office's shared-password session. This matters because Opora's role memories span projects. Recall also requires the office's Origin header. The server calls only Opora's fixed status, roles and recall endpoints, without redirects or proxies, with response-size bounds and deadlines. Returned fields are explicitly selected. No provisioning, settings, writes or session commits are forwarded. + +Status refreshes every 15 seconds while the memory tab is open; leaving it cancels browser requests and polling. Recall runs only when submitted. Results are rendered as text, not instructions or HTML, and remain labeled with the submitted role/query/project. `configured` means Opora reports its memory service configured, not that every lookup succeeds. A recall failure is shown separately. Retrieval scores are not measures of agent quality. + +OpenViking must be running independently (the local setup uses Docker and Ollama). If the service is down, Opora's `unavailable` state is shown. Start the existing OpenViking deployment and verify Opora's `/api/memory/status`; do not create replacement role keys merely to make the visualization green. ## Implementation and validation diff --git a/src/client/features/process-wall/index.ts b/src/client/features/process-wall/index.ts index a8bc75fdd..af0144bd1 100644 --- a/src/client/features/process-wall/index.ts +++ b/src/client/features/process-wall/index.ts @@ -22,7 +22,8 @@ export function installProcessWall(ctx: Ctx) { pulls: store.pulls, connected: ctx.net.up, ready, at, }); let current = snapshot(); - const panel = new ProcessWallPanel(history, next => { view = next; page = 0; lastDraw = 0; }, () => { page++; lastDraw = 0; }); + let memoryLabel = 'ověřit v detailu'; + const panel = new ProcessWallPanel(history, next => { view = next; page = 0; lastDraw = 0; }, () => { page++; lastDraw = 0; }, label => { memoryLabel = label; lastDraw = 0; }); const refresh = () => { current = snapshot(); panel.update(current); lastDraw = 0; }; ctx.interactions.define('processwall', { reach: 18, @@ -40,7 +41,7 @@ export function installProcessWall(ctx: Ctx) { if (frame.now - lastPanel > 1000) { current = snapshot(); panel.update(current); lastPanel = frame.now; } if (!ctx.inOffice() || ctx.upTop()) return; if (frame.now - lastDraw < (current.running && current.connected && ready && !ctx.reduceMotion.matches ? 100 : 1000)) return; - texture.draw(current, view, page, frame.t, ctx.reduceMotion.matches); lastDraw = frame.now; + texture.draw(current, view, page, frame.t, ctx.reduceMotion.matches, memoryLabel); lastDraw = frame.now; }); texture.draw(current, view, page, 0, true); } diff --git a/src/client/features/process-wall/memory.ts b/src/client/features/process-wall/memory.ts new file mode 100644 index 000000000..36c715c79 --- /dev/null +++ b/src/client/features/process-wall/memory.ts @@ -0,0 +1,72 @@ +import { h } from '../../ui/dom'; +import type { MemoryStatus, MemoryRecall } from '../../../shared/process-memory'; + +const LABELS: Record = { configured: 'Připojeno', disabled: 'V Opoře vypnuto', unavailable: 'Nedostupné', needs_provisioning: 'Chybí klíče rolí', not_configured: 'Nenastaveno' }; +/** Only mounted while the user inspects memory; poll status without disturbing form or results. */ +export class MemoryPane { + readonly root = h('section.process-memory'); + private status = h('div.process-wall-notice', {}, 'Ověřuji Oporu a OpenViking…'); + private role = h('select', { 'aria-label': 'Role paměti' }); + private query = h('input', { 'aria-label': 'Dotaz do paměti', placeholder: 'Např. obnova spojení, chyby a ověřené opravy', maxlength: 2000, required: true }); + private project = h('input', { 'aria-label': 'ID projektu Opory', placeholder: 'ID projektu Opory (volitelné)', maxlength: 128 }); + private results = h('div.process-memory-results', { 'aria-live': 'polite' }); + private submit = h('button', { type: 'submit' }, 'Vyhledat skutečnou paměť →'); + private request = new AbortController(); + private timer?: ReturnType; + private stopped = false; + private roleSignature = ''; + private searching = false; + private configured = false; + constructor(private report: (label: string) => void) { + const form = h('form.process-memory-form', { onsubmit: e => { e.preventDefault(); void this.recall(); } }, + h('label', {}, 'Role agenta', this.role), h('label', {}, 'Hledaný kontext', this.query), + h('label', {}, 'Projekt · přesné ID, nikoli název repozitáře', this.project), this.submit); + this.root.append(h('h3', {}, 'OpenViking · živá paměť přes Oporu'), this.status, + h('p', {}, 'agent-office → Opora Control Center → OpenViking → zdrojové vzpomínky'), + h('p.process-wall-notice', {}, 'Čtení paměti rolí. Klíče zůstávají v Opoře. Vyhledání není zápis, trénování modelu ani důkaz zlepšení agenta.'), form, this.results, + h('a', { href: 'https://github.com/volcengine/OpenViking', target: '_blank', rel: 'noopener noreferrer' }, 'Repozitář OpenViking ↗')); + this.submit.disabled = true; + void this.refresh(); + } + destroy() { this.stopped = true; clearTimeout(this.timer); this.request.abort(); this.report('stav ověřen v detailu'); } + private async refresh() { + try { + const response = await fetch('/api/process-memory', { signal: this.request.signal, cache: 'no-store' }); + if (!response.ok) throw new Error(response.status === 403 ? 'Paměť Opory může číst správce kanceláře.' : 'Spojení s kanceláří není dostupné.'); + const s: MemoryStatus = await response.json(); + if (this.stopped) return; + this.configured = s.status === 'configured'; + this.status.textContent = `${LABELS[s.status]} · ${s.reason} · ověřeno ${new Date(s.checkedAt).toLocaleTimeString('cs-CZ')}${s.missingRoles.length ? ' · chybí: ' + s.missingRoles.join(', ') : ''}`; + this.report(LABELS[s.status]); + const signature = JSON.stringify(s.roles); + if (signature !== this.roleSignature) { + const selected = this.role.value; + this.role.replaceChildren(...s.roles.map(role => h('option', { value: role.id, title: role.description }, role.label))); + if (s.roles.some(role => role.id === selected)) this.role.value = selected; + this.roleSignature = signature; + } + } catch (error) { + if (this.stopped) return; + this.configured = false; this.status.textContent = error instanceof Error ? error.message : 'Paměť není dostupná.'; this.report('nedostupné'); + } finally { + if (!this.stopped) { this.submit.disabled = !this.configured || this.searching; this.timer = setTimeout(() => void this.refresh(), 15000); } + } + } + private async recall() { + if (!this.configured || this.searching) return; + this.searching = true; this.submit.disabled = true; + const query = this.query.value, role = this.role.value, project = this.project.value.trim(); + this.results.replaceChildren(h('p', {}, 'Vyhledávám v paměti zvolené role…')); + try { + const response = await fetch('/api/process-memory/recall', { method: 'POST', headers: { 'content-type': 'application/json' }, signal: this.request.signal, body: JSON.stringify({ role, query, project }) }); + if (!response.ok) throw new Error('Vyhledání se nepodařilo. Zkontrolujte stav paměti, roli a ID projektu.'); + const result: MemoryRecall = await response.json(); + if (this.stopped) return; + this.results.replaceChildren(h('p', {}, `Dotaz: ${query} · role: ${role} · ${project || 'paměť role napříč projekty'}`)); + if (result.status !== 'configured') { this.results.append(h('p', {}, result.reason || 'Paměť není dostupná.')); return; } + this.results.append(h('p', {}, `${result.items.length} skutečných výsledků${result.truncated ? ' · výpis zkrácen' : ''}. Před použitím ověřte jejich relevanci.`)); + for (const item of result.items) this.results.append(h('article.process-wall-card', {}, h('code', {}, item.uri), h('p', {}, `Původ: ${item.origin} · skóre vyhledání: ${item.score.toFixed(3)}`), h('p.process-memory-text', {}, item.text))); + } catch (error) { if (!this.stopped) this.results.replaceChildren(h('p', {}, error instanceof Error ? error.message : 'Paměť není dostupná.')); } + finally { this.searching = false; this.submit.disabled = !this.configured; } + } +} diff --git a/src/client/features/process-wall/texture.ts b/src/client/features/process-wall/texture.ts index dc7083576..bd28c5475 100644 --- a/src/client/features/process-wall/texture.ts +++ b/src/client/features/process-wall/texture.ts @@ -9,7 +9,7 @@ export class ProcessTexture { this.texture = new THREE.CanvasTexture(this.canvas); this.texture.colorSpace = THREE.SRGBColorSpace; this.texture.anisotropy = 4; } - draw(s: WallSnapshot, view: WallView, page: number, t: number, reduced: boolean) { + draw(s: WallSnapshot, view: WallView, page: number, t: number, reduced: boolean, memoryLabel = 'ověřit v detailu') { const g = this.canvas.getContext('2d')!; g.fillStyle = '#091523'; g.fillRect(0, 0, 1920, 320); const text = (v: string, x: number, y: number, size = 24, color = '#d9e9f8', width?: number) => { @@ -43,7 +43,7 @@ export class ProcessTexture { } }); text(`E / klik → otevřít · Strana ${(page % count) + 1}/${count} · Přepnutí pohledu v detailu`, 28, 300, 20, '#74d9d5'); - text('Paměť / nasazení: bez adaptéru', 1440, 300, 20, '#b5a5d9'); + text('OpenViking: ' + memoryLabel, 1360, 300, 20, '#b5a5d9', 530); this.texture.needsUpdate = true; } } diff --git a/src/client/features/process-wall/ui.css b/src/client/features/process-wall/ui.css index 906284a2d..f5ba26352 100644 --- a/src/client/features/process-wall/ui.css +++ b/src/client/features/process-wall/ui.css @@ -21,4 +21,9 @@ .process-wall-card a { color: #83f2dd; font-size: 13px; }.process-wall-empty { padding: 30px; color: #b5c8d8; } .process-wall-event { display: flex; gap: 20px; border-bottom: 1px solid #2c435a; padding: 12px 0; font-size: 13px; }.process-wall-event time { color: #73dfcf; white-space: nowrap; } .process-wall-demo { width: 100%; height: 65vh; border: 1px solid #36546a; border-radius: 9px; background: #0b1624; } +.process-memory-form { display: grid; grid-template-columns: 1fr 2fr; gap: 16px; margin: 20px 0; } +.process-memory-form label { display: grid; gap: 8px; font-size: 12px; color: #acbfd1; } +.process-memory-form input, .process-memory-form select { width: 100%; min-width: 0; padding: 12px; border-radius: 6px; border: 1px solid #36546a; color: #d9e9f8; background: #14283a; font: inherit; } +.process-memory-form button { align-self: end; }.process-memory a { color: #83f2dd; }.process-memory-text { white-space: pre-wrap; }.process-memory-results { overflow-wrap: anywhere; } +@media (max-width: 600px) { .process-memory-form { grid-template-columns: 1fr; } } @media (max-width: 600px) { .process-wall-modal { padding: 12px; }.process-wall-modal h2 { font-size: 21px; }.process-wall-metrics { grid-template-columns: 1fr 1fr; }.process-wall-card-title { display: block; } } diff --git a/src/client/features/process-wall/ui.ts b/src/client/features/process-wall/ui.ts index b088f4c97..b54100fd4 100644 --- a/src/client/features/process-wall/ui.ts +++ b/src/client/features/process-wall/ui.ts @@ -1,8 +1,9 @@ import { h, openModal, type Modal } from '../../ui/dom'; import { connectionLabel, PHASES, visibleRows, type WallSnapshot, type WallView, type WallHistory } from './model'; import './ui.css'; +import { MemoryPane } from './memory'; -type Tab = WallView | 'history' | 'demo'; +type Tab = WallView | 'history' | 'demo' | 'memory'; export class ProcessWallPanel { private modal?: Modal; private tab: Tab = 'overview'; @@ -12,14 +13,15 @@ export class ProcessWallPanel { private frame?: HTMLIFrameElement; private snapshot?: WallSnapshot; private rendered = ''; - constructor(private history: WallHistory, private changeView: (view: WallView) => void, private nextPage: () => void) {} + private memory?: MemoryPane; + constructor(private history: WallHistory, private changeView: (view: WallView) => void, private nextPage: () => void, private reportMemory: (label: string) => void) {} open(s: WallSnapshot) { if (this.modal) return; this.tab = 'overview'; this.changeView('overview'); this.rendered = ''; const tabs = h('nav.process-wall-tabs', { 'aria-label': 'Pohled na proces' }); this.buttons.clear(); - for (const [id, label] of [['overview', 'Celý proces'], ['workers', 'Pracovníci'], ['blockers', 'Blokace'], ['history', 'Historie pozorování'], ['demo', 'Podrobný plán · DEMO']] as const) { + for (const [id, label] of [['overview', 'Celý proces'], ['workers', 'Pracovníci'], ['blockers', 'Blokace'], ['history', 'Historie pozorování'], ['memory', 'OpenViking · živě'], ['demo', 'Podrobný plán · DEMO']] as const) { const button = h('button', { type: 'button', onclick: () => { this.tab = id; if (id === 'overview' || id === 'workers' || id === 'blockers') this.changeView(id); @@ -32,7 +34,7 @@ export class ProcessWallPanel { this.status, tabs, h('div.process-wall-controls', {}, h('span', {}, 'Zvolený živý pohled se promítá také na stěnu.'), h('button', { type: 'button', onclick: () => this.nextPage() }, 'Další strana na stěně →')), this.body); - this.modal = openModal(panel, { doing: 'sleduje proces práce', onClose: () => { this.frame = undefined; this.body.replaceChildren(); this.modal = undefined; } }); + this.modal = openModal(panel, { doing: 'sleduje proces práce', onClose: () => { this.memory?.destroy(); this.memory = undefined; this.frame = undefined; this.body.replaceChildren(); this.modal = undefined; } }); this.update(s); this.buttons.get('overview')!.focus(); } update(s: WallSnapshot) { this.snapshot = s; if (this.modal) this.render(); } @@ -46,6 +48,11 @@ export class ProcessWallPanel { const signature = JSON.stringify([this.tab, s, githubStale, this.tab === 'history' ? this.history.entries : null]); if (signature === this.rendered) return; this.rendered = signature; + if (this.tab === 'memory') { + if (!this.memory) { this.frame = undefined; this.memory = new MemoryPane(this.reportMemory); this.body.replaceChildren(this.memory.root); } + return; + } + this.memory?.destroy(); this.memory = undefined; if (this.tab === 'demo') { if (this.frame) return; this.body.replaceChildren(h('p.process-wall-notice', {}, 'DEMO · Modelový plán, paměť a pipeline. Tato animace nepopisuje právě běžící pracovníky kanceláře.')); @@ -90,6 +97,6 @@ export class ProcessWallPanel { this.body.append(article); } this.body.append(h('div.process-wall-boundary', {}, h('strong', {}, 'Další části procesu'), - h('p', {}, 'Opora plán · OpenViking paměť · nasazení: živé adaptéry zatím nejsou připojené. Jejich navržené kroky, podmínky a rozšíření repozitářů najdete v Podrobném plánu · DEMO.'))); + h('p', {}, 'OpenViking: živý stav a čtení paměti najdete na kartě OpenViking · živě. Externí plán Opory a nasazení zde zatím nemají živé události; jejich návrh je v Podrobném plánu · DEMO.'))); } } diff --git a/src/server/http/routes/index.ts b/src/server/http/routes/index.ts index 179472262..741e444a4 100644 --- a/src/server/http/routes/index.ts +++ b/src/server/http/routes/index.ts @@ -8,6 +8,7 @@ import { fileRoutes } from './files.js'; import { githubRoutes } from './github.js'; import { pageRoutes } from './pages.js'; import { processStudioRoute } from './process-studio.js'; +import { processMemoryRoutes } from './process-memory.js'; import { searchRoutes } from './search.js'; import { serviceRoutes } from './services.js'; @@ -40,5 +41,6 @@ export const routes: readonly Route[] = [ pageRoutes.office, pageRoutes.lite, processStudioRoute, + ...processMemoryRoutes, pageRoutes.bundle, ]; diff --git a/src/server/http/routes/process-memory.ts b/src/server/http/routes/process-memory.ts new file mode 100644 index 000000000..13516ce06 --- /dev/null +++ b/src/server/http/routes/process-memory.ts @@ -0,0 +1,26 @@ +import type { Route } from '../router.js'; +import { readBody, sameOrigin, send } from '../util.js'; +import { memoryRecall, memoryStatus, oporaBase, readOpora, recallPayload } from '../../process-memory/client.js'; + +/** Opora memory is machine-wide, so only office admins/shared-password sessions can inspect it. */ +export const processMemoryRoutes = [ + { method: 'GET', path: '/api/process-memory', auth: 'session', async handle(_ctx, { res, session }) { + if (session.account && session.account.role !== 'admin') return send(res, 403, { error: 'Paměť Opory může číst správce kanceláře.' }); + const raw = process.env.OPORA_CONTROL_CENTER_URL; + if (!raw) return send(res, 200, { status: 'not_configured', reason: 'Nastavte OPORA_CONTROL_CENTER_URL na serveru agent-office.', roles: [], missingRoles: [], checkedAt: Date.now() }); + try { + const base = oporaBase(raw); + const [status, roles] = await Promise.all([readOpora(base, '/api/memory/status'), readOpora(base, '/api/memory/roles')]); + return send(res, 200, memoryStatus(status, roles)); + } catch { return send(res, 200, { status: 'unavailable', reason: 'Paměťové API Opory není dostupné nebo nevrátilo očekávaná data.', roles: [], missingRoles: [], checkedAt: Date.now() }); } + } }, + { method: 'POST', path: '/api/process-memory/recall', auth: 'session', async handle(ctx, { req, res, session }) { + if ((session.account && session.account.role !== 'admin') || !sameOrigin(req, ctx.cfg)) return send(res, 403, { error: 'Vyhledávání paměti vyžaduje správce a původ kanceláře.' }); + let payload; + try { payload = recallPayload(JSON.parse(await readBody(req, 16000))); } catch { return send(res, 400, { error: 'Neplatná role, dotaz nebo ID projektu Opory.' }); } + try { + const base = oporaBase(process.env.OPORA_CONTROL_CENTER_URL || ''); + return send(res, 200, memoryRecall(await readOpora(base, '/api/memory/recall', payload), payload.role)); + } catch { return send(res, 503, { error: 'Vybavení paměti není dostupné. Zkontrolujte Oporu a OpenViking.' }); } + } }, +] satisfies Route[]; diff --git a/src/server/process-memory/client.ts b/src/server/process-memory/client.ts new file mode 100644 index 000000000..4cd31bef6 --- /dev/null +++ b/src/server/process-memory/client.ts @@ -0,0 +1,56 @@ +import http from 'node:http'; +import type { MemoryStatus, MemoryRecall } from '../../shared/process-memory.js'; + +const LIMIT = 512 * 1024; +/** Fixed server configuration only. Browser inputs can never choose a host, path or key. */ +export function oporaBase(raw: string): URL { + const url = new URL(raw); + if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) + || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('Opora musí mít HTTP adresu na loopbacku bez cesty a přihlášení.'); + if (url.hostname === 'localhost') url.hostname = '127.0.0.1'; + return url; +} +export function readOpora(base: URL, path: '/api/memory/status' | '/api/memory/roles' | '/api/memory/recall', payload?: unknown): Promise { + return new Promise((resolve, reject) => { + const data = payload ? JSON.stringify(payload) : undefined; + const req = http.request(new URL(path, base), { method: data ? 'POST' : 'GET', headers: { accept: 'application/json', ...(data ? { origin: base.origin, 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {}) } }, res => { + if (res.statusCode !== 200) { res.resume(); reject(new Error(`Opora odpověděla HTTP ${res.statusCode}.`)); return; } + let size = 0; const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => { size += chunk.length; if (size > LIMIT) req.destroy(new Error('Odpověď Opory je příliš velká.')); else chunks.push(chunk); }); + res.on('error', reject); + res.on('end', () => { if (size > LIMIT) return; try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch { reject(new Error('Opora nevrátila platná data.')); } }); + }); + const timer = setTimeout(() => req.destroy(new Error('Opora neodpověděla v časovém limitu.')), data ? 35000 : 7000); + req.on('close', () => clearTimeout(timer)); req.on('error', () => reject(new Error('Paměťové API Opory není dostupné.'))); + req.end(data); + }); +} +const object = (v: unknown): Record => { + if (!v || typeof v !== 'object' || Array.isArray(v)) throw new Error('Neplatný kontrakt paměti Opory.'); + return v as Record; +}; +const text = (v: unknown, max = 1000) => typeof v === 'string' ? v.slice(0, max) : ''; +export function memoryStatus(raw: unknown, registry: unknown): MemoryStatus { + const s = object(raw), r = object(registry); + if (!['configured', 'disabled', 'unavailable', 'needs_provisioning'].includes(String(s.status)) || !Array.isArray(r.roles)) throw new Error('Neplatný stav paměti Opory.'); + return { status: s.status as MemoryStatus['status'], reason: text(s.reason), checkedAt: Date.now(), + roles: r.roles.slice(0, 64).map(value => { const role = object(value); return { id: text(role.id, 128), label: text(role.label, 120), description: text(role.description) }; }), + missingRoles: Array.isArray(s.missingRoles) ? s.missingRoles.slice(0, 64).map(v => text(v, 128)) : [] }; +} +export function recallPayload(raw: unknown) { + const p = object(raw); + if (Object.keys(p).sort().join(',') !== 'project,query,role' || typeof p.role !== 'string' || !/^role-[a-z0-9-]{1,80}$/.test(p.role) + || typeof p.query !== 'string' || !p.query.trim() || p.query.length > 2000 + || typeof p.project !== 'string' || (p.project && !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(p.project))) throw new Error('Vyberte roli, dotaz do 2000 znaků a případně přesné ID projektu Opory.'); + return { role: p.role, project: p.project || null, query: p.query.trim(), workspace: null, nodeId: null, maxTokens: 2000 }; +} +export function memoryRecall(raw: unknown, role: string): MemoryRecall { + const r = object(raw); + if (!['configured', 'disabled', 'unavailable', 'skipped'].includes(String(r.status)) || !Array.isArray(r.items)) throw new Error('Neplatná odpověď vybavení paměti.'); + let remaining = 16000; + const items = r.items.slice(0, 20).map(value => { + const item = object(value), body = text(item.text, Math.min(remaining, 8000)); remaining -= body.length; + return { uri: text(item.uri, 1000), text: body, origin: text(item.origin, 100), score: typeof item.score === 'number' && Number.isFinite(item.score) ? item.score : 0 }; + }); + return { status: String(r.status), reason: text(r.reason), role, truncated: r.truncated === true || remaining === 0 || r.items.length > 20, items }; +} diff --git a/src/shared/process-memory.ts b/src/shared/process-memory.ts new file mode 100644 index 000000000..0d6272318 --- /dev/null +++ b/src/shared/process-memory.ts @@ -0,0 +1,10 @@ +/** Read-only HTTP adapter to Opora's existing OpenViking integration. */ +export interface MemoryRole { id: string; label: string; description: string } +export interface MemoryStatus { + status: 'configured' | 'disabled' | 'unavailable' | 'needs_provisioning' | 'not_configured'; + reason: string; roles: MemoryRole[]; missingRoles: string[]; checkedAt: number; +} +export interface MemoryRecall { + status: string; reason: string; role: string; truncated: boolean; + items: { uri: string; text: string; origin: string; score: number }[]; +} diff --git a/tests/process-memory.test.ts b/tests/process-memory.test.ts new file mode 100644 index 000000000..7815c1414 --- /dev/null +++ b/tests/process-memory.test.ts @@ -0,0 +1,53 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { memoryRecall, memoryStatus, oporaBase, readOpora, recallPayload } from '../src/server/process-memory/client.js'; +import { processMemoryRoutes } from '../src/server/http/routes/process-memory.js'; +import type { Ctx } from '../src/server/office/context.js'; + +test('Opora target is fixed to a credential-free loopback origin', () => { + assert.equal(oporaBase('http://localhost:8740').hostname, '127.0.0.1'); + for (const value of ['https://127.0.0.1', 'http://example.com', 'http://127.0.0.1@evil.test', 'http://user:key@localhost', 'http://localhost/api', 'http://localhost?secret=1', 'http://localhost/#x']) assert.throws(() => oporaBase(value)); +}); +test('recall accepts only a role, query and optional explicit Opora project identity', () => { + const input = { role: 'role-backend', query: ' reconnect ', project: '' }; + assert.deepEqual(recallPayload(input), { role: 'role-backend', query: 'reconnect', project: null, workspace: null, nodeId: null, maxTokens: 2000 }); + for (const invalid of [{ ...input, query: '' }, { ...input, query: 'x'.repeat(2001) }, { ...input, role: '../admin' }, { ...input, project: 'repo/path' }, { ...input, url: 'http://evil.test' }]) assert.throws(() => recallPayload(invalid)); +}); +test('adapter allowlists response fields and bounds content without inventing success', () => { + const status = memoryStatus({ status: 'needs_provisioning', reason: 'missing', api_key: 'secret', missingRoles: ['role-tests'] }, { roles: [{ id: 'role-tests', label: 'Tests', description: 'checks', key: 'secret' }] }); + assert.equal(status.status, 'needs_provisioning'); assert(!JSON.stringify(status).includes('secret')); + assert.throws(() => memoryStatus({ status: 'fine' }, { roles: [] })); + const result = memoryRecall({ status: 'configured', items: Array.from({ length: 22 }, () => ({ uri: 'viking://x', text: 'x'.repeat(9000), score: 1, origin: 'self', key: 'secret' })) }, 'role-tests'); + assert(result.truncated); assert.equal(result.items.length, 20); assert.equal(result.items.reduce((n, i) => n + i.text.length, 0), 16000); assert(!JSON.stringify(result).includes('secret')); +}); +test('HTTP adapter rejects redirects and large responses and sends the required Opora Origin', async t => { + const server = http.createServer((req, res) => { + if (req.url === '/api/memory/status') { res.writeHead(302, { location: 'http://example.com' }); res.end(); } + else if (req.url === '/api/memory/roles') res.end('x'.repeat(512 * 1024 + 1)); + else { assert.equal(req.headers.origin, `http://127.0.0.1:${(server.address() as { port: number }).port}`); res.end(JSON.stringify({ status: 'configured', items: [] })); } + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + t.after(() => { server.closeAllConnections(); server.close(); }); + const base = oporaBase(`http://127.0.0.1:${(server.address() as { port: number }).port}`); + await assert.rejects(readOpora(base, '/api/memory/status')); + await assert.rejects(readOpora(base, '/api/memory/roles')); + assert.deepEqual(await readOpora(base, '/api/memory/recall', { query: 'test' }), { status: 'configured', items: [] }); +}); +test('memory routes require admin sessions and same-origin recall', async t => { + const old = process.env.OPORA_CONTROL_CENTER_URL; delete process.env.OPORA_CONTROL_CENTER_URL; + t.after(() => { if (old === undefined) delete process.env.OPORA_CONTROL_CENTER_URL; else process.env.OPORA_CONTROL_CENTER_URL = old; }); + const server = http.createServer((req, res) => { + const route = req.method === 'POST' ? processMemoryRoutes[1] : processMemoryRoutes[0]; + const ctx = { cfg: { trustProxy: false } } as Ctx; + const session = req.headers['x-test-member'] ? { account: { role: 'member' } } : {}; + void route.handle(ctx, { req, res, session } as Parameters[1]); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + t.after(() => { server.closeAllConnections(); server.close(); }); + const url = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + assert.equal((await fetch(url, { headers: { 'x-test-member': '1' } })).status, 403); + assert.equal((await fetch(url, { method: 'POST', headers: { origin: 'http://evil.test' } })).status, 403); + assert.equal((await (await fetch(url)).json()).status, 'not_configured'); + assert(processMemoryRoutes.every(route => route.auth === 'session')); +});