static-fire 0.0.0-stage → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +230 -2
- package/bin/static-fire.js +46 -0
- package/package.json +29 -4
- package/pricing.yaml +20 -0
- package/src/adapters/index.ts +153 -0
- package/src/api.ts +218 -0
- package/src/cli.ts +297 -0
- package/src/core/apkit-install.ts +47 -0
- package/src/core/authored.ts +47 -0
- package/src/core/batch-state.ts +25 -0
- package/src/core/batch.ts +435 -0
- package/src/core/batches.ts +221 -0
- package/src/core/checks.ts +63 -0
- package/src/core/commands.ts +42 -0
- package/src/core/compare.ts +203 -0
- package/src/core/config.ts +25 -0
- package/src/core/copying.ts +18 -0
- package/src/core/db.ts +572 -0
- package/src/core/derived.ts +74 -0
- package/src/core/engine.ts +85 -0
- package/src/core/facets.ts +128 -0
- package/src/core/grade.ts +197 -0
- package/src/core/grading-versions.ts +281 -0
- package/src/core/identity.ts +263 -0
- package/src/core/init.ts +231 -0
- package/src/core/judge-schema.ts +37 -0
- package/src/core/judge.ts +166 -0
- package/src/core/label.ts +43 -0
- package/src/core/lock.ts +40 -0
- package/src/core/migrate.ts +288 -0
- package/src/core/new-task.ts +85 -0
- package/src/core/outside-tools.ts +129 -0
- package/src/core/profiles.ts +42 -0
- package/src/core/project.ts +54 -0
- package/src/core/regrade.ts +233 -0
- package/src/core/score.ts +148 -0
- package/src/core/settings.ts +44 -0
- package/src/core/task-file.ts +95 -0
- package/src/core/trial-detail.ts +73 -0
- package/src/core/types.ts +195 -0
- package/src/core/view-query.ts +85 -0
- package/src/core/view.ts +498 -0
- package/src/guide.md +547 -0
- package/src/server.ts +24 -0
- package/web/api.ts +6 -0
- package/web/app.tsx +1019 -0
- package/web/brand/offset-tokens.css +126 -0
- package/web/charts.tsx +190 -0
- package/web/compare.tsx +317 -0
- package/web/encoding.tsx +55 -0
- package/web/filter-menu.ts +164 -0
- package/web/index.html +17 -0
- package/web/pages.ts +16 -0
- package/web/run-status.ts +51 -0
- package/web/styles.css +333 -0
- package/web/suite-notes.ts +20 -0
- package/web/wording.ts +38 -0
package/web/encoding.tsx
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// One visual encoding for every chart and table (Offset dataviz tokens, web/brand/offset-tokens.css):
|
|
2
|
+
// shape = harness (circle Claude Code, square Codex, diamond Antigravity, triangle Pi, star by hand)
|
|
3
|
+
// colour = model family (fixed slot per family, so a colour never moves between views; ink = a person)
|
|
4
|
+
// fill = apkit Profile (filled = with Profile, hollow = none; a person is always filled)
|
|
5
|
+
// Every mark keeps Offset's 2px ink outline; text is always ink, never the series colour.
|
|
6
|
+
import type { ConfigSummary, Harness } from "../src/core/types";
|
|
7
|
+
export { HARNESS_NAME } from "../src/core/label";
|
|
8
|
+
export const HARNESS_ORDER: Harness[] = ["claude", "codex", "agy", "pi", "human"];
|
|
9
|
+
|
|
10
|
+
const FAMILY_SLOT: Record<string, number> = {
|
|
11
|
+
"claude-opus": 3, "claude-sonnet": 4, "claude-haiku": 5,
|
|
12
|
+
"gpt-sol": 7, "gpt-luna": 6, "gemini-argon": 2, "gemini-flash": 1,
|
|
13
|
+
};
|
|
14
|
+
const FAMILY_NAME: Record<string, string> = {
|
|
15
|
+
"claude-opus": "Opus", "claude-sonnet": "Sonnet", "claude-haiku": "Haiku",
|
|
16
|
+
"gpt-sol": "GPT Sol", "gpt-luna": "GPT Luna", "gemini-argon": "Gemini Argon", "gemini-flash": "Gemini Flash", human: "Person",
|
|
17
|
+
};
|
|
18
|
+
export const familyName = (f: string) => FAMILY_NAME[f] ?? f;
|
|
19
|
+
export const familySlot = (f: string) => FAMILY_SLOT[f] ?? 1;
|
|
20
|
+
export const fill = (f: string) => (f === "human" ? "var(--color-ink)" : `var(--dataviz-${familySlot(f)})`);
|
|
21
|
+
export const strong = (f: string) => (f === "human" ? "var(--color-ink)" : `var(--dataviz-${familySlot(f)}-strong)`);
|
|
22
|
+
|
|
23
|
+
/** SVG path for a marker centred on (0,0) with radius r. */
|
|
24
|
+
export function shapePath(h: Harness, r: number) {
|
|
25
|
+
switch (h) {
|
|
26
|
+
case "codex": { const s = r * 0.9; return `M${-s},${-s}H${s}V${s}H${-s}Z`; }
|
|
27
|
+
case "agy": { const s = r * 1.2; return `M0,${-s}L${s},0L0,${s}L${-s},0Z`; }
|
|
28
|
+
case "pi": { const s = r * 1.15; return `M0,${-s}L${s},${s * 0.8}L${-s},${s * 0.8}Z`; }
|
|
29
|
+
case "human": {
|
|
30
|
+
const R = r * 1.3, ri = R * 0.45;
|
|
31
|
+
const pts = Array.from({ length: 10 }, (_, i) => { const a = -Math.PI / 2 + (i * Math.PI) / 5, d = i % 2 ? ri : R; return `${(d * Math.cos(a)).toFixed(2)},${(d * Math.sin(a)).toFixed(2)}`; });
|
|
32
|
+
return `M${pts.join("L")}Z`;
|
|
33
|
+
}
|
|
34
|
+
default: return `M${r},0A${r},${r} 0 1,1 ${-r},0A${r},${r} 0 1,1 ${r},0Z`;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type MarkerLike = Pick<ConfigSummary, "harness" | "profile" | "family">;
|
|
39
|
+
|
|
40
|
+
export function markerProps(s: MarkerLike) {
|
|
41
|
+
const filled = s.profile || s.harness === "human";
|
|
42
|
+
return { fill: filled ? fill(s.family) : "var(--color-white)", stroke: "var(--outline)", strokeWidth: 2 };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A standalone marker for legends, tables and lists. `neutral` shows the shape (or hollowness) only. */
|
|
46
|
+
export function Marker({ s, size = 14, neutral }: { s: MarkerLike; size?: number; neutral?: boolean }) {
|
|
47
|
+
const r = size / 2 - 2;
|
|
48
|
+
const dot = neutral ? "var(--fg-3)" : fill(s.family);
|
|
49
|
+
return (
|
|
50
|
+
<svg width={size} height={size} viewBox={`${-size / 2} ${-size / 2} ${size} ${size}`} aria-hidden className="marker">
|
|
51
|
+
<path d={shapePath(s.harness, r)} {...markerProps(s)} {...(neutral && s.profile ? { fill: "var(--color-cream)" } : {})} />
|
|
52
|
+
{!s.profile && s.harness !== "human" && <path d={shapePath(s.harness, r * 0.45)} fill={dot} stroke="none" />}
|
|
53
|
+
</svg>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// The Results page's Filter menu and filter tags (#26), as plain data: which sections and values the menu lists,
|
|
2
|
+
// what picking a value sets in the view query, what the search lists and which tags show. The page only draws it.
|
|
3
|
+
// A config facet (Harness, Model, Effort, Profile) takes several values (#31): a pick adds or removes one value and
|
|
4
|
+
// each picked value is its own tag. Judge, Grading version and Date range take one value: a pick replaces it.
|
|
5
|
+
import type { Facets, FacetValue } from "../src/core/facets";
|
|
6
|
+
import { HARNESS_NAME } from "../src/core/label";
|
|
7
|
+
import { CONFIG_FACETS, valueSet } from "../src/core/view-query";
|
|
8
|
+
import type { ConfigFacet, ViewQuery } from "../src/core/view-query";
|
|
9
|
+
|
|
10
|
+
export type SectionId = "harness" | "model" | "effort" | "profile" | "judge" | "grading" | "dates";
|
|
11
|
+
export const SECTION_NAMES: Record<SectionId, string> = {
|
|
12
|
+
harness: "Harness", model: "Model", effort: "Effort", profile: "Profile", judge: "Judge", grading: "Grading version", dates: "Date range",
|
|
13
|
+
};
|
|
14
|
+
export const EFFORT_NONE = "No effort setting", PROFILE_NONE = "No Profile (user setup only)";
|
|
15
|
+
|
|
16
|
+
type Patch = Partial<ViewQuery>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One value the menu lists. Picking it sets `set`; picking it again when it is picked sets `clear`. In a config
|
|
20
|
+
* section, `set` adds the value to the picked ones and `clear` removes only it.
|
|
21
|
+
*/
|
|
22
|
+
export interface MenuValue {
|
|
23
|
+
section: SectionId; sectionName: string; key: string; label: string;
|
|
24
|
+
/** A second line: a grading version's date, judge and current mark. */
|
|
25
|
+
detail?: string;
|
|
26
|
+
/** The task a grading version belongs to, when more than one task is in scope. */
|
|
27
|
+
group?: string;
|
|
28
|
+
/** A title for the value, for example a grading version's full hash. */
|
|
29
|
+
title?: string;
|
|
30
|
+
count: number; picked: boolean; current?: boolean;
|
|
31
|
+
set: Patch; clear: Patch;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MenuSection {
|
|
35
|
+
id: SectionId; name: string;
|
|
36
|
+
/** The picked value, "N picked" when there are more, or how many values there are. */
|
|
37
|
+
summary: string;
|
|
38
|
+
picked: boolean;
|
|
39
|
+
values: MenuValue[];
|
|
40
|
+
/** What removing this section's filter sets. */
|
|
41
|
+
remove: Patch;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface MenuContext {
|
|
45
|
+
facets: Facets | null; query: ViewQuery;
|
|
46
|
+
/** The view's judge: set by the judge filter, or by a picked grading version. */
|
|
47
|
+
judge: { id: string; label: string };
|
|
48
|
+
titles: Record<string, string>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const CLEAR_ALL: Patch = { judge: undefined, grading: undefined, harness: undefined, model: undefined, effort: undefined, profile: undefined, since: undefined, until: undefined };
|
|
52
|
+
|
|
53
|
+
const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? "" : "s"}`;
|
|
54
|
+
const short = (hash: string) => hash.slice(0, 8);
|
|
55
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
56
|
+
/** "2026-11-06" (a date filter, a calendar day) as "Nov 6, 2026", with no time zone shift. */
|
|
57
|
+
const dayText = (day: string) => {
|
|
58
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(day);
|
|
59
|
+
return m ? `${MONTHS[Number(m[2]) - 1]} ${Number(m[3])}, ${m[1]}` : day;
|
|
60
|
+
};
|
|
61
|
+
const shortDate = (iso: string) => new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
|
62
|
+
|
|
63
|
+
/** The date range filter in words, or null when it is not set. */
|
|
64
|
+
export function dateRangeText(since?: string, until?: string): string | null {
|
|
65
|
+
if (since && until) return `${dayText(since)} to ${dayText(until)}`;
|
|
66
|
+
if (since) return `from ${dayText(since)}`;
|
|
67
|
+
if (until) return `until ${dayText(until)}`;
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const NAMES: Partial<Record<SectionId, (v: string) => string>> = {
|
|
72
|
+
harness: (v) => HARNESS_NAME[v as keyof typeof HARNESS_NAME] ?? v,
|
|
73
|
+
effort: (v) => (v === "none" ? EFFORT_NONE : v),
|
|
74
|
+
profile: (v) => (v === "none" ? PROFILE_NONE : v),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function section(id: SectionId, values: MenuValue[], remove: Patch): MenuSection {
|
|
78
|
+
const on = values.filter((v) => v.picked);
|
|
79
|
+
const summary = on.length > 1 ? `${on.length} picked` : on[0]?.label ?? plural(values.length, "value");
|
|
80
|
+
return { id, name: SECTION_NAMES[id], summary, picked: on.length > 0, values, remove };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A config facet: its values with counts, and each URL value no trial has, listed with no trials. */
|
|
84
|
+
function facetSection(id: ConfigFacet, listed: FacetValue[], picked: string[]): MenuSection {
|
|
85
|
+
const all: FacetValue[] = [...listed, ...picked.filter((p) => !listed.some((v) => v.value === p)).map((value) => ({ value, count: 0 }))];
|
|
86
|
+
const name = NAMES[id] ?? ((v: string) => v);
|
|
87
|
+
const to = (values: string[]): Patch => { const v = valueSet(values); return { [id]: v.length ? v : undefined }; };
|
|
88
|
+
const values = all.map((v): MenuValue => ({
|
|
89
|
+
section: id, sectionName: SECTION_NAMES[id], key: v.value, label: v.label ?? name(v.value), count: v.count, picked: picked.includes(v.value),
|
|
90
|
+
set: to([...picked, v.value]), clear: to(picked.filter((p) => p !== v.value)),
|
|
91
|
+
}));
|
|
92
|
+
return section(id, values, { [id]: undefined });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Every section of the menu, in order, for the facets and the query in view. */
|
|
96
|
+
export function menuSections({ facets, query, judge, titles }: MenuContext): MenuSection[] {
|
|
97
|
+
const f = facets;
|
|
98
|
+
const config = CONFIG_FACETS.map((k) => facetSection(k, f?.[k] ?? [], query[k] ?? []));
|
|
99
|
+
|
|
100
|
+
// Judge: a picked grading version sets the view's judge, so that judge shows as picked.
|
|
101
|
+
const judgePicked = query.judge ?? (query.grading ? judge.id : undefined);
|
|
102
|
+
const judges = f?.judge ?? [];
|
|
103
|
+
const listedJudges = judges.some((j) => j.value === judge.id) ? judges : [...judges, { value: judge.id, label: judge.label, count: 0 }];
|
|
104
|
+
const judgeValues = listedJudges.map((j): MenuValue => ({
|
|
105
|
+
section: "judge", sectionName: SECTION_NAMES.judge, key: j.value, label: j.label ?? j.value, count: j.count, picked: j.value === judgePicked,
|
|
106
|
+
set: { judge: j.value, grading: undefined }, clear: { judge: undefined, grading: undefined },
|
|
107
|
+
}));
|
|
108
|
+
const judgeSection = section("judge", judgeValues, { judge: undefined, grading: undefined });
|
|
109
|
+
|
|
110
|
+
// Grading version: per task, its versions by name. Each judge's current version is marked. A current one clears
|
|
111
|
+
// the filter and sets its own judge: the judge in view (which may come only from the picked hash, so the pick
|
|
112
|
+
// keeps it), or another judge, whose current version that is.
|
|
113
|
+
// Read from the query, not the loaded view, so a tag goes the moment its filter does. A URL may hold a hash prefix.
|
|
114
|
+
const isPicked = (hash: string) => !!query.grading && hash.startsWith(query.grading);
|
|
115
|
+
const groups = f?.grading ?? [];
|
|
116
|
+
const gradingValues = groups.flatMap((g) => {
|
|
117
|
+
const judges = new Set(g.setups.map((s) => s.judgeLabel)).size > 1;
|
|
118
|
+
return g.setups.map((s): MenuValue => ({
|
|
119
|
+
section: "grading", sectionName: SECTION_NAMES.grading, key: s.hash, label: s.name,
|
|
120
|
+
detail: [shortDate(s.firstSeen), judges ? `judge ${s.judgeLabel}` : "", s.current ? "current" : ""].filter(Boolean).join(" · "),
|
|
121
|
+
group: groups.length > 1 ? titles[g.taskId] ?? g.taskId : undefined,
|
|
122
|
+
title: `Grading hash ${s.hash}`,
|
|
123
|
+
count: s.count, picked: isPicked(s.hash), current: s.current,
|
|
124
|
+
set: s.current ? { grading: undefined, judge: s.judgeId === judge.id ? judgePicked : s.judgeId ?? undefined } : { grading: s.hash, judge: undefined },
|
|
125
|
+
clear: { grading: undefined },
|
|
126
|
+
}));
|
|
127
|
+
});
|
|
128
|
+
if (query.grading && !gradingValues.some((v) => v.picked)) // a URL hash no listed version has
|
|
129
|
+
gradingValues.push({ section: "grading", sectionName: SECTION_NAMES.grading, key: query.grading, label: short(query.grading), title: `Grading hash ${query.grading}`, count: 0, picked: true, set: {}, clear: { grading: undefined } });
|
|
130
|
+
const gradingSection = section("grading", gradingValues, { grading: undefined });
|
|
131
|
+
|
|
132
|
+
// Date range: a pair of dates, not a list of values.
|
|
133
|
+
const range = dateRangeText(query.since, query.until);
|
|
134
|
+
const dates: MenuSection = { id: "dates", name: SECTION_NAMES.dates, summary: range ?? "Any date", picked: !!range, values: [], remove: { since: undefined, until: undefined } };
|
|
135
|
+
|
|
136
|
+
return [...config, judgeSection, gradingSection, dates];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** What picking `v` sets: the value, or without it when it is already picked. */
|
|
140
|
+
export const pick = (v: MenuValue): Patch => (v.picked ? v.clear : v.set);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The search: every value of every section whose section name, name, task or detail line (a grading version's
|
|
144
|
+
* judge) matches the text (case ignored), as one flat list; and whether the date range matches, so it shows as one entry.
|
|
145
|
+
*/
|
|
146
|
+
export function searchMenu(sections: MenuSection[], text: string): { values: MenuValue[]; dates: boolean } {
|
|
147
|
+
const q = text.trim().toLowerCase();
|
|
148
|
+
if (!q) return { values: [], dates: false };
|
|
149
|
+
const values = sections.flatMap((s) => s.values).filter((v) => `${v.sectionName} ${v.label} ${v.group ?? ""} ${v.detail ?? ""}`.toLowerCase().includes(q));
|
|
150
|
+
return { values, dates: SECTION_NAMES.dates.toLowerCase().includes(q) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface FilterTag { section: SectionId; /** Unique among the tags. */ key: string; name: string; value: string; text: string; remove: Patch }
|
|
154
|
+
|
|
155
|
+
const CONFIG_SECTIONS = new Set<SectionId>(CONFIG_FACETS);
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* One tag per active filter, reading "<section>: <value>": one per picked value of a config section, whose
|
|
159
|
+
* remove control removes only that value, and one for each other section with a pick.
|
|
160
|
+
*/
|
|
161
|
+
export const filterTags = (sections: MenuSection[]): FilterTag[] =>
|
|
162
|
+
sections.filter((s) => s.picked).flatMap((s): FilterTag[] => CONFIG_SECTIONS.has(s.id)
|
|
163
|
+
? s.values.filter((v) => v.picked).map((v) => ({ section: s.id, key: `${s.id}:${v.key}`, name: s.name, value: v.label, text: `${s.name}: ${v.label}`, remove: v.clear }))
|
|
164
|
+
: [{ section: s.id, key: s.id, name: s.name, value: s.summary, text: `${s.name}: ${s.summary}`, remove: s.remove }]);
|
package/web/index.html
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Static Fire</title>
|
|
7
|
+
<link rel="icon" href="data:," />
|
|
8
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
9
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
10
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400..900&family=IBM+Plex+Mono:wght@400;500;600&display=swap" />
|
|
11
|
+
<link rel="stylesheet" href="./styles.css" />
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div id="root"></div>
|
|
15
|
+
<script type="module" src="./app.tsx"></script>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
package/web/pages.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The dashboard's pages: client-side routes of the one page the server serves (DEC-006 of #21). The server
|
|
2
|
+
// serves index.html on each path; the page picks what to show from the path. A new page is one entry here
|
|
3
|
+
// and its render branch in app.tsx. The URL query belongs to the Results page (its filters), never to another.
|
|
4
|
+
export const PAGES = [
|
|
5
|
+
{ id: "results", path: "/", title: "Results" },
|
|
6
|
+
{ id: "runs", path: "/runs", title: "Runs" },
|
|
7
|
+
{ id: "compare", path: "/compare", title: "Compare" },
|
|
8
|
+
] as const;
|
|
9
|
+
|
|
10
|
+
export type PageId = (typeof PAGES)[number]["id"];
|
|
11
|
+
|
|
12
|
+
/** The page a path shows. A trailing slash is ignored; the server serves only the paths above. */
|
|
13
|
+
export const pageOf = (pathname: string): PageId =>
|
|
14
|
+
PAGES.find((p) => p.path === (pathname.replace(/\/+$/, "") || "/"))?.id ?? "results";
|
|
15
|
+
|
|
16
|
+
export const pathOf = (id: PageId): string => PAGES.find((p) => p.id === id)!.path;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The run status indicator in the top bar (US-005 of #21): what GET /api/batches says, in one line. It uses the
|
|
2
|
+
// server's batch state (src/core/batch-state.ts) as the Runs page does, so a batch that is not ended and may have
|
|
3
|
+
// stopped ("stalled") is never counted as running. Reads only the batch route (DEC-008).
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How often the dashboard reads the batch route again with no database change. A running batch of another process
|
|
7
|
+
* reads as stalled once 25 minutes pass with no trial movement, which writes nothing, so no event announces it.
|
|
8
|
+
*/
|
|
9
|
+
export const STATUS_REFRESH_MS = 60_000;
|
|
10
|
+
|
|
11
|
+
/** The fields of a GET /api/batches row that the indicator reads. */
|
|
12
|
+
export interface StatusBatch {
|
|
13
|
+
batch: { endedAt: string | null };
|
|
14
|
+
counts: Record<"queued" | "running" | "done" | "failed" | "invalid", number>;
|
|
15
|
+
total: number;
|
|
16
|
+
state: "running" | "stalled" | "ended";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type RunStatus =
|
|
20
|
+
| { kind: "none" }
|
|
21
|
+
/** Nothing running. lastEndedAt is null when no batch has ended. */
|
|
22
|
+
| { kind: "idle"; lastEndedAt: string | null; stalled: number }
|
|
23
|
+
/** runs: running batches. ended / total: their trials that ended (done, failed or invalid) of those asked for. */
|
|
24
|
+
| { kind: "running"; runs: number; ended: number; total: number; stalled: number }
|
|
25
|
+
/** The batch route failed; error is its message. */
|
|
26
|
+
| { kind: "unavailable"; error: string };
|
|
27
|
+
|
|
28
|
+
export function runStatus(rows: StatusBatch[]): RunStatus {
|
|
29
|
+
if (!rows.length) return { kind: "none" };
|
|
30
|
+
const stalled = rows.filter((r) => r.state === "stalled").length;
|
|
31
|
+
const running = rows.filter((r) => r.state === "running");
|
|
32
|
+
if (running.length) {
|
|
33
|
+
let ended = 0, total = 0;
|
|
34
|
+
for (const r of running) { ended += r.counts.done + r.counts.failed + r.counts.invalid; total += r.total; }
|
|
35
|
+
return { kind: "running", runs: running.length, ended, total, stalled };
|
|
36
|
+
}
|
|
37
|
+
let lastEndedAt: string | null = null;
|
|
38
|
+
for (const r of rows) if (r.state === "ended" && r.batch.endedAt && (!lastEndedAt || r.batch.endedAt > lastEndedAt)) lastEndedAt = r.batch.endedAt;
|
|
39
|
+
return { kind: "idle", lastEndedAt, stalled };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const runs = (n: number) => `${n} run${n === 1 ? "" : "s"}`;
|
|
43
|
+
|
|
44
|
+
/** The indicator's words. The progress figure of a running state ("2 of 6") is drawn apart, next to its bar. */
|
|
45
|
+
export function statusText(s: RunStatus, at: (iso: string) => string): string {
|
|
46
|
+
if (s.kind === "none") return "No runs yet";
|
|
47
|
+
if (s.kind === "unavailable") return `Run status unavailable: ${s.error}`;
|
|
48
|
+
if (s.kind === "running") return `${runs(s.runs)} in progress${s.stalled ? ` · ${runs(s.stalled)} may have stopped` : ""}`;
|
|
49
|
+
return ["No runs in progress", s.lastEndedAt ? `last ended ${at(s.lastEndedAt)}` : "", s.stalled ? `${runs(s.stalled)} not ended, may have stopped` : ""]
|
|
50
|
+
.filter(Boolean).join(" · ");
|
|
51
|
+
}
|