Skip to content
Merged
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
101 changes: 101 additions & 0 deletions packages/loopover-engine/src/customer-loop-view.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
8 changes: 8 additions & 0 deletions packages/loopover-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
139 changes: 139 additions & 0 deletions test/unit/customer-loop-view.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): ProgressSnapshot => ({
phase: "coding",
status: "running",
iteration: 2,
maxIterations: 5,
percentComplete: 40,
recentActivity: [{ step: "ran tests" }],
done: false,
...over,
});

const entry = (over: Partial<LoopConsumptionEntry> = {}): 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> = {}): 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);
});
});
});