Skip to content

Commit 7328476

Browse files
feat(miner-ui): add a build-time-flagged demo mode (no backend, no credentials)
apps/loopover-miner-ui only exists as something an individual AMS miner builds and runs locally -- there's no way to show anyone what it looks like without them self-hosting it first, and #5229's open decision about whether hosted AMS reuses this dashboard is currently being made with no working prototype to point at. The 12 local API plugins that back this dashboard's real data are Vite dev/preview-server-only (configureServer/configurePreviewServer hooks) -- they never run in a built, deployed context at all, so "swap the plugin for a mock" can't work for an eventual static/Worker deploy. The mock has to live at the frontend fetch-client layer instead. Adds VITE_DEMO_MODE (mirrors loopover-ui's signInPreview() build-time-flag mechanism) gating a demo branch in the five REST fetchers backing the three main dashboard routes (run-history, ledgers, portfolio-queue + its release/requeue actions, governor). Governor pause/resume and queue release/requeue mutate real in-memory (browser-session-only) state rather than a static read-only view -- harmless to simulate, and a control that visibly does nothing is a worse demo than one that responds. A visible "Demo — sample data, no live backend" badge keeps this honest; every fixture value is entirely synthetic. Scoped deliberately to this mock layer only -- discover/attempt/chat (which trigger a real coding-agent iteration or ground against a live MCP connection) and the CI build+deploy workflow are follow-up work, not this change. Advances #5963
1 parent e1f8db8 commit 7328476

13 files changed

Lines changed: 419 additions & 2 deletions

apps/loopover-miner-ui/src/governor.test.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { fireEvent, render, screen } from "@testing-library/react";
2-
import { describe, expect, it, vi } from "vitest";
2+
import { afterEach, describe, expect, it, vi } from "vitest";
33

44
import {
55
fetchGovernorPauseState,
@@ -11,6 +11,7 @@ import {
1111
type GovernorPauseState,
1212
type GovernorPauseStateResult,
1313
} from "./lib/governor";
14+
import { resetDemoDataForTest } from "./lib/demo-data";
1415
import { GovernorControlSection } from "./routes/ledgers";
1516
import {
1617
governorApiPlugin,
@@ -99,6 +100,11 @@ describe("GovernorControlSection (#4857)", () => {
99100
});
100101

101102
describe("fetchGovernorPauseState / pauseGovernor / resumeGovernor (#4857)", () => {
103+
afterEach(() => {
104+
vi.unstubAllEnvs();
105+
resetDemoDataForTest();
106+
});
107+
102108
const jsonResponse = (status: number, payload: unknown) =>
103109
({ ok: status >= 200 && status < 300, status, json: async () => payload }) as unknown as Response;
104110

@@ -175,6 +181,40 @@ describe("fetchGovernorPauseState / pauseGovernor / resumeGovernor (#4857)", ()
175181
}),
176182
).toEqual(failing);
177183
});
184+
185+
describe("demo mode (#5963)", () => {
186+
it("fetchGovernorPauseState returns the canned (not-paused) demo state without ever calling fetch", async () => {
187+
vi.stubEnv("VITE_DEMO_MODE", "1");
188+
let called = false;
189+
const result = await fetchGovernorPauseState(async () => {
190+
called = true;
191+
return jsonResponse(200, { pauseState: pausedState });
192+
});
193+
expect(called).toBe(false);
194+
expect(result).toEqual({ ok: true, pauseState: { paused: false, reason: null, pausedAt: null } });
195+
});
196+
197+
it("pauseGovernor/resumeGovernor mutate an in-memory demo state and round-trip through fetchGovernorPauseState, without ever calling fetch", async () => {
198+
vi.stubEnv("VITE_DEMO_MODE", "1");
199+
let called = false;
200+
const failIfCalled = async () => {
201+
called = true;
202+
return jsonResponse(200, {});
203+
};
204+
205+
const paused = await pauseGovernor("demo pause", failIfCalled);
206+
expect(paused.ok).toBe(true);
207+
expect(paused.ok && paused.pauseState.paused).toBe(true);
208+
expect(paused.ok && paused.pauseState.reason).toBe("demo pause");
209+
const afterPause = await fetchGovernorPauseState(failIfCalled);
210+
expect(afterPause.ok && afterPause.pauseState.paused).toBe(true);
211+
212+
const resumed = await resumeGovernor(failIfCalled);
213+
expect(resumed).toEqual({ ok: true, pauseState: { paused: false, reason: null, pausedAt: null } });
214+
215+
expect(called).toBe(false);
216+
});
217+
});
178218
});
179219

