From 19456a831a6d15b2e601e0e9f0ff41088c216ff7 Mon Sep 17 00:00:00 2001 From: luciferlive112116 <291889058+luciferlive112116@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:26:27 +0800 Subject: [PATCH] feat(engine): assemble the customer-facing loop dashboard view A customer has no place to see their loop's status, spend, and results, distinct from any internal operations view (#4807). Its proposal is to surface #4800's progress stream and #4792's spend/ledger data; both have since landed, so the data the surface needs now exists. Add that surface's pure view model. buildCustomerLoopView assembles one customer's loop into what their dashboard shows: where it is (#4800's ProgressSnapshot, passed through untouched), what it has cost them (#4792's spend, plus headroom against their #4796 allocation), and what came out of it (#4801's results payload) -- the submit -> watch progress -> see spend and results path, computed once, so the eventual UI only lays it out. The central guarantee is tenant isolation. #4807 exists because a customer's view is distinct from #4808's internal ops fleet view: a customer sees THEIR loop and nothing else. So the tenant filter runs here, via #4792's own audited totalConsumptionForTenant, rather than trusting a caller to have pre-filtered -- a dashboard that renders one customer another's spend is the worst bug this surface could have, and "the caller filtered it" is not a defense. A test passes another tenant's rows in and asserts they reach neither the totals nor the serialized view. Only #4796's spend dimensions are read from the quota decision. Concurrency is not reported: a spend view has no honest activeLoops reading, and inventing one would make the customer's "within allocation" answer depend on a number nobody measured -- the same rule #4792 follows. evaluateTenantQuota still computes the headroom, so the customer's figures and the enforcement path cannot disagree. Assembles a view only: no fetching, no rendering, no clock read. Building the surface is the separate UI work, which the issue gates on the shared design system (#4966/#4967), so this core carries no styling or framework opinion and touches no UI path. Invariants tested: no quota reports null rather than a fabricated ceiling, so a customer can tell "no limit is set" from "you have room"; a concurrency-only limit never makes a within-allocation customer read as over spend; and resultsReady requires both a finished loop and a real payload, so the dashboard never invites a customer to see results that do not exist yet. Closes #4807 --- .../loopover-engine/src/customer-loop-view.ts | 101 +++++++++++++ packages/loopover-engine/src/index.ts | 8 + test/unit/customer-loop-view.test.ts | 139 ++++++++++++++++++ 3 files changed, 248 insertions(+) create mode 100644 packages/loopover-engine/src/customer-loop-view.ts create mode 100644 test/unit/customer-loop-view.test.ts diff --git a/packages/loopover-engine/src/customer-loop-view.ts b/packages/loopover-engine/src/customer-loop-view.ts new file mode 100644 index 0000000000..3728f080c4 --- /dev/null +++ b/packages/loopover-engine/src/customer-loop-view.ts @@ -0,0 +1,101 @@ +// Customer-facing loop dashboard view model (pure) — #4807, part of the Rent-a-Loop path #4778. +// +// Deterministic and side-effect-free: given ONE customer's own loop, it assembles what their dashboard shows — +// where the loop is (submit → watch progress), what it has cost them (spend), and what came out of it (results). +// That is #4807's "submit → watch progress → see spend and results" as a decision core: the data the surface +// renders, computed once, so the eventual UI only lays it out. +// +// It composes the already-merged halves the issue names rather than restating them: #4800's ProgressSnapshot is +// passed through untouched, and spend comes from #4792's own totalConsumptionForTenant — which is also where +// this view's central guarantee comes from. #4807 exists because a customer's view is "distinct from any +// internal operations view" (#4808's fleet summary): a customer sees THEIR loop and nothing else. So the +// tenant filter is applied here, by that same audited primitive, rather than trusting a caller to have handed +// in a pre-filtered list — a dashboard that renders one customer another's spend is the worst bug this surface +// could have, and "the caller filtered it" is not a defense. +// +// It assembles a view only: no fetching, no rendering, no clock read. Building the surface itself is the +// separate UI work, which the issue additionally gates on the shared design system (#4966/#4967) — so this core +// carries no styling or framework opinion and stays correct whatever renders it. + +import { totalConsumptionForTenant, type LoopConsumptionEntry } from "./loop-consumption.js"; +import type { ProgressSnapshot } from "./loop-progress.js"; +import type { ResultsPayload } from "./results-payload.js"; +import { evaluateTenantQuota, type TenantQuota } from "./tenant-quota.js"; + +export type CustomerLoopViewInput = { + /** The customer this view belongs to. Every figure below is scoped to them. */ + tenantId: string; + loopId: string; + /** #4800's snapshot, passed through as-is — the customer already sees exactly this in the progress stream. */ + progress: ProgressSnapshot; + /** + * Consumption entries for the period. MAY contain other tenants' rows — they are filtered out here rather + * than trusted to have been filtered by the caller. + */ + consumption?: readonly LoopConsumptionEntry[] | undefined; + /** #4801's payload once the loop has produced one; absent/null until then — never a placeholder. */ + results?: ResultsPayload | null | undefined; + /** The customer's allocation, when they have one. Absent means "no quota configured", not "unlimited". */ + quota?: TenantQuota | null | undefined; +}; + +export type CustomerLoopSpend = { + computeUnitsUsed: number; + wallClockMsUsed: number; + /** Headroom against their allocation, or null when no quota is configured — never guessed. */ + remaining: { computeUnits: number; wallClockMs: number } | null; + /** Whether they are still within allocation, or null when no quota is configured. */ + withinQuota: boolean | null; +}; + +export type CustomerLoopView = { + loopId: string; + progress: ProgressSnapshot; + spend: CustomerLoopSpend; + results: ResultsPayload | null; + /** True only once the loop is done AND a payload exists — what "see results" waits on. */ + resultsReady: boolean; +}; + +/** + * Assemble one customer's loop dashboard view (#4807). Pure: reads only what it is handed and returns a view + * without fetching, rendering, or mutating anything. + * + * Spend is computed by #4792's `totalConsumptionForTenant` against `input.tenantId`, so a row belonging to any + * other tenant cannot reach this customer's dashboard even if the caller passes the whole period's entries. + * Quota headroom is only reported when a quota is configured: with none, `remaining`/`withinQuota` are `null` + * rather than a fabricated ceiling — a customer must be able to tell "no limit is set" from "you have room". + * + * Only #4796's SPEND dimensions are read from the quota decision. Its third dimension, concurrency, is not + * reported here: a spend view has no honest `activeLoops` reading to give it, and inventing one would make the + * customer's "within allocation" answer depend on a number nobody measured — the same rule #4792's + * `totalConsumptionForTenant` follows for exactly this reason. `evaluateTenantQuota` is still what computes the + * headroom, so the customer's figures and the enforcement path can never disagree about their allocation. + * + * `resultsReady` requires both that the loop is done and that a payload actually exists, so the dashboard never + * invites a customer to "see results" that are not there yet. + */ +export function buildCustomerLoopView(input: CustomerLoopViewInput): CustomerLoopView { + const used = totalConsumptionForTenant(input.consumption ?? [], input.tenantId); + const quota = input.quota ?? null; + // activeLoops is the identity here, not a measurement: the concurrency verdict is discarded below, and both + // spend dimensions' headroom is independent of it. + const decision = quota === null ? null : evaluateTenantQuota({ ...used, activeLoops: 0 }, quota); + const results = input.results ?? null; + + return { + loopId: input.loopId, + progress: input.progress, + spend: { + computeUnitsUsed: used.computeUnitsUsed, + wallClockMsUsed: used.wallClockMsUsed, + remaining: + decision === null + ? null + : { computeUnits: decision.remaining.computeUnits, wallClockMs: decision.remaining.wallClockMs }, + withinQuota: decision === null ? null : decision.exceeded !== "compute" && decision.exceeded !== "time", + }, + results, + resultsReady: input.progress.done && results !== null, + }; +} diff --git a/packages/loopover-engine/src/index.ts b/packages/loopover-engine/src/index.ts index 68f89951dd..dff337ad0e 100644 --- a/packages/loopover-engine/src/index.ts +++ b/packages/loopover-engine/src/index.ts @@ -597,6 +597,14 @@ export { // `LoopConsumptionOutcome` is deliberately its own name, not loop-escalation.ts's `LoopRunOutcome` re-exported // below: that one is a loop's HEALTH state (running/converged/abandoned/error), whereas a consumption entry // only exists for a run that already stopped and only distinguishes finished work from work cut short. +// The customer-facing counterpart to #4808's internal ops fleet view: one customer's own loop only, with +// spend sourced through #4792's tenant-filtering primitive so another tenant's rows cannot reach it (#4807). +export { + buildCustomerLoopView, + type CustomerLoopSpend, + type CustomerLoopView, + type CustomerLoopViewInput, +} from "./customer-loop-view.js"; export { buildLoopConsumptionEntry, totalConsumptionForTenant, diff --git a/test/unit/customer-loop-view.test.ts b/test/unit/customer-loop-view.test.ts new file mode 100644 index 0000000000..f8a8d13b24 --- /dev/null +++ b/test/unit/customer-loop-view.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; + +import { buildCustomerLoopView, type CustomerLoopViewInput } from "../../packages/loopover-engine/src/customer-loop-view"; +import type { LoopConsumptionEntry } from "../../packages/loopover-engine/src/loop-consumption"; +import type { ProgressSnapshot } from "../../packages/loopover-engine/src/loop-progress"; +import type { ResultsPayload } from "../../packages/loopover-engine/src/results-payload"; + +const progress = (over: Partial = {}): ProgressSnapshot => ({ + phase: "coding", + status: "running", + iteration: 2, + maxIterations: 5, + percentComplete: 40, + recentActivity: [{ step: "ran tests" }], + done: false, + ...over, +}); + +const entry = (over: Partial = {}): LoopConsumptionEntry => ({ + tenantId: "acme", + loopId: "loop-1", + outcome: "completed", + wallClockMs: 30_000, + computeUnits: 40, + complete: true, + ...over, +}); + +const results = (): ResultsPayload => ({ + prLink: "https://github.com/acme/widgets/pull/7", + summary: "Opened a PR fixing the reported crash.", + diffPreview: [{ path: "src/app.ts", additions: 3, deletions: 1 }], + totals: { files: 1, additions: 3, deletions: 1 }, +}); + +const input = (over: Partial = {}): CustomerLoopViewInput => ({ + tenantId: "acme", + loopId: "loop-1", + progress: progress(), + ...over, +}); + +const QUOTA = { computeUnits: 100, wallClockMs: 60_000, maxConcurrentLoops: 2 }; + +describe("buildCustomerLoopView (#4807)", () => { + it("surfaces the loop's own progress snapshot untouched — the customer sees exactly the #4800 stream", () => { + const snapshot = progress(); + const view = buildCustomerLoopView(input({ progress: snapshot })); + expect(view.loopId).toBe("loop-1"); + expect(view.progress).toEqual(snapshot); + }); + + it("reports spend from the customer's own consumption entries (#4792)", () => { + const view = buildCustomerLoopView(input({ consumption: [entry(), entry({ loopId: "loop-2", computeUnits: 10, wallClockMs: 5_000 })] })); + expect(view.spend.computeUnitsUsed).toBe(50); + expect(view.spend.wallClockMsUsed).toBe(35_000); + }); + + it("a loop with no consumption yet reports zero spend, not undefined", () => { + expect(buildCustomerLoopView(input()).spend).toMatchObject({ computeUnitsUsed: 0, wallClockMsUsed: 0 }); + expect(buildCustomerLoopView(input({ consumption: [] })).spend).toMatchObject({ computeUnitsUsed: 0, wallClockMsUsed: 0 }); + }); + + // The reason #4807 is "distinct from any internal operations view": a customer sees THEIR loop, nothing else. + it("INVARIANT: another tenant's entries can never reach this customer's dashboard", () => { + const view = buildCustomerLoopView( + input({ + consumption: [entry(), entry({ tenantId: "globex", loopId: "secret", computeUnits: 999, wallClockMs: 999_000 })], + }), + ); + expect(view.spend.computeUnitsUsed).toBe(40); + expect(view.spend.wallClockMsUsed).toBe(30_000); + expect(JSON.stringify(view)).not.toContain("globex"); + expect(JSON.stringify(view)).not.toContain("secret"); + }); + + describe("quota headroom", () => { + it("reports remaining allocation and within-quota when a quota is configured", () => { + const view = buildCustomerLoopView(input({ consumption: [entry()], quota: QUOTA })); + expect(view.spend.remaining).toEqual({ computeUnits: 60, wallClockMs: 30_000 }); + expect(view.spend.withinQuota).toBe(true); + }); + + it("flags a customer who has spent their whole allocation", () => { + const view = buildCustomerLoopView(input({ consumption: [entry({ computeUnits: 100 })], quota: QUOTA })); + expect(view.spend.remaining).toEqual({ computeUnits: 0, wallClockMs: 30_000 }); + expect(view.spend.withinQuota).toBe(false); + }); + + it("flags a customer who has burned their whole time allocation", () => { + const view = buildCustomerLoopView(input({ consumption: [entry({ wallClockMs: 60_000 })], quota: QUOTA })); + expect(view.spend.withinQuota).toBe(false); + }); + + it("INVARIANT: no quota configured reports null, never a fabricated ceiling", () => { + for (const q of [undefined, null]) { + const view = buildCustomerLoopView(input({ consumption: [entry()], quota: q })); + expect(view.spend.remaining).toBeNull(); + expect(view.spend.withinQuota).toBeNull(); + expect(view.spend.computeUnitsUsed).toBe(40); // spend itself is still real + } + }); + + // A spend view has no honest activeLoops reading, so it must not answer a concurrency question. + it("INVARIANT: a concurrency-only limit never makes a within-allocation customer read as over spend", () => { + const view = buildCustomerLoopView(input({ consumption: [entry()], quota: { ...QUOTA, maxConcurrentLoops: 0 } })); + expect(view.spend.withinQuota).toBe(true); + expect(view.spend.remaining).toEqual({ computeUnits: 60, wallClockMs: 30_000 }); + }); + }); + + describe("results", () => { + it("is null while the loop is still running, and not ready", () => { + const view = buildCustomerLoopView(input()); + expect(view.results).toBeNull(); + expect(view.resultsReady).toBe(false); + }); + + it("INVARIANT: never invites the customer to see results that do not exist yet", () => { + // done, but nothing produced a payload + expect(buildCustomerLoopView(input({ progress: progress({ done: true, status: "converged" }) })).resultsReady).toBe(false); + // a payload exists, but the loop is not done + expect(buildCustomerLoopView(input({ results: results() })).resultsReady).toBe(false); + }); + + it("is ready once the loop is done and a payload exists", () => { + const payload = results(); + const view = buildCustomerLoopView(input({ progress: progress({ done: true, status: "converged" }), results: payload })); + expect(view.results).toEqual(payload); + expect(view.resultsReady).toBe(true); + }); + + it("an explicit null payload is treated the same as an absent one", () => { + const view = buildCustomerLoopView(input({ progress: progress({ done: true }), results: null })); + expect(view.results).toBeNull(); + expect(view.resultsReady).toBe(false); + }); + }); +});