Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions poc/graph-control/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ npm run dev # dev server, hot reload — opens the demo fixture
npm run build # → dist/index.html, a single self-contained file you can double-click
npm run build:lib # → dist-lib/fallout-graph-control.js, an IIFE for host embedding
npm run report # → dist-lib/report.html, a self-contained static build-graph report
npm run live # → dist-lib/live.html, a self-contained looping live-run demo
```

## Static HTML report
Expand Down Expand Up @@ -47,6 +48,28 @@ Re-calling `mount` on the same element reconciles in place — that's how the VS
Code extension does its live refresh. The runtime `<style>` injection needs
`style-src 'unsafe-inline'` in the host's CSP.

## Live run graph

`mountLive` keeps the graph in sync with a running build — each per-target status
update animates in place (queued → running → succeeded/failed; edges feeding a
running target flow). Layout is cached on the graph *structure*, so a status-only
update never re-lays-out.

```js
const dispose = FalloutGraph.mountLive(el, initialGraph, {
subscribe: FalloutGraph.sseStatus('/build/events'), // or pollStatus(url, ms)
onRunTarget: (name) => { /* … */ },
});
// later: dispose();
```

A source is any `subscribe(push)` that calls `push` with a `{ targetName: status }`
patch or a whole replacement graph, and returns a teardown. Two adapters ship:
`pollStatus(url, intervalMs)` (fits the extension's file-watcher / a status JSON)
and `sseStatus(url)` (a live server stream). `npm run live` builds a self-contained
demo driven by a scripted run — the stand-in until the `BuildManager` status
producer (Phase 3, framework side) exists.

## How it maps to Fallout

- Data model mirrors `build-graph.json` (see `src/model.ts` ↔
Expand Down
1 change: 1 addition & 0 deletions poc/graph-control/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"build": "vite build",
"build:lib": "vite build --config vite.lib.config.ts",
"report": "node scripts/report.mjs",
"live": "node scripts/live-demo.mjs",
"preview": "vite preview"
},
"dependencies": {
Expand Down
86 changes: 86 additions & 0 deletions poc/graph-control/scripts/live-demo.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Emits a self-contained live-run demo: the control's IIFE inlined, driven by a
// scripted build run through FalloutGraph.mountLive. Proves the live animation
// path (queued → running → succeeded, edges flowing into active targets) with no
// server and no C# side — a stand-in for the real BuildManager status stream.
//
// node scripts/live-demo.mjs [out.html]
import { execSync } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, '..');
const bundle = join(root, 'dist-lib', 'fallout-graph-control.js');
const outArg = process.argv[2] ?? join(root, 'dist-lib', 'live.html');

if (!existsSync(bundle)) {
console.log('[live-demo] building library bundle…');
execSync('npm run build:lib', { cwd: root, stdio: 'inherit' });
}
const bundleJs = readFileSync(bundle, 'utf8');

const html = `<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fallout — Live Build Graph</title>
<style>html, body { height: 100%; margin: 0; padding: 0; } #graph { height: 100vh; }</style>
</head>
<body>
<div id="graph"></div>
<script>${bundleJs}</script>
<script>
// Structure with every target queued — the run drives the statuses.
var initial = {
version: 1, falloutVersion: '2026.1.0-preview.412.g8f3a1c',
targets: [
{ name:'Clean', description:'Wipe artifacts', default:false, listed:false, dependsOn:[], after:[], triggeredBy:[], triggers:[], status:'skipped' },
{ name:'Restore', description:'dotnet restore', default:false, listed:true, dependsOn:[], after:['Clean'], triggeredBy:[], triggers:[], status:'queued' },
{ name:'Compile', description:'Build all projects', default:false, listed:true, dependsOn:['Restore'], after:[], triggeredBy:[], triggers:[], status:'queued' },
{ name:'Test', description:'xUnit suite', default:false, listed:true, dependsOn:['Compile'], after:[], triggeredBy:[], triggers:[], status:'queued' },
{ name:'Pack', description:'NuGet pack (default)',default:true, listed:true, dependsOn:['Compile'], after:[], triggeredBy:[], triggers:['Canary'], status:'queued' },
{ name:'Publish', description:'Push to GH Packages', default:false, listed:true, dependsOn:['Test','Pack'], after:[], triggeredBy:[], triggers:[], status:'queued' },
{ name:'Canary', description:'Smoke-test package', default:false, listed:true, dependsOn:['Publish'], after:[], triggeredBy:['Pack'], triggers:[], status:'queued' }
]
};