180220
describe("matchGovernorRoute (#4857)", () => {

apps/loopover-miner-ui/src/ledgers.test.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,10 @@ describe("LedgersPage (#4855)", () => {
407407
});
408408

409409
describe("fetchLedgers (#4855)", () => {
410+
afterEach(() => {
411+
vi.unstubAllEnvs();
412+
});
413+
410414
const jsonResponse = (status: number, payload: unknown) =>
411415
({ ok: status >= 200 && status < 300, status, json: async () => payload }) as unknown as Response;
412416

@@ -434,6 +438,17 @@ describe("fetchLedgers (#4855)", () => {
434438
}),
435439
).toEqual({ ok: false, error: "connection refused" });
436440
});
441+
442+
it("#5963: in demo mode, returns a canned summary without ever calling fetch", async () => {
443+
vi.stubEnv("VITE_DEMO_MODE", "1");
444+
let called = false;
445+
const result = await fetchLedgers(async () => {
446+
called = true;
447+
return jsonResponse(200, { summary: emptyLedgersSummary() });
448+
});
449+
expect(called).toBe(false);
450+
expect(result.ok).toBe(true);
451+
});
437452
});
438453

439454
describe("handleLedgersRequest (#4855)", () => {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import {
3+
DEMO_LEDGERS_SUMMARY,
4+
DEMO_PORTFOLIO_QUEUE_SUMMARY,
5+
DEMO_RUN_STATES,
6+
getDemoGovernorState,
7+
getDemoPortfolioQueueItems,
8+
isDemoMode,
9+
removeDemoPortfolioQueueItem,
10+
resetDemoDataForTest,
11+
setDemoGovernorPaused,
12+
setDemoGovernorResumed,
13+
} from "./demo-data";
14+
15+
afterEach(() => {
16+
vi.unstubAllEnvs();
17+
resetDemoDataForTest();
18+
});
19+
20+
describe("isDemoMode() (#5963)", () => {
21+
it("is false when VITE_DEMO_MODE is unset", () => {
22+
vi.stubEnv("VITE_DEMO_MODE", "");
23+
expect(isDemoMode()).toBe(false);
24+
});
25+
26+
it("is true only for the exact string '1'", () => {
27+
vi.stubEnv("VITE_DEMO_MODE", "1");
28+
expect(isDemoMode()).toBe(true);
29+
vi.stubEnv("VITE_DEMO_MODE", "true");
30+
expect(isDemoMode()).toBe(false);
31+
});
32+
});
33+
34+
describe("demo fixtures shape (#5963)", () => {
35+
it("DEMO_RUN_STATES is non-empty and every row has a valid state", () => {
36+
expect(DEMO_RUN_STATES.length).toBeGreaterThan(0);
37+
for (const row of DEMO_RUN_STATES) {
38+
expect(["idle", "discovering", "planning", "preparing"]).toContain(row.state);
39+
}
40+
});
41+
42+
it("DEMO_LEDGERS_SUMMARY's claim byStatus counts sum to its total", () => {
43+
const { total, byStatus } = DEMO_LEDGERS_SUMMARY.claims;
44+
expect(byStatus.active + byStatus.released + byStatus.expired).toBe(total);
45+
});
46+
47+
it("DEMO_PORTFOLIO_QUEUE_SUMMARY's per-repo totals sum to the fleet total", () => {
48+
const repoSum = DEMO_PORTFOLIO_QUEUE_SUMMARY.repos.reduce((sum, r) => sum + r.total, 0);
49+
expect(repoSum).toBe(DEMO_PORTFOLIO_QUEUE_SUMMARY.total);
50+
});
51+
});
52+
53+
describe("demo governor pause state (#5963)", () => {
54+
it("starts resumed (not paused)", () => {
55+
expect(getDemoGovernorState()).toEqual({ paused: false, reason: null, pausedAt: null });
56+
});
57+
58+
it("setDemoGovernorPaused sets paused=true with the given reason and a fresh timestamp", () => {
59+
const state = setDemoGovernorPaused("investigating");
60+
expect(state.paused).toBe(true);
61+
expect(state.reason).toBe("investigating");
62+
expect(state.pausedAt).toEqual(expect.any(String));
63+
expect(getDemoGovernorState()).toEqual(state);
64+
});
65+
66+
it("setDemoGovernorPaused accepts a null reason", () => {
67+
expect(setDemoGovernorPaused(null).reason).toBeNull();
68+
});
69+
70+
it("setDemoGovernorResumed clears paused/reason/pausedAt", () => {
71+
setDemoGovernorPaused("x");
72+
expect(setDemoGovernorResumed()).toEqual({ paused: false, reason: null, pausedAt: null });
73+
});
74+
});
75+
76+
describe("demo portfolio-queue items (#5963)", () => {
77+
it("starts with the default fixture items", () => {
78+
expect(getDemoPortfolioQueueItems().length).toBeGreaterThan(0);
79+
});
80+
81+
it("removeDemoPortfolioQueueItem removes and returns the matching item", () => {
82+
const beforeCount = getDemoPortfolioQueueItems().length;
83+
const target = { ...getDemoPortfolioQueueItems()[0]! };
84+
const removed = removeDemoPortfolioQueueItem(target.repoFullName, target.identifier);
85+
expect(removed).toEqual(target);
86+
expect(getDemoPortfolioQueueItems()).toHaveLength(beforeCount - 1);
87+
expect(getDemoPortfolioQueueItems().find((i) => i.identifier === target.identifier)).toBeUndefined();
88+
});
89+
90+
it("removeDemoPortfolioQueueItem returns null for an unknown item, without mutating the list", () => {
91+
const before = getDemoPortfolioQueueItems().length;
92+
expect(removeDemoPortfolioQueueItem("nope/nope", "does-not-exist")).toBeNull();
93+
expect(getDemoPortfolioQueueItems()).toHaveLength(before);
94+
});
95+
});
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Demo mode (#5963): a build-time-flagged, zero-backend mock data layer so this dashboard can be deployed as a
2+
// static demo with no real miner harness, local ledger files, or operator credentials behind it -- the same
3+
// mechanism family as loopover-ui's signInPreview() escape hatch (apps/loopover-ui/src/lib/api/session.ts), just
4+
// covering N fabricated API responses instead of one fake session object. `import.meta.env.VITE_DEMO_MODE` is a
5+
// build-time constant, so the "off" branch (the real fetch calls) is dead-code-eliminated from a demo bundle and
6+
// vice versa -- a production self-host build never carries this module's data.
7+
//
8+
// Scope: the five REST fetchers backing the three main dashboard routes (run-history, ledgers, portfolio +
9+
// its queue actions, governor). discover/attempt/chat are NOT covered here -- those trigger a real coding-agent
10+
// iteration or ground against a live MCP connection, and fabricating a convincing multi-minute agent run is a
11+
// separate, much larger content-design task than tabular summary data (tracked as follow-up work, not this PR).
12+
//
13+
// Every value below is entirely synthetic -- no real repo, run, ledger entry, or account referenced anywhere.
14+
15+
import type { RunStateRow } from "./run-history";
16+
import type { LedgersSummary } from "./ledgers";
17+
import type { PortfolioQueueSummary } from "./portfolio-queue";
18+
import type { PortfolioQueueActionItem } from "./portfolio-queue-actions";
19+
import type { GovernorPauseState } from "./governor";
20+
21+
export function isDemoMode(): boolean {
22+
return import.meta.env.VITE_DEMO_MODE === "1";
23+
}
24+
25+
export const DEMO_RUN_STATES: RunStateRow[] = [
26+
{
27+
apiBaseUrl: "https://forge.example.com",
28+
repoFullName: "acme/widgets",
29+
state: "preparing",
30+
updatedAt: "2026-07-18T14:02:00.000Z",
31+
},
32+
{
33+
apiBaseUrl: "https://forge.example.com",
34+
repoFullName: "acme/api-gateway",
35+
state: "discovering",
36+
updatedAt: "2026-07-18T13:47:00.000Z",
37+
},
38+
{
39+
apiBaseUrl: "https://forge.example.com",
40+
repoFullName: "acme/docs-site",
41+
state: "idle",
42+
updatedAt: "2026-07-18T11:15:00.000Z",
43+
},
44+
{
45+
apiBaseUrl: "https://forge.example.com",
46+
repoFullName: "northwind/inventory",
47+
state: "planning",
48+
updatedAt: "2026-07-18T12:30:00.000Z",
49+
},
50+
];
51+
52+
export const DEMO_LEDGERS_SUMMARY: LedgersSummary = {
53+
claims: { total: 18, byStatus: { active: 3, released: 12, expired: 3 } },
54+
events: {
55+
total: 142,
56+
byType: { claimed: 41, released: 38, event_recorded: 63 },
57+
recent: [
58+
{ eventType: "claimed", repoFullName: "acme/widgets", createdAt: "2026-07-18T14:00:00.000Z" },
59+
{ eventType: "released", repoFullName: "acme/api-gateway", createdAt: "2026-07-18T13:45:00.000Z" },
60+
{ eventType: "event_recorded", repoFullName: "acme/docs-site", createdAt: "2026-07-18T11:10:00.000Z" },
61+
{ eventType: "claimed", repoFullName: "northwind/inventory", createdAt: "2026-07-18T09:30:00.000Z" },
62+
{ eventType: "released", repoFullName: "acme/widgets", createdAt: "2026-07-17T22:14:00.000Z" },
63+
],
64+
},
65+
governor: { total: 9, byEventType: { paused: 4, resumed: 5 } },
66+
};
67+
68+
export const DEMO_PORTFOLIO_QUEUE_SUMMARY: PortfolioQueueSummary = {
69+
total: 27,
70+
byStatus: { queued: 9, in_progress: 3, done: 15 },
71+
repos: [
72+
{ repoFullName: "acme/widgets", byStatus: { queued: 4, in_progress: 1, done: 6 }, total: 11 },
73+
{ repoFullName: "acme/api-gateway", byStatus: { queued: 2, in_progress: 1, done: 4 }, total: 7 },
74+
{ repoFullName: "acme/docs-site", byStatus: { queued: 1, in_progress: 0, done: 3 }, total: 4 },
75+
{ repoFullName: "northwind/inventory", byStatus: { queued: 2, in_progress: 1, done: 2 }, total: 5 },
76+
],
77+
oldestQueuedAgeMs: 6 * 60 * 60 * 1000, // 6h
78+
};
79+
80+
const DEFAULT_DEMO_PORTFOLIO_QUEUE_ITEMS: PortfolioQueueActionItem[] = [
81+
{
82+
apiBaseUrl: "https://forge.example.com",
83+
repoFullName: "acme/widgets",
84+
identifier: "wgt-2451",
85+
status: "in_progress",
86+
},
87+
{
88+
apiBaseUrl: "https://forge.example.com",
89+
repoFullName: "acme/api-gateway",
90+
identifier: "gw-118",
91+
status: "in_progress",
92+
},
93+
{ apiBaseUrl: "https://forge.example.com", repoFullName: "acme/widgets", identifier: "wgt-2438", status: "done" },
94+
{
95+
apiBaseUrl: "https://forge.example.com",
96+
repoFullName: "northwind/inventory",
97+
identifier: "inv-77",
98+
status: "done",
99+
},
100+
];
101+
102+
// Mutable, in-memory, browser-session-only copy -- release/requeue removes the item from this actionable list
103+
// (simulating it going back to "queued", which this endpoint doesn't itself track), same session-only-state
104+
// reasoning as the governor pause state below: a demo control that visibly does nothing is a worse demo than
105+
// one that responds, and there's no real queue here to protect from a fabricated write.
106+
let demoPortfolioQueueItems: PortfolioQueueActionItem[] = [...DEFAULT_DEMO_PORTFOLIO_QUEUE_ITEMS];
107+
108+
export function getDemoPortfolioQueueItems(): PortfolioQueueActionItem[] {
109+
return demoPortfolioQueueItems;
110+
}
111+
112+
/** Remove one item (by repoFullName + identifier) from the demo actionable list, simulating a release/requeue.
113+
* Returns the removed item, or null if no matching item was found (mirrors the real API's not-found shape). */
114+
export function removeDemoPortfolioQueueItem(
115+
repoFullName: string,
116+
identifier: string,
117+
): PortfolioQueueActionItem | null {
118+
const index = demoPortfolioQueueItems.findIndex(
119+
(item) => item.repoFullName === repoFullName && item.identifier === identifier,
120+
);
121+
if (index === -1) return null;
122+
const [removed] = demoPortfolioQueueItems.splice(index, 1);
123+
// A valid index always has exactly one element to splice out; the fallback only guards the array-access
124+
// type, not a real runtime path.
125+
return removed ?? null;
126+
}
127+
128+
/** Test-only: restores the module-level mutable demo state (governor pause state + queue items) to its
129+
* defaults, so one test's release/pause doesn't leak into the next. Never called from app code. */
130+
export function resetDemoDataForTest(): void {
131+
demoPortfolioQueueItems = [...DEFAULT_DEMO_PORTFOLIO_QUEUE_ITEMS];
132+
demoGovernorState = { paused: false, reason: null, pausedAt: null };
133+
}
134+
135+
// Mutable, in-memory, browser-session-only -- pause/resume is harmless to actually simulate (no real governor,
136+
// nothing to protect), and a static read-only demo of a control that visibly does nothing is a worse demo than
137+
// one that responds. Resets to this default on every page reload; never persisted anywhere.
138+
let demoGovernorState: GovernorPauseState = { paused: false, reason: null, pausedAt: null };
139+
140+
export function getDemoGovernorState(): GovernorPauseState {
141+
return demoGovernorState;
142+
}
143+
144+
export function setDemoGovernorPaused(reason: string | null): GovernorPauseState {
145+
demoGovernorState = { paused: true, reason, pausedAt: new Date().toISOString() };
146+
return demoGovernorState;
147+
}
148+
149+
export function setDemoGovernorResumed(): GovernorPauseState {
150+
demoGovernorState = { paused: false, reason: null, pausedAt: null };
151+
return demoGovernorState;
152+
}

apps/loopover-miner-ui/src/lib/governor.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
// response, a guard narrowing the parsed JSON payload) but adds two WRITE actions, the miner-ui's first — safe
44
// only because vite-auth.ts (#4858) now authenticates every /api/* request, including these.
55

6+
import { getDemoGovernorState, isDemoMode, setDemoGovernorPaused, setDemoGovernorResumed } from "./demo-data";
7+
68
export const GOVERNOR_PAUSE_STATE_API_PATH = "/api/governor/pause-state";
79
export const GOVERNOR_PAUSE_API_PATH = "/api/governor/pause";
810
export const GOVERNOR_RESUME_API_PATH = "/api/governor/resume";
@@ -36,6 +38,7 @@ async function parseGovernorPauseStateResponse(
3638

3739
/** Fetch the governor's current pause state; failures surface as a typed error result the view renders, never a crash. */
3840
export async function fetchGovernorPauseState(fetchImpl: typeof fetch = fetch): Promise<GovernorPauseStateResult> {
41+
if (isDemoMode()) return { ok: true, pauseState: getDemoGovernorState() };
3942
try {
4043
const response = await fetchImpl(GOVERNOR_PAUSE_STATE_API_PATH);
4144
return await parseGovernorPauseStateResponse(response, "local governor pause-state API");
@@ -69,10 +72,12 @@ async function postGovernorAction(
6972

7073
/** Pause the governor, optionally with a reason (mirrors `loopover-miner governor pause [--reason <text>]`). */
7174
export function pauseGovernor(reason?: string, fetchImpl: typeof fetch = fetch): Promise<GovernorPauseStateResult> {
75+
if (isDemoMode()) return Promise.resolve({ ok: true, pauseState: setDemoGovernorPaused(reason ?? null) });
7276
return postGovernorAction(GOVERNOR_PAUSE_API_PATH, reason ? { reason } : {}, fetchImpl);
7377
}
7478

7579
/** Resume the governor (mirrors `loopover-miner governor resume`). */
7680
export function resumeGovernor(fetchImpl: typeof fetch = fetch): Promise<GovernorPauseStateResult> {
81+
if (isDemoMode()) return Promise.resolve({ ok: true, pauseState: setDemoGovernorResumed() });
7782
return postGovernorAction(GOVERNOR_RESUME_API_PATH, {}, fetchImpl);
7883
}

apps/loopover-miner-ui/src/lib/ledgers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
// enforce). This client just fetches that summary and validates its shape; a failure surfaces as a typed error
55
// result the view renders, never a crash.
66

7+
import { DEMO_LEDGERS_SUMMARY, isDemoMode } from "./demo-data";
8+
79
export const LEDGERS_API_PATH = "/api/ledgers";
810

911
export const CLAIM_STATUSES = ["active", "released", "expired"] as const;
@@ -56,6 +58,7 @@ function isLedgersSummary(value: unknown): value is LedgersSummary {
5658

5759
/** Fetch the local ledgers summary; failures surface as a typed error result the view renders, never a crash. */
5860
export async function fetchLedgers(fetchImpl: typeof fetch = fetch): Promise<LedgersResult> {
61+
if (isDemoMode()) return { ok: true, summary: DEMO_LEDGERS_SUMMARY };
5962
try {
6063
const response = await fetchImpl(LEDGERS_API_PATH);
6164
if (!response.ok) return { ok: false, error: `local ledgers API responded ${response.status}` };

0 commit comments

Comments
 (0)