SLOPSHOPPER

olo-fleet

A live view of every OloEngine worktree, its PR's CI and review threads, and the cross-worktree build lock (who builds, who waits); notifies when a PR's CI…

newpanerowsguardcommandtoast
★ 17v0.1.0NOASSERTIONupdated 2026-10-10drsnuggles8/OloEngineBase/.claude/mods/olo-fleet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · olo-fleet
│ ┃ OloEngine fleet ✕ › fix the failing auth test and add an audit log call │ ┃ Worktrees │ ┃ ⏺ Read(src/auth.ts) │ ┃ Build lock ⎿ Read 6 lines │ ┃ build-lock status was not JSON ⏺ Update(src/auth.ts) │ ┃ Updated 0 min ago [ Refresh ] ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /fleet │ Worktrees │ │ Build lock │ build-lock status was not JSON │ Updated 0 min ago [ Refresh ] │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ olo-fleet: fleet: build lock 0/0 held

Draws

Pane · OloEngine fleet
Worktrees Build lock build-lock status was not JSON Updated 0 min ago [ Refresh ]
Command output
Worktrees Build lock build-lock status was not JSON Updated 0 min ago [ Refresh ]
README

OloEngine

Windows Sanitizers GPU Conformance (AMD, self-hosted) Cross-Vendor Conformance Fuzz Smoke SonarCloud Scan Pre-commit checks

OloEngine is a cross-platform (Windows / Linux) C++23 interactive-application and rendering engine. It started as a follow-along of Hazel but has since grown well past that scope into a full ECS-driven engine with a physically-based OpenGL 4.6 renderer, 2D/3D physics, dual C#/Lua scripting, networking, navigation, procedural terrain and environmental systems, a gameplay/AI stack, and an ImGui editor.

Screenshots

Metallic and dielectric PBR spheres (a roughness sweep) reflecting a procedural star-nest sky:

PBR materials reflecting a procedural sky

PBR modelAnimated models3D physicsFFT ocean
PBR modelAnimated models3D physicsFFT ocean

Table of contents

Repository layout

Target / directoryWhat it is
OloEngine/The engine static library (OloEngine). All subsystems live under src/OloEngine/<Subsystem>/; platform glue under src/Platform/.
OloEditor/ImGui-based editor (OloEditor). Panels under src/, runtime assets under assets/, a sample game under SandboxProject/.
OloRuntime/Standalone game runtime (OloRuntime) that loads what the editor builds.
OloServer/Headless dedicated server (OloServer) — the one target that also runs on WSL2.
OloEngine-ScriptCore/C# scripting API library, built with dotnet build under any generator; C# scripting runs on Windows only.
OloEngine-LuaScriptCore/Lua scripting core (all platforms).
OloEngine/tests/GoogleTest suite (OloEngine-Tests).
tools/Build-time tooling, notably OloHeaderTool (code generation — see Tooling).
docs/Design docs, ADRs, build/testing guides, and agent rules — see Documentation.

Getting started

Supported platforms

  • Windows — Visual Studio 2026 (the default msvc preset) or Visual Studio 2022.
  • Linux — GCC 14+.
  • WSL2 — can compile every target and run OloServer, but not OloEditor: WSL2's software renderer only exposes OpenGL 4.5, and the editor requires a native OpenGL 4.6 GPU.