// Scripted run: each step is a status patch pushed at { at } ms into the run.
var script = [
{ at: 500, s: { Restore:'running' } },
{ at: 1600, s: { Restore:'succeeded', Compile:'running' } },
{ at: 3000, s: { Compile:'succeeded', Test:'running', Pack:'running' } },
{ at: 5200, s: { Test:'succeeded', Pack:'succeeded', Publish:'running' } },
{ at: 6600, s: { Publish:'succeeded', Canary:'running' } },
{ at: 7800, s: { Canary:'succeeded' } }
];
var LOOP_GAP = 2000, RUN_END = 7800;

// Looping simulator: resets to queued, replays the script, repeats — so the
// graph is animating whenever you look. Stand-in for a real status stream.
function simulate(push) {
var timers = [];
function reset() {
var t = initial.targets.map(function (x) {
return Object.assign({}, x, { status: x.name === 'Clean' ? 'skipped' : 'queued' });
});
push(Object.assign({}, initial, { targets: t }));
}
function cycle() {
reset();
script.forEach(function (step) { timers.push(setTimeout(function () { push(step.s); }, step.at)); });
timers.push(setTimeout(cycle, RUN_END + LOOP_GAP));
}
cycle();
return function () { timers.forEach(clearTimeout); };
}

FalloutGraph.mountLive(document.getElementById('graph'), initial, { subscribe: simulate });
</script>
</body>
</html>
`;

writeFileSync(resolve(outArg), html);
console.log('[live-demo] wrote ' + resolve(outArg) + ' (' + Math.round(Buffer.byteLength(html) / 1024) + ' KB, self-contained)');
56 changes: 45 additions & 11 deletions poc/graph-control/src/GraphControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,30 +23,64 @@ export interface GraphControlProps {
onRunTarget?: (target: string) => void;
}

// Structural signature: names + relations + flags, but NOT status. Layout depends
// only on structure, so a status-only change (the live case) reuses the existing
// positions instead of triggering an async relayout — the key to smooth animation.
function structureSignature(graph: BuildGraph): string {
return graph.targets
.map((t) => `${t.name}|${t.dependsOn}|${t.after}|${t.triggers}|${t.triggeredBy}|${t.default}|${t.listed}`)
.join('\n');
}

/**
* The reusable Fallout graph control. Same component drives the VS Code webview,
* the static --plan HTML report, and (later) the live CI run graph — only the
* data source and the onRunTarget handler change.
* the static --plan HTML report, and the live CI run graph — only the data source
* and the onRunTarget handler change. Layout (positions) is computed from the
* structure; per-target status is patched in on top, so a live run animates
* without re-laying-out.
*/
export function GraphControl({ graph, onRunTarget }: GraphControlProps) {
const [nodes, setNodes] = useState<Node<TargetNodeData>[]>([]);
const [edges, setEdges] = useState<Edge[]>([]);
const [ready, setReady] = useState(false);
// Base layout — positions + edges — recomputed only when the structure changes.
const [base, setBase] = useState<{ nodes: Node<TargetNodeData>[]; edges: Edge[] } | null>(null);
const structureKey = useMemo(() => structureSignature(graph), [graph]);

useEffect(() => {
let cancelled = false;
setReady(false);
void layoutGraph(graph).then((laid) => {
if (cancelled) return;
setNodes(laid.nodes);
setEdges(laid.edges);
setReady(true);
if (!cancelled) setBase(laid);
});
return () => {
cancelled = true;
};
// graph is read for its structure only; structureKey gates the relayout.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [structureKey]);

const statusByName = useMemo(() => {
const m = new Map<string, string | undefined>();
for (const t of graph.targets) m.set(t.name, t.status);
return m;
}, [graph]);

// Patch current status onto the laid-out nodes (new object only when it changed,
// so unchanged nodes keep referential identity).
const nodes = useMemo<Node<TargetNodeData>[]>(() => {
if (!base) return [];
return base.nodes.map((n) => {
const status = statusByName.get(n.id);
return status === n.data.status ? n : { ...n, data: { ...n.data, status } };
});
}, [base, statusByName]);

// Animate edges feeding a currently-running target (the "flow" into active work).
const edges = useMemo<Edge[]>(() => {
if (!base) return [];
return base.edges.map((e) => {
const animated = statusByName.get(e.target) === 'running';
return animated === e.animated ? e : { ...e, animated };
});
}, [base, statusByName]);

const onNodeClick = useMemo<NodeMouseHandler>(
() => (_event, node) => onRunTarget?.(node.id),
[onRunTarget],
Expand All @@ -63,7 +97,7 @@ export function GraphControl({ graph, onRunTarget }: GraphControlProps) {
<span className="graph-count">{graph.targets.length} targets</span>
</div>
<div className="graph-canvas">
{ready && (
{base && (
<ReactFlow
nodes={nodes}
edges={edges}
Expand Down
87 changes: 87 additions & 0 deletions poc/graph-control/src/mount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,90 @@ export function unmount(el: HTMLElement): void {
roots.get(el)?.unmount();
roots.delete(el);
}

// ---- Live run graph ----------------------------------------------------------

/** A status update: either a {targetName: status} patch, or a whole replacement graph. */
export type StatusUpdate = Record<string, string> | BuildGraph;

/** Wires a status source. Called once with a `push` callback; returns a teardown. */
export type Subscribe = (push: (update: StatusUpdate) => void) => (() => void) | void;

export interface LiveOptions {
onRunTarget?: (target: string) => void;
/** The status source — e.g. `pollStatus(url)` or `sseStatus(url)`, or a custom fn. */
subscribe: Subscribe;
}

function isGraph(update: StatusUpdate): update is BuildGraph {
return Array.isArray((update as BuildGraph).targets);
}

/**
* Renders the graph and keeps it live: each update from `subscribe` patches
* per-target status (or replaces the whole graph) and re-renders. Because layout
* is cached on structure, a status patch only animates the affected nodes/edges.
* Returns a dispose function that tears down the source and unmounts.
*/
export function mountLive(el: HTMLElement, initialGraph: BuildGraph, options: LiveOptions): () => void {
let graph = initialGraph;
const render = () => mount(el, graph, { onRunTarget: options.onRunTarget });
render();

const apply = (update: StatusUpdate) => {
if (isGraph(update)) {
graph = update;
} else {
graph = {
...graph,
targets: graph.targets.map((t) =>
update[t.name] ? { ...t, status: update[t.name] } : t,
),
};
}
render();
};

const teardown = options.subscribe(apply);
return () => {
teardown?.();
unmount(el);
};
}

/** Status source that polls a JSON URL (a status map or a full graph) on an interval. */
export function pollStatus(url: string, intervalMs = 1000): Subscribe {
return (push) => {
let stopped = false;
const tick = async () => {
if (stopped) return;
try {
const res = await fetch(url, { cache: 'no-store' });
if (res.ok) push(await res.json());
} catch {
// transient — the next tick retries
}
};
const id = setInterval(tick, intervalMs);
void tick();
return () => {
stopped = true;
clearInterval(id);
};
};
}

/** Status source backed by Server-Sent Events; each event's data is a JSON update. */
export function sseStatus(url: string): Subscribe {
return (push) => {
const source = new EventSource(url);
source.onmessage = (event) => {
try {
push(JSON.parse(event.data));
} catch {
// ignore malformed frames
}
};
return () => source.close();
};
}