Requirements

  • CMake 4.2+ — required by CMakePresets.json (the msvc preset's Visual Studio 18 2026 generator only exists in 4.2+). The root CMakeLists.txt itself only requires 3.27, so a plain cmake -B build -G "Visual Studio 17 2022" still works without presets.
  • Python 3.10+ with the jinja2 package (needed to build glad2).
  • Vulkan SDK — for SPIR-V shader compilation.
  • Steamworks SDK — optional, and you must download it yourself: its licence forbids redistribution, so it is neither in this repo nor available through vcpkg. Needed only to work on Steam features (achievements, Steam Cloud, rich presence/overlay). Steam support is auto-detected: OLO_WITH_STEAM turns on when STEAMWORKS_SDK_ROOT points at a real SDK (a public/steam directory) and stays off otherwise — so without the SDK everything builds, runs and tests exactly as before, and with it installed you get the Steam path without passing a flag. The SDK itself is free — an ordinary Steam account and the Steamworks SDK Access Agreement, not the $100 Steam Direct publishing fee. Setup: docs/ops/build.md.

Clone

git clone https://github.com/drsnuggles8/OloEngineBase

Most third-party dependencies are resolved from the vcpkg.json manifest and built once into vcpkg's machine-global binary cache, so a second worktree restores them in seconds instead of recompiling (issue #773). VCPKG_ROOT must point at a bootstrapped vcpkg clone — see docs/ops/build.md; a configure without it fails with a pointer there. A handful of dependencies are still fetched at configure time via FetchContent/CPM into OloEngine/vendor/ (never edit that directory — a CMake reconfigure wipes it). CMake also creates the build/ directory with the generated solution files.

Building & running

CMake presets (CMakePresets.json):

PresetGenerator / build dirPurpose
msvcVisual Studio 18 2026 → build/Primary, default build dir
clangclNinja Multi-Config → build-clang/clang-cl warnings with the MSVC ABI
clangcl-asanNinja Multi-Config → build-clang/Adds AddressSanitizer
# Generate the Visual Studio solution
scripts\Win-GenerateProjectVS2026.bat        # or Win-GenerateProjectVS2022.bat

# Build a target
cmake --build build --target OloEditor       --config Debug --parallel
cmake --build build --target OloEngine-Tests --config Debug --parallel
cmake --build build --target OloRuntime      --config Debug --parallel
cmake --build build --target OloServer       --config Debug --parallel

# ClangCL (configure once, then build)
cmake --preset clangcl
cmake --build build-clang --target OloEngine-Tests --config Debug --parallel

Working directory matters. OloEditor, OloRuntime, and OloServer resolve assets, shaders, and Mono assemblies relative to OloEditor/. Always run them with cwd = OloEditor/. The VS Code tasks already do this.

VS Code users — predefined tasks in .vscode/tasks.json wrap all of the above: build-oloeditor-debug, run-oloeditor-debug, build-tests-debug, run-tests-debug, configure-clangcl, etc. Run them from the Command Palette (Ctrl+Shift+P → Tasks: Run Task).

CLion users — open the repo folder, let CLion configure the CMake project, then set the working directory of the OloEditor run configuration to the OloEditor/ folder.

The full per-OS build matrix (including Linux and WSL specifics) lives in docs/ops/build.md.

Features

Core engine

  • Entity-Component-System built on EnTT under a UUID-keyed Entity wrapper. The hottest update loops use EnTT owning groups for packed iteration.
  • Deterministic gameplay scheduler — per-tick systems declare their read/write data flow and the execution order is derived by topological sort, with optional worker-thread parallelism (audited per system) and a physics-shadow phase that steps the physics world alongside game-thread systems.
  • Multi-threaded asset system — async loading with hot-reload via filewatch; typed retrieval through AssetManager.
  • Dual scripting — C# via Mono (Windows) and Lua via Sol2 (all platforms), with generated bindings.
  • YAML serialization — scenes, entities, and prefabs. Trivial component (de)serialization, the ECS component tuple, save-game capture/restore lists, and add/remove handlers are all code-generated from the component definitions (see Tooling), so most new components round-trip with zero hand-written glue.
  • Save games — component-level snapshot/restore with a versioned binary archive format.
  • Custom memory management, threading, and task systems — custom allocators, RAII resources, a job/task scheduler, and async primitives.

Rendering

  • Modern OpenGL 4.6 with Direct State Access and SPIR-V shader compilation.
  • Stateless, layered, multi-threaded command queue (Molecular Matters' design) driven by a render-graph.
  • Physically-based rendering — metallic-roughness workflow, image-based lighting (IBL).
  • Deferred + forward paths with Hi-Z GPU occlusion culling and LOD.
  • Post-processing & advanced lighting — SSAO, SSR, SSGI, contact shadows, all shadow types (incl. PCSS), TAA, volumetric fog + god rays, screen-space decals, order-independent transparency, and FSR/CAS upscaling.
  • Unified 2D/3D pipeline — sprites and 3D meshes through one renderer.
  • Skeletal animation — Assimp-imported skeletons, animation graphs and state machines, additive layers, and humanoid retargeting; secondary motion via spring bones and cloth.
  • GPU-driven particles — compute-based particle simulation.
  • Real-time particle fluids — GPU Position-Based Fluids solver (compute spatial hash + density-constraint solve) with screen-space fluid rendering, deterministic CPU fallback, and two-way Jolt rigid-body coupling.

Physics

  • 3D — Jolt Physics for rigid bodies, joints, characters, and vehicles.
  • 2D — Box2D for 2D mechanics.
  • Custom collision layers for flexible filtering and detection.

Environmental & world systems

  • Procedural terrain generation with GPU-driven quadtree LOD, and optional virtual texturing — a feedback-driven page cache that moves the splat blend off the per-pixel path.
  • Water / FFT ocean rendering.
  • Weather & environment — wind, snow (with deformation), and precipitation systems.
  • Streaming volumes, light-probe volumes, fog volumes, and navigation-mesh bounds as scene-level authored data.

Gameplay, AI & narrative

  • AI — GOAP planner / action system and a perception system (sight / sound / awareness).
  • Navigation — Recast/Detour nav-mesh generation with nav agents.
  • Dialogue & quests, plus a localization system driving LocalizedTextComponent.
  • Cinematic sequencer — timeline-driven cutscenes.

Networking

  • Reliable UDP transport over GameNetworkingSockets, with encrypted transport (libsodium) and a protobuf wire format for snapshots / RPC. OloServer provides the headless dedicated-server target.

Audio & video

  • 3D spatial audio (miniaudio) with source/listener ECS components and a SoundGraph / MetaSounds-style audio-graph.
  • Video playback component.

UI system

  • ECS-based UI — Canvas, RectTransform, Panel, Text, Image, Button, Slider, Checkbox, Toggle, Progress Bar, Input Field, Dropdown, Scroll View, Grid Layout, WorldAnchor, and more.
  • Anchor layout — RectTransform-style anchoring, pivots, and auto-layout via grid containers; works in both 2D and 3D.
  • Editor integration — a "Create UI" menu, per-component property panels, and editor-time preview.
  • Full C# and Lua bindings for every UI component.

Editor & debugging

  • ImGui editor — scene hierarchy, content/asset browser with drag-and-drop, gizmos and transform tools, and real-time scene manipulation.
  • Play-in-editor — snapshot/restore around Play/Simulate.
  • Profiling — Tracy integration plus custom renderer profiler and GPU/CPU memory tracking.
  • Read-only MCP diagnostics server — inspect a live editor frame (screenshots, camera control, intermediate render targets, shader errors) from an external tool. See docs/guides/mcp-diagnostics-server.md.

Testing

The suite (OloEngine-Tests, GoogleTest) spans two independent axes:

  1. A renderer testing pyramid (L1–L11 plus plumbing / culling-LOD / shader-pipe / integration layers) covering pixels, GL state, and shader math — including screenshot-based visual-regression tests that gate GPU-equipped runs and skip cleanly when no OpenGL 4.6 context is present.
  2. A Functional / cross-subsystem axis ("Functional" tag) driving real Scene::OnUpdateRuntime ticks across Animation × Physics × Scripting × Networking × Audio × Asset × Nav × Save-game × Gameplay × AI seams.
# Run everything, or a single test
build\OloEngine\tests\Debug\OloEngine-Tests.exe
build\OloEngine\tests\Debug\OloEngine-Tests.exe --gtest_filter=SuiteName.TestName

The rationale, per-layer reference, classification rules, and anti-patterns live in docs/testing.md and docs/agent-rules/testing-architecture.md.

Continuous integration

Per-PR and per-push

These gate every change and run on GitHub-hosted runners.

WorkflowStatusCovers
WindowsWindowsMSVC build + full suite via CTest
SanitizersSanitizersASan (Windows/clang-cl), ASan+LSan / UBSan / TSan (Linux/Clang)
SonarCloud ScanSonarCloud ScanStatic analysis (see sonarqube-review-alignment.md)
Pre-commit checksPre-commit checksFormatting, YAML, test-catalogue classification

Scheduled

Cron times are UTC — GitHub's scheduler does not observe DST, so local times shift by an hour across the year. Every one of these is also workflow_dispatch-able from the Actions tab. Scheduled runs may start tens of minutes late under load.

WorkflowStatusCadence (UTC)Runs onCovers
GPU Conformance (AMD)GPU Conformance (AMD, self-hosted)nightly 00:47self-hosted Linux / AMD Navi 10The renderer's visual, golden and perf layers against a real GPU — they skip everywhere else. See self-hosted-gpu-runner.md
Cross-Vendor ConformanceCross-Vendor Conformancenightly 03:47GitHub-hosted WindowsL1/3/5/6/8 against Mesa llvmpipe (software)
Fuzz SmokeFuzz Smokenightly 04:13GitHub-hosted WindowsParser / deserializer fuzzing
Flaky repro (#281)Flaky repro (#281)nightly 07:00GitHub-hosted WindowsRepeat-runs a known-flaky test to hunt issue #281
Video (FFmpeg backend)Video (FFmpeg backend)weekly, Mon 06:00GitHub-hosted matrixOLO_VIDEO_FFMPEG=ON build + decode tests
reflection-status-checkreflection-status-checkmonthly, 1st 09:07GitHub-hosted LinuxReminder to re-check C++26 reflection toolchain support

Hardware-GPU vendor coverage is AMD-only. The goldens in assets/tests/golden/ are baselined on NVIDIA, but only ever from a developer machine — there is no NVIDIA or Intel CI. llvmpipe is a third software implementation, not a second vendor, so it catches spec-compliance drift rather than driver divergence.

Tooling

OloHeaderTool (tools/OloHeaderTool/) scans OloEngine/src/ and generates, at build time (via the GenerateBindings target):

  • C++ and C# scripting bindings from OLO_PROPERTY annotations.
  • The ECS AllComponents type list, the save-game capture/restore lists, the OnComponentAdded/OnComponentRemoved no-op handlers, and per-component scene serialize/deserialize blocks — all derived from the struct *Component definitions.

This collapses what used to be several hand-maintained, easy-to-forget touch-points into codegen: an all-trivial component now round-trips through scenes, save-games, and scripting with little or no hand-written glue. See CLAUDE.md for the exact contract when adding or changing a component.

Dependencies

Most dependencies come from the vcpkg.json manifest, pinned as a whole by its builtin-baseline registry commit and built into vcpkg's binary cache. The ones marked (in-tree) below are still fetched via FetchContent/CPM into OloEngine/vendor/, each for a specific reason documented in that file's header comment — briefly: sol2 and Lua are pinned to a tested pair, stb's port is ~2 years behind on an image decoder, ImGuizmo's port would drag in a second Dear ImGui, and the single-header / forked / generator-driven ones have no port at all. OpenUSD and FFmpeg have their own build paths (see CLAUDE.md).

Core — entt (ECS) · glm (math) · spdlog (logging) · yaml-cpp (serialization) · nlohmann/json (tools / IPC) · choc (utilities, in-tree) · atomic_queue (lock-free MPMC queue) · meshoptimizer

Rendering — glad (in-tree) · glfw · assimp · stb (in-tree) · zlib

Physics — Jolt Physics (3D) · box2d (2D)

Networking — GameNetworkingSockets · libsodium · protobuf

Navigation — recastnavigation

Audio — miniaudio

Scripting — sol2 (in-tree) · lua (in-tree) · Mono (C# runtime, manually integrated)

UI & editor — imgui (in-tree) · imguizmo (in-tree)

Development & profiling — tracy · googletest · filewatch (in-tree)

Code style & pre-commit hooks

We enforce a small set of formatting rules via pre-commit:

python -m pip install --user pre-commit
pre-commit install                 # enable the git hook (run in the repo root)
pre-commit run --all-files         # optional: apply across the whole repo

Included hooks: trailing-whitespace, end-of-file-fixer, and clang-format for C/C++ (uses the repo-root .clang-format). An .editorconfig mirrors the whitespace rules for editors. The hooks ignore vendor/, build/, and IDE metadata folders. A GitHub Action runs pre-commit on every push and PR.

Documentation

Everything under docs/ — start at docs/README.md for the full index. Highlights:

  • docs/ops/build.md — full Windows / Linux / WSL build matrix.
  • docs/testing.md — the canonical testing opinion doc.
  • docs/guides/ — subsystem how-tos (AI/GOAP, perception, terrain, UI, localization, cinematics, video, MCP diagnostics).
  • docs/adr/ — architecture decision records.
  • CLAUDE.md — contributor/agent playbook: build, test, and the component cross-binding contract.

Influences & references

  • Hazel — the foundation this engine started from (The Cherno's game-engine series).
  • Lumos — ideas for the OpenGL renderer and engine architecture.
  • Arc — ideas for the audio engine.
  • Molecular Matters Blog — the multi-threaded render command queue architecture.
Source 3 files
hooks/register.tsx 240 lines
1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register } from 'claude-code'
3
4import type { FleetPr, FleetSnapshot, FleetWorktree } from '../types'
5import {
6  liveQueue,
7  minutesSince,
8  orphanPrs,
9  parseLockStatus,
10  parsePrs,
11  parseWorktrees,
12  prByBranch,
13  prLine,
14  slotLine,
15  statusLine,
16  summaryMarkdown,
17  transitions,
18  waiterLine,
19  worktreeColor,
20  worktreeLine,
21} from './fleet'
22
23const PANE = 'olo-fleet'
24const REFRESH_MS = 3 * 60 * 1000
25const AFTER_PUBLISH_MS = 60 * 1000
26const SNAPSHOT = { plugin: 'olo-fleet', key: 'snapshot' } as const
27const snapshotAtom = atom(SNAPSHOT, null)
28
29const PR_QUERY =
30  'query($owner:String!,$name:String!){repository(owner:$owner,name:$name){pullRequests(states:OPEN,first:50){nodes{' +
31  'number title headRefName isDraft mergeable url reviewThreads(first:100){nodes{isResolved}} ' +
32  'commits(last:1){nodes{commit{statusCheckRollup{state contexts(first:100){nodes{__typename ' +
33  '... on CheckRun{name status conclusion} ... on StatusContext{context state}}}}}}}}}}}'
34
35// One refresh at a time; a timer tick during a slow refresh is dropped, not queued.
36let isRefreshing = false
37
38export const register: Register = on => {
39  on('session.start', async ($, e, next) => {
40    await $.command.register({
41      name: 'fleet',
42      description: 'Show every OloEngine worktree, its PR, CI and review threads, and who holds or waits for the build lock',
43    })
44    $.clock.every(REFRESH_MS, () => {
45      void refresh($)
46    })
47    void refresh($)
48    return next(e)
49  })
50
51  // /fleet always answers in its own transcript row (drawn by the CommandOutput hook
52  // below). The VS Code extension reports a mod's pane as placed but never draws it, so
53  // the pane is only a bonus for surfaces that do show one (the fullscreen terminal).
54  on('command.run', { command: 'fleet' }, async $ => {
55    void $.ui.open({ id: PANE, title: 'OloEngine fleet' })
56    await refresh($, { force: true })
57    const snap = (await $.state.get(SNAPSHOT)).value
58    return { text: snap === null || snap === undefined ? 'Could not read the fleet (git or gh failed).' : summaryMarkdown(snap, await $.clock.now()) }
59  })
60
61  // After a push or a new PR, CI starts: look again in a minute instead of waiting a full period.
62  on('tool.call', { tool: ['Bash', 'PowerShell'] }, async ($, e, next) => {
63    const ran = await next(e)
64    const command = typeof e.command === 'string' ? e.command : ''
65    if (ran.deny === undefined && /\bgit\b[^\n;&|]*\bpush\b|\bgh\s+pr\s+(?:create|ready|merge)\b/.test(command)) {
66      $.clock.after(AFTER_PUBLISH_MS, () => {
67        void refresh($)
68      })
69    }
70    return ran
71  })
72
73  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) =>
74    drawFleet($, $.ui.resolve(e), await read($, snapshotAtom), Math.max(40, e.props.bodyColumns)),
75  )
76
77  // Draws /fleet's own transcript row as the full view; the markdown text stays what the
78  // model reads and what a surface that cannot draw the tree shows.
79  on('ui.render', { component: 'CommandOutput', props: { command: 'fleet' } }, async ($, e, next) => {
80    const snap = await read($, snapshotAtom)
81    if (snap === null || e.props.isErrored) {
82      return next(e)
83    }
84    return drawFleet($, $.ui.resolve(e), snap, Math.max(40, e.viewport?.columns ?? 120))
85  })
86}
87
88async function run($: EngineInterface, argv: string[], timeoutMs = 20_000): Promise<string | undefined> {
89  try {
90    const r = await $.process.run(argv, { timeoutMs })
91    return r.exitCode === 0 ? r.stdout : undefined
92  } catch {
93    return undefined
94  }
95}
96
97async function refresh($: EngineInterface, opts: { force?: boolean } = {}): Promise<void> {
98  if (isRefreshing) {
99    if (!opts.force) {
100      return
101    }
102    // A user asked: wait for the refresh already under way (bounded), then show its result.
103    for (let i = 0; i < 120 && isRefreshing; i += 1) {
104      await $.clock.sleep(500)
105    }
106    return
107  }
108  isRefreshing = true
109  try {
110    const errors: string[] = []
111    const commonDir = (await run($, ['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], 5000))?.trim()
112    if (commonDir === undefined || commonDir.length === 0) {
113      return
114    }
115    const base = commonDir.replace(/[\\/]\.git[\\/]?$/, '')
116
117    const [porcelain, remote, branchOut, lockOut] = await Promise.all([
118      run($, ['git', '-C', base, 'worktree', 'list', '--porcelain'], 10_000),
119      run($, ['git', '-C', base, 'remote', 'get-url', 'origin'], 5000),
120      run($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], 5000),
121      run($, ['pwsh', '-NoProfile', '-NonInteractive', '-File', `${$.plugin.root}/scripts/lock-status.ps1`, '-CommonDir', commonDir], 20_000),
122    ])
123
124    const listed = porcelain === undefined ? [] : parseWorktrees(porcelain)
125    if (porcelain === undefined) {
126      errors.push('git worktree list failed')
127    }
128    const dirtyCounts = await Promise.all(
129      listed.map(async w => {
130        const out = await run($, ['git', '-C', w.path, 'status', '--porcelain'], 30_000)
131        return out === undefined ? null : out.split('\n').filter(l => l.trim().length > 0).length
132      }),
133    )
134    const worktrees: FleetWorktree[] = listed.map((w, i) => ({ ...w, dirty: dirtyCounts[i] ?? null }))
135
136    // A failed or garbled GitHub reply keeps the last PRs known (marked stale) rather than
137    // an empty list, so the next good reply still compares against real CI states.
138    const previous = await $.state.get(SNAPSHOT)
139    let prs: FleetPr[] = previous.value?.prs ?? []
140    const repo = remote === undefined ? undefined : /github\.com[:/]([^/]+)\/(.+?)(?:\.git)?\s*$/.exec(remote)
141    if (repo?.[1] !== undefined && repo[2] !== undefined) {
142      const out = await run($, ['gh', 'api', 'graphql', '-f', `query=${PR_QUERY}`, '-F', `owner=${repo[1]}`, '-F', `name=${repo[2]}`], 30_000)
143      if (out === undefined) {
144        errors.push('GitHub query failed (gh not authenticated, or offline); PR states are from the last good query')
145      } else {
146        try {
147          prs = parsePrs(out)
148        } catch {
149          errors.push('GitHub replied with something that is not the expected JSON (a proxy page?); PR states are from the last good query')
150        }
151      }
152    }
153
154    let lock = { slots: [] as FleetSnapshot['slots'], queue: [] as FleetSnapshot['queue'] }
155    if (lockOut === undefined) {
156      errors.push('build-lock status could not be read')
157    } else {
158      try {
159        lock = parseLockStatus(lockOut)
160      } catch {
161        errors.push('build-lock status was not JSON')
162      }
163    }
164
165    const snapshot: FleetSnapshot = { takenAt: await $.clock.now(), base, worktrees, prs, ...lock, errors }
166    await update($, snapshotAtom, () => snapshot)
167
168    $.ui.status(statusLine(snapshot, branchOut?.trim() ?? null))
169
170    for (const t of transitions(previous.value?.prs, prs)) {
171      const text =
172        t.to === 'SUCCESS'
173          ? `PR #${t.number} CI is green: ${t.title}`
174          : `PR #${t.number} CI failed${t.failing.length > 0 ? ` (${t.failing.slice(0, 3).join(', ')})` : ''}: ${t.title}`
175      $.ui.toast(text, { timeoutMs: 10_000 })
176      void $.ui.notify(text, { title: 'OloEngine CI' }).catch(() => undefined)
177    }
178  } catch {
179    // Every caller fires refresh and forgets it (`void refresh($)`): a rejection here would be
180    // unhandled. The next tick tries again.
181  } finally {
182    isRefreshing = false
183  }
184}
185
186
187async function drawFleet($: EngineInterface, el: ElementTable, snap: FleetSnapshot | null, width: number) {
188  const { Box, Text, Button } = el
189  if (snap === null) {
190    return (
191      <Box flexDirection="column">
192        <Text dimColor>Collecting worktrees, PRs and the build lock…</Text>
193      </Box>
194    )
195  }
196  const now = await $.clock.now()
197  const prs = prByBranch(snap.prs)
198  const clip = (s: string) => (s.length > width ? `${s.slice(0, width - 1)}…` : s)
199
200  return (
201    <Box flexDirection="column">
202      <Text bold>Worktrees</Text>
203      {snap.worktrees.map(w => (
204        <Text key={`wt-${w.path}`} wrap="truncate" color={worktreeColor(w, prs.get(w.branch ?? ''))}>
205          {clip(worktreeLine(w, prs.get(w.branch ?? '')))}
206        </Text>
207      ))}
208      {orphanPrs(snap).map(p => (
209        <Text key={`pr-${p.number}`} wrap="truncate" dimColor>
210          {clip(`  (no worktree) #${p.number} ${p.branch}: ${prLine(p)}`)}
211        </Text>
212      ))}
213      <Text> </Text>
214      <Text bold>Build lock</Text>
215      {snap.slots.map(s => (
216        <Text key={`slot-${s.name}`} wrap="truncate" color={s.held ? 'warning' : 'success'}>
217          {clip(`  ${slotLine(s, now)}`)}
218        </Text>
219      ))}
220      {liveQueue(snap).map((q, i) => (
221        <Text key={`q-${q.enqueued}-${i}`} wrap="truncate" dimColor>
222          {clip(`  ${waiterLine(q, i, now)}`)}
223        </Text>
224      ))}
225      {snap.queue.some(q => !q.alive) && (
226        <Text dimColor>{`  ${snap.queue.filter(q => !q.alive).length} stale ticket(s) from dead processes (build-lock skips them)`}</Text>
227      )}
228      {snap.errors.map((err, i) => (
229        <Text key={`err-${i}`} color="error" wrap="truncate">
230          {clip(`  ${err}`)}
231        </Text>
232      ))}
233      <Box>
234        <Text dimColor>{`Updated ${minutesSince(new Date(snap.takenAt).toISOString(), now) ?? 0} min ago  `}</Text>
235        <Button key="refresh" label="Refresh" onPress={() => refresh($, { force: true })} />
236      </Box>
237    </Box>
238  )
239}
240
hooks/fleet.ts 230 lines
1// Pure parsing and summarising for the fleet view. No engine calls: the tests drive it.
2
3import type { FleetCheckState, FleetPr, FleetSlot, FleetSnapshot, FleetWaiter, FleetWorktree } from '../types'
4
5export function baseName(path: string): string {
6  return path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? path
7}
8
9/** `git worktree list --porcelain`. The first entry is the main worktree. */
10export function parseWorktrees(porcelain: string): Omit<FleetWorktree, 'dirty'>[] {
11  const out: Omit<FleetWorktree, 'dirty'>[] = []
12  for (const block of porcelain.replace(/\r\n?/g, '\n').split(/\n\n+/)) {
13    const path = /^worktree (.+)$/m.exec(block)?.[1]?.trim()
14    if (path === undefined) {
15      continue
16    }
17    const head = /^HEAD ([0-9a-f]+)$/m.exec(block)?.[1] ?? ''
18    const branch = /^branch refs\/heads\/(.+)$/m.exec(block)?.[1]?.trim() ?? null
19    out.push({ path, name: baseName(path), branch, head: head.slice(0, 9), isBase: out.length === 0 })
20  }
21  return out
22}
23
24type RawContext = { name?: string; context?: string; status?: string; conclusion?: string | null; state?: string }
25
26/** The GraphQL answer for open PRs (see PR_QUERY in register). */
27export function parsePrs(json: string): FleetPr[] {
28  const data = JSON.parse(json) as {
29    data?: { repository?: { pullRequests?: { nodes?: unknown[] } } }
30  }
31  const nodes = data.data?.repository?.pullRequests?.nodes ?? []
32  return nodes.flatMap(node => {
33    const n = node as {
34      number: number
35      title: string
36      headRefName: string
37      url: string
38      isDraft: boolean
39      mergeable: string
40      reviewThreads?: { nodes?: { isResolved: boolean }[] }
41      commits?: { nodes?: { commit?: { statusCheckRollup?: { state?: string; contexts?: { nodes?: RawContext[] } } | null } }[] }
42    }
43    if (typeof n.number !== 'number') {
44      return []
45    }
46    const rollup = n.commits?.nodes?.[0]?.commit?.statusCheckRollup ?? null
47    const contexts = rollup?.contexts?.nodes ?? []
48    const failing = contexts
49      .filter(c => ['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE'].includes(String(c.conclusion ?? c.state ?? '')))
50      .map(c => c.name ?? c.context ?? '?')
51    const running = contexts.filter(c => (c.status !== undefined && c.status !== 'COMPLETED') || c.state === 'PENDING').length
52    return [
53      {
54        number: n.number,
55        title: n.title,
56        branch: n.headRefName,
57        url: n.url,
58        isDraft: n.isDraft,
59        mergeable: n.mergeable,
60        checks: (rollup?.state as FleetCheckState | undefined) ?? 'NONE',
61        failing,
62        running,
63        unresolved: (n.reviewThreads?.nodes ?? []).filter(t => !t.isResolved).length,
64      },
65    ]
66  })
67}
68
69/** lock-status.ps1's JSON. */
70export function parseLockStatus(json: string): { slots: FleetSlot[]; queue: FleetWaiter[] } {
71  const raw = JSON.parse(json) as { slots?: FleetSlot[] | FleetSlot; queue?: FleetWaiter[] | FleetWaiter }
72  const list = <T>(v: T[] | T | undefined): T[] => (v === undefined ? [] : Array.isArray(v) ? v : [v])
73  return { slots: list(raw.slots), queue: list(raw.queue) }
74}
75
76export type Transition = { number: number; title: string; from: FleetCheckState; to: FleetCheckState; failing: string[] }
77
78/** CI results that settled since the last snapshot: worth a notification. */
79export function transitions(previous: readonly FleetPr[] | undefined, next: readonly FleetPr[]): Transition[] {
80  if (previous === undefined) {
81    return []
82  }
83  const before = new Map(previous.map(p => [p.number, p.checks]))
84  return next.flatMap(pr => {
85    const from = before.get(pr.number)
86    const settled = pr.checks === 'SUCCESS' || pr.checks === 'FAILURE' || pr.checks === 'ERROR'
87    return from !== undefined && from !== pr.checks && settled ? [{ number: pr.number, title: pr.title, from, to: pr.checks, failing: pr.failing }] : []
88  })
89}
90
91export function minutesSince(iso: string | null, now: number): number | undefined {
92  if (iso === null) {
93    return undefined
94  }
95  const t = Date.parse(iso)
96  return Number.isNaN(t) ? undefined : Math.max(0, Math.round((now - t) / 60_000))
97}
98
99/** "C:/repos/OloEngine-x/build-cached" from a build command, for naming who builds. */
100export function buildTree(command: string | null): string | null {
101  const m = command === null ? null : /--build\s+("?)([^"\s]+)\1/.exec(command)
102  return m?.[2] ?? null
103}
104
105export function describeHolder(slot: FleetSlot, now: number): string {
106  const who = slot.worktree ? baseName(slot.worktree) : 'unknown'
107  // A sub-worktree a session fanned out into builds under the parent session's name: say which tree.
108  const tree = buildTree(slot.command)
109  const treeOwner = tree !== null && /[\\/]/.test(tree) ? baseName(tree.replace(/[\\/]build[\w-]*$/, '')) : who
110  const where = treeOwner !== who ? ` (${treeOwner})` : ''
111  const config = /--config\s+(\w+)/.exec(slot.command ?? '')?.[1]
112  const mins = minutesSince(slot.acquired, now)
113  return `${who}${where}${config ? ` ${config}` : ''}${mins !== undefined ? `, ${mins} min` : ''}`
114}
115
116const CHECK_MARK: Record<FleetCheckState, string> = {
117  SUCCESS: 'green',
118  FAILURE: 'RED',
119  ERROR: 'RED',
120  PENDING: 'running',
121  EXPECTED: 'waiting',
122  NONE: 'no checks',
123}
124
125export function checkWord(state: FleetCheckState): string {
126  return CHECK_MARK[state]
127}
128
129/** The one-line status: this worktree's PR, then the build lock. */
130export function statusLine(snapshot: FleetSnapshot, currentBranch: string | null): string {
131  const parts: string[] = []
132  const mine = snapshot.prs.find(p => p.branch === currentBranch)
133  if (mine !== undefined) {
134    const threads = mine.unresolved > 0 ? `, ${mine.unresolved} open thread(s)` : ''
135    parts.push(`PR #${mine.number} ${checkWord(mine.checks)}${threads}`)
136  }
137  const held = snapshot.slots.filter(s => s.held).length
138  const waiting = snapshot.queue.filter(q => q.alive).length
139  parts.push(`build lock ${held}/${snapshot.slots.length} held${waiting > 0 ? `, ${waiting} waiting` : ''}`)
140  const red = snapshot.prs.filter(p => p.checks === 'FAILURE' || p.checks === 'ERROR').length
141  if (red > 0) {
142    parts.push(`${red} red PR(s)`)
143  }
144  return `fleet: ${parts.join(' · ')}`
145}
146
147/** Worktrees whose branch has an open PR, keyed by branch. */
148export function prByBranch(prs: readonly FleetPr[]): Map<string, FleetPr> {
149  return new Map(prs.map(p => [p.branch, p]))
150}
151
152export function prLine(pr: FleetPr): string {
153  const parts = [pr.checks === 'PENDING' && pr.running > 0 ? `CI running, ${pr.running} check(s) left` : `CI ${checkWord(pr.checks)}`]
154  if (pr.failing.length > 0) {
155    parts.push(`failing: ${pr.failing.slice(0, 3).join(', ')}${pr.failing.length > 3 ? ', …' : ''}`)
156  }
157  if (pr.unresolved > 0) {
158    parts.push(`${pr.unresolved} open thread(s)`)
159  }
160  if (pr.mergeable === 'CONFLICTING') {
161    parts.push('CONFLICTS with master')
162  }
163  if (pr.isDraft) {
164    parts.push('draft')
165  }
166  return parts.join(', ')
167}
168
169export function worktreeLine(w: FleetWorktree, pr: FleetPr | undefined): string {
170  const dirty = w.dirty === null ? '?' : w.dirty === 0 ? 'clean' : `${w.dirty} changed`
171  const branch = w.branch ?? `detached ${w.head}`
172  const prText = pr === undefined ? (w.isBase ? '' : 'no PR') : `PR #${pr.number}: ${prLine(pr)}`
173  return `  ${w.name}  [${branch}]  ${dirty}${prText ? `  ${prText}` : ''}`
174}
175
176export function worktreeColor(w: FleetWorktree, pr: FleetPr | undefined): 'error' | 'success' | 'warning' | 'text' {
177  if (pr?.checks === 'FAILURE' || pr?.checks === 'ERROR' || pr?.mergeable === 'CONFLICTING') {
178    return 'error'
179  }
180  if (pr?.unresolved !== undefined && pr.unresolved > 0) {
181    return 'warning'
182  }
183  return pr?.checks === 'SUCCESS' ? 'success' : 'text'
184}
185
186export function orphanPrs(snap: FleetSnapshot): FleetPr[] {
187  return snap.prs.filter(p => !snap.worktrees.some(w => w.branch === p.branch))
188}
189
190export function liveQueue(snap: FleetSnapshot): FleetWaiter[] {
191  return snap.queue.filter(q => q.alive)
192}
193
194export function slotLine(slot: FleetSlot, now: number): string {
195  return `${slot.name}: ${slot.held ? `held by ${describeHolder(slot, now)}` : 'free'}`
196}
197
198export function waiterLine(waiter: FleetWaiter, index: number, now: number): string {
199  const as: FleetSlot = { name: '', held: false, worktree: waiter.worktree, command: waiter.command, acquired: waiter.enqueued }
200  return `${index + 1}. ${describeHolder(as, now)} waiting`
201}
202
203/**
204 * The fleet as markdown: what /fleet answers where no pane can be placed. It is also
205 * the text the model reads for that command row, so it stays short.
206 */
207export function summaryMarkdown(snap: FleetSnapshot, now: number): string {
208  const prs = prByBranch(snap.prs)
209  const lines = ['**Worktrees**', '']
210  for (const w of snap.worktrees) {
211    lines.push(`- ${worktreeLine(w, prs.get(w.branch ?? '')).trim()}`)
212  }
213  for (const p of orphanPrs(snap)) {
214    lines.push(`- (no worktree) PR #${p.number} \`${p.branch}\`: ${prLine(p)}`)
215  }
216  lines.push('', '**Build lock**', '')
217  for (const s of snap.slots) {
218    lines.push(`- ${slotLine(s, now)}`)
219  }
220  liveQueue(snap).forEach((q, i) => lines.push(`- ${waiterLine(q, i, now)}`))
221  const stale = snap.queue.length - liveQueue(snap).length
222  if (stale > 0) {
223    lines.push(`- ${stale} stale ticket(s) from dead processes`)
224  }
225  for (const err of snap.errors) {
226    lines.push(`- error: ${err}`)
227  }
228  return lines.join('\n')
229}
230
types/index.d.ts 56 lines
1export type FleetCheckState = 'SUCCESS' | 'FAILURE' | 'ERROR' | 'PENDING' | 'EXPECTED' | 'NONE'
2
3export type FleetWorktree = {
4  path: string
5  name: string
6  branch: string | null
7  head: string
8  isBase: boolean
9  /** Lines of `git status --porcelain`; null when it could not be read in time. */
10  dirty: number | null
11}
12
13export type FleetPr = {
14  number: number
15  title: string
16  branch: string
17  url: string
18  isDraft: boolean
19  mergeable: string
20  checks: FleetCheckState
21  failing: string[]
22  running: number
23  unresolved: number
24}
25
26export type FleetSlot = {
27  name: string
28  held: boolean
29  worktree: string | null
30  command: string | null
31  acquired: string | null
32}
33
34export type FleetWaiter = {
35  worktree: string
36  command: string
37  enqueued: string
38  alive: boolean
39}
40
41export type FleetSnapshot = {
42  takenAt: number
43  base: string
44  worktrees: FleetWorktree[]
45  prs: FleetPr[]
46  slots: FleetSlot[]
47  queue: FleetWaiter[]
48  errors: string[]
49}
50
51declare module 'claude-code' {
52  interface PluginState {
53    'olo-fleet': { snapshot: FleetSnapshot | null }
54  }
55}
56