@nanocollective/roster 0.1.0-alpha.2 → 0.1.0-alpha.21
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/README.md +70 -84
- package/dist/cli.js +4775 -2495
- package/docs/README.md +19 -11
- package/docs/agents.md +328 -13
- package/docs/architecture.md +13 -5
- package/docs/charters/cmo.md +69 -0
- package/docs/charters/cto.md +71 -0
- package/docs/charters/support.md +60 -0
- package/docs/commands.md +95 -11
- package/docs/concepts.md +64 -12
- package/docs/cost.md +39 -3
- package/docs/developing.md +16 -21
- package/docs/doctor-codes.md +21 -6
- package/docs/export.md +2 -1
- package/docs/extending.md +13 -4
- package/docs/getting-started.md +121 -80
- package/docs/images/brain.jpg +0 -0
- package/docs/images/org.jpg +0 -0
- package/docs/images/prompt.jpg +0 -0
- package/docs/images/setup-org.jpg +0 -0
- package/docs/images/setup-plan.jpg +0 -0
- package/docs/images/staff.jpg +0 -0
- package/docs/manual-steps.md +94 -101
- package/docs/memory.md +29 -8
- package/docs/org-yaml.md +76 -11
- package/docs/portal.md +261 -47
- package/docs/prompts.md +77 -11
- package/docs/security.md +51 -7
- package/docs/session-workflow.md +51 -21
- package/docs/staff-yaml.md +16 -7
- package/docs/troubleshooting.md +23 -20
- package/docs/upgrading.md +9 -3
- package/docs/writing-a-charter.md +33 -17
- package/package.json +1 -1
- package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +7 -0
- package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +16 -4
- package/templates/brain/CHARTER.md +3 -3
- package/templates/brain/README.md +1 -0
- package/templates/brain/log/decisions.md +3 -0
- package/templates/brain/staff.yaml +0 -1
- package/templates/brain/strategy/ideas.md +7 -0
- package/templates/ops/.github/workflows/session.yaml +117 -40
- package/templates/ops/agents.mjs +127 -8
- package/templates/ops/compose.mjs +77 -7
- package/templates/ops/inflight.mjs +157 -0
- package/templates/ops/org/operating.md +21 -7
- package/templates/ops/org/voice.md +9 -0
- package/templates/ops/prompts/_identity.md +8 -1
- package/templates/ops/prompts/_inflight.md +14 -0
- package/templates/ops/prompts/_paths.md +2 -1
- package/templates/ops/prompts/daily.md +16 -7
- package/templates/ops/prompts/mention.md +18 -2
- package/templates/ops/run-record.mjs +144 -0
- package/templates/portal/css/base.css +238 -64
- package/templates/portal/css/brain.css +30 -20
- package/templates/portal/css/diff.css +15 -10
- package/templates/portal/css/graph.css +12 -7
- package/templates/portal/css/health.css +32 -11
- package/templates/portal/css/inbox.css +117 -14
- package/templates/portal/css/layout.css +93 -41
- package/templates/portal/css/markdown.css +57 -15
- package/templates/portal/css/runs.css +13 -0
- package/templates/portal/css/setup.css +83 -39
- package/templates/portal/index.html +24 -3
- package/templates/portal/js/api.js +65 -4
- package/templates/portal/js/app.js +112 -12
- package/templates/portal/js/dialog.js +94 -4
- package/templates/portal/js/dom.js +25 -0
- package/templates/portal/js/icons.js +8 -1
- package/templates/portal/js/lightbox.js +273 -0
- package/templates/portal/js/md.js +23 -6
- package/templates/portal/js/mdedit.js +84 -0
- package/templates/portal/js/mention.js +264 -0
- package/templates/portal/js/refresh.js +136 -6
- package/templates/portal/js/state.js +55 -8
- package/templates/portal/js/views/app.js +23 -5
- package/templates/portal/js/views/checklist.js +29 -10
- package/templates/portal/js/views/credential.js +98 -0
- package/templates/portal/js/views/docs.js +94 -4
- package/templates/portal/js/views/files.js +58 -14
- package/templates/portal/js/views/graph.js +1 -1
- package/templates/portal/js/views/health.js +178 -37
- package/templates/portal/js/views/inbox.js +938 -98
- package/templates/portal/js/views/memory.js +16 -1
- package/templates/portal/js/views/org.js +124 -104
- package/templates/portal/js/views/orgedit.js +213 -0
- package/templates/portal/js/views/paste.js +33 -7
- package/templates/portal/js/views/prompt.js +61 -67
- package/templates/portal/js/views/repos.js +20 -15
- package/templates/portal/js/views/runonce.js +94 -0
- package/templates/portal/js/views/runs.js +165 -0
- package/templates/portal/js/views/setup.js +311 -83
- package/templates/portal/js/views/staff.js +139 -22
- package/templates/portal/js/yaml.js +134 -0
- package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +0 -50
- package/templates/ops/prompts/pr-mention.md +0 -57
|
@@ -9,11 +9,13 @@
|
|
|
9
9
|
* changing a byte of the composed text, and nothing said so before.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { highlighted } from "../mdedit.js";
|
|
12
13
|
import { getFile, post } from "../api.js";
|
|
13
|
-
import { askText } from "../dialog.js";
|
|
14
|
+
import { askText, askYes } from "../dialog.js";
|
|
14
15
|
import { el, esc, grow, kb, toClipboard } from "../dom.js";
|
|
15
16
|
import { icon } from "../icons.js";
|
|
16
17
|
import { mdlite } from "../md.js";
|
|
18
|
+
import { render } from "../router.js";
|
|
17
19
|
import { S, staff, writeHash } from "../state.js";
|
|
18
20
|
import { diffStat, unifiedDiff } from "../textdiff.js";
|
|
19
21
|
import { renderDiff } from "./changed.js";
|
|
@@ -21,9 +23,53 @@ import { renderDiff } from "./changed.js";
|
|
|
21
23
|
const KINDS = [
|
|
22
24
|
["daily", "Daily", "the scheduled run"],
|
|
23
25
|
["mention", "Mention", "someone typed @them in an issue"],
|
|
24
|
-
["pr-mention", "PR mention", "someone typed @them on a pull request"],
|
|
25
26
|
];
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Build the paste-ready brief on the server, where compose.mjs lives, and copy it.
|
|
30
|
+
*
|
|
31
|
+
* A finding pre-fills the box rather than skipping it: what it wrote is a starting point, and
|
|
32
|
+
* "and keep it in operating.md" is exactly the sort of thing you want to add.
|
|
33
|
+
*
|
|
34
|
+
* Exported because the findings themselves live on Health now, and the fix for one of them is
|
|
35
|
+
* the same brief whichever screen you asked from.
|
|
36
|
+
*/
|
|
37
|
+
export async function copyAmendBrief(handle, kind, want, btn, note) {
|
|
38
|
+
const label = btn.textContent;
|
|
39
|
+
const asked = await askText({
|
|
40
|
+
title: "What do you want changed?",
|
|
41
|
+
hint:
|
|
42
|
+
"This goes at the top of a brief carrying the whole prompt and every file it is " +
|
|
43
|
+
"made of. Say it the way you would say it to a person.",
|
|
44
|
+
value: want,
|
|
45
|
+
placeholder: "stop opening decision issues for anything reversible",
|
|
46
|
+
confirm: "Copy the brief",
|
|
47
|
+
});
|
|
48
|
+
if (!asked) return;
|
|
49
|
+
btn.disabled = true;
|
|
50
|
+
btn.textContent = "building…";
|
|
51
|
+
try {
|
|
52
|
+
const url =
|
|
53
|
+
"/api/amend?staff=" + encodeURIComponent(handle) +
|
|
54
|
+
"&kind=" + encodeURIComponent(kind) +
|
|
55
|
+
"&want=" + encodeURIComponent(asked);
|
|
56
|
+
const text = await (await fetch(url, { cache: "no-store" })).text();
|
|
57
|
+
btn.disabled = false;
|
|
58
|
+
toClipboard(text, btn, label);
|
|
59
|
+
if (note) {
|
|
60
|
+
note.textContent = "paste it into whatever agent you use";
|
|
61
|
+
note.className = "meta";
|
|
62
|
+
}
|
|
63
|
+
} catch (e) {
|
|
64
|
+
btn.disabled = false;
|
|
65
|
+
btn.textContent = label;
|
|
66
|
+
if (note) {
|
|
67
|
+
note.textContent = e.message;
|
|
68
|
+
note.className = "meta err";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
27
73
|
export function viewPrompt(m) {
|
|
28
74
|
const s = staff();
|
|
29
75
|
if (!S.promptKind) S.promptKind = "daily";
|
|
@@ -54,40 +100,7 @@ export function viewPrompt(m) {
|
|
|
54
100
|
const note = el("span", { className: "meta" });
|
|
55
101
|
m.append(el("div", { className: "row", style: "margin-bottom:16px" }, [kind, help, copy, note]));
|
|
56
102
|
|
|
57
|
-
|
|
58
|
-
A finding pre-fills the box rather than skipping it: what it wrote is a starting point,
|
|
59
|
-
and "and keep it in operating.md" is exactly the sort of thing you want to add. */
|
|
60
|
-
async function copyAmend(want, btn) {
|
|
61
|
-
const target = btn ?? help;
|
|
62
|
-
const label = target.textContent;
|
|
63
|
-
const asked = await askText({
|
|
64
|
-
title: "What do you want changed?",
|
|
65
|
-
hint:
|
|
66
|
-
"This goes at the top of a brief carrying the whole prompt and every file it is " +
|
|
67
|
-
"made of. Say it the way you would say it to a person.",
|
|
68
|
-
value: want,
|
|
69
|
-
placeholder: "stop opening decision issues for anything reversible",
|
|
70
|
-
confirm: "Copy the brief",
|
|
71
|
-
});
|
|
72
|
-
if (!asked) return;
|
|
73
|
-
target.disabled = true;
|
|
74
|
-
target.textContent = "building…";
|
|
75
|
-
try {
|
|
76
|
-
const url =
|
|
77
|
-
"/api/amend?staff=" + encodeURIComponent(s.handle) +
|
|
78
|
-
"&kind=" + encodeURIComponent(S.promptKind) +
|
|
79
|
-
"&want=" + encodeURIComponent(asked);
|
|
80
|
-
const text = await (await fetch(url, { cache: "no-store" })).text();
|
|
81
|
-
target.disabled = false;
|
|
82
|
-
toClipboard(text, target, label);
|
|
83
|
-
note.textContent = "paste it into whatever agent you use";
|
|
84
|
-
} catch (e) {
|
|
85
|
-
target.disabled = false;
|
|
86
|
-
target.textContent = label;
|
|
87
|
-
note.textContent = e.message;
|
|
88
|
-
note.className = "meta err";
|
|
89
|
-
}
|
|
90
|
-
}
|
|
103
|
+
const copyAmend = (want, btn) => copyAmendBrief(s.handle, S.promptKind, want, btn ?? help, note);
|
|
91
104
|
|
|
92
105
|
const split = el("div", { className: "split" });
|
|
93
106
|
const tree = el("div", { className: "tree" });
|
|
@@ -140,12 +153,9 @@ export function viewPrompt(m) {
|
|
|
140
153
|
for (const l of view.layers) inlined.append(layerRow(l));
|
|
141
154
|
tree.append(inlined);
|
|
142
155
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
tree.append(bad);
|
|
147
|
-
}
|
|
148
|
-
|
|
156
|
+
/* No Problems section. This screen answers "what is sent"; whether what is sent is any
|
|
157
|
+
good is a health question, and it is asked and answered on Health. Having both here
|
|
158
|
+
made the tree beside the prompt half prompt and half complaints. */
|
|
149
159
|
const named = group("Named, not inlined");
|
|
150
160
|
named.append(
|
|
151
161
|
el("p", {
|
|
@@ -160,28 +170,6 @@ export function viewPrompt(m) {
|
|
|
160
170
|
open(S.promptOpen ?? "composed");
|
|
161
171
|
}
|
|
162
172
|
|
|
163
|
-
/* A finding is only half useful. The other half is the sentence that goes into the brief,
|
|
164
|
-
which is why every one of them carries a `want`. */
|
|
165
|
-
function problemRow(p) {
|
|
166
|
-
const b = el("div", { className: "prob " + p.level });
|
|
167
|
-
b.append(el("div", { className: "ptitle", textContent: p.title }));
|
|
168
|
-
b.append(el("div", { className: "pdetail", textContent: p.detail }));
|
|
169
|
-
const fix = el("button", { className: "ghbtn", textContent: "Copy a prompt to fix this" });
|
|
170
|
-
fix.onclick = () => copyAmend(p.want, fix);
|
|
171
|
-
const row = el("div", { className: "row", style: "margin-top:8px" }, [fix]);
|
|
172
|
-
if (p.path) {
|
|
173
|
-
row.append(
|
|
174
|
-
el("button", {
|
|
175
|
-
className: "ghbtn",
|
|
176
|
-
textContent: "Open the file",
|
|
177
|
-
onclick: () => open(p.path),
|
|
178
|
-
}),
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
b.append(row);
|
|
182
|
-
return b;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
173
|
function group(title, right) {
|
|
186
174
|
const g = el("div", { className: "navgroup" });
|
|
187
175
|
const h = el("div", { className: "ghead", textContent: title });
|
|
@@ -364,8 +352,10 @@ export function viewPrompt(m) {
|
|
|
364
352
|
);
|
|
365
353
|
|
|
366
354
|
const ta = el("textarea", { value: text, className: "editor" });
|
|
367
|
-
|
|
355
|
+
const hl = highlighted(ta, "md");
|
|
356
|
+
viewer.append(hl);
|
|
368
357
|
grow(ta);
|
|
358
|
+
hl.repaint();
|
|
369
359
|
ta.addEventListener("input", () => grow(ta));
|
|
370
360
|
|
|
371
361
|
const status = el("span", { className: "meta" });
|
|
@@ -388,8 +378,12 @@ export function viewPrompt(m) {
|
|
|
388
378
|
status.textContent = "nothing changed";
|
|
389
379
|
return;
|
|
390
380
|
}
|
|
391
|
-
|
|
392
|
-
|
|
381
|
+
const yes = await askYes({
|
|
382
|
+
title: "Commit " + layer.path + " to " + layer.repo + " and push?",
|
|
383
|
+
hint: "Picked up by " + reach + ".",
|
|
384
|
+
confirm: "Commit and push",
|
|
385
|
+
});
|
|
386
|
+
if (!yes) return;
|
|
393
387
|
save.disabled = cancel.disabled = true;
|
|
394
388
|
status.textContent = "committing…";
|
|
395
389
|
status.className = "meta";
|
|
@@ -5,31 +5,36 @@
|
|
|
5
5
|
* spelling a repo name exactly right in a YAML file, which is the kind of thing that fails
|
|
6
6
|
* quietly and reads as the tool being broken. */
|
|
7
7
|
|
|
8
|
-
import { addRepo, listRepos } from "../api.js";
|
|
8
|
+
import { addRepo, getRepos, listRepos } from "../api.js";
|
|
9
9
|
import { el } from "../dom.js";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* @param {{org: string, known
|
|
12
|
+
* @param {{org: string, known?: string[], onAdded?: () => void}} opts without `known`, what
|
|
13
|
+
* org.yaml already lists is read off disk, which is how the Org screen uses it long after setup.
|
|
13
14
|
*/
|
|
14
15
|
export function repoPicker(opts) {
|
|
15
|
-
const box = el("div", { className: "manual" });
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
16
|
+
const box = el("div", { className: opts.bare ? "" : "manual" });
|
|
17
|
+
if (!opts.bare) {
|
|
18
|
+
box.append(
|
|
19
|
+
el("b", { textContent: "Which repos the staff work in" }),
|
|
20
|
+
el("p", {
|
|
21
|
+
textContent:
|
|
22
|
+
"Add the repos your staff should work on. New hires get access to them automatically.",
|
|
23
|
+
}),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
24
26
|
|
|
25
27
|
const out = el("div", { style: "margin-top:9px" });
|
|
26
28
|
box.append(out);
|
|
27
29
|
out.replaceChildren(el("p", { className: "sub", textContent: "Reading your repos…" }));
|
|
28
30
|
|
|
29
|
-
|
|
30
|
-
.
|
|
31
|
+
const known = opts.known
|
|
32
|
+
? Promise.resolve(opts.known)
|
|
33
|
+
: getRepos().then((d) => (d.repos ?? []).map((r) => r.name));
|
|
34
|
+
Promise.all([listRepos(opts.org), known])
|
|
35
|
+
.then(([data, listed]) => {
|
|
31
36
|
if (data.error) throw new Error(data.error);
|
|
32
|
-
const already = new Set(
|
|
37
|
+
const already = new Set(listed);
|
|
33
38
|
const candidates = (data.repos ?? []).filter((r) => !already.has(r.name));
|
|
34
39
|
|
|
35
40
|
if (!candidates.length) {
|
|
@@ -52,7 +57,7 @@ export function repoPicker(opts) {
|
|
|
52
57
|
add.disabled = true;
|
|
53
58
|
add.textContent = "Adding…";
|
|
54
59
|
try {
|
|
55
|
-
await addRepo(repo.name, "product");
|
|
60
|
+
await addRepo(repo.name, "product", repo.visibility);
|
|
56
61
|
add.textContent = "Added";
|
|
57
62
|
row.classList.add("added");
|
|
58
63
|
opts.onAdded?.();
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/* One daily run, now, followed to the end.
|
|
2
|
+
*
|
|
3
|
+
* The last step of setting anybody up. Doctor calls a workflow that has never run unproven,
|
|
4
|
+
* because nothing short of a finished run shows the App, its grant, the secrets and the
|
|
5
|
+
* callers all work together. This starts one, and says how it ended. */
|
|
6
|
+
|
|
7
|
+
import { runStatus, startRun } from "../api.js";
|
|
8
|
+
import { askYes } from "../dialog.js";
|
|
9
|
+
import { el } from "../dom.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {{staff: string, name: string, onDone?: (ok: boolean) => void}} opts
|
|
13
|
+
*/
|
|
14
|
+
export function runOnce(opts) {
|
|
15
|
+
const box = el("div", { className: "manual" });
|
|
16
|
+
box.append(
|
|
17
|
+
el("b", { textContent: "Run " + opts.name + " once, now" }),
|
|
18
|
+
el("p", {
|
|
19
|
+
className: "sub",
|
|
20
|
+
textContent:
|
|
21
|
+
"A real daily run: it does a day's work and spends what a scheduled one would. " +
|
|
22
|
+
"When it finishes you get the outcome and the log.",
|
|
23
|
+
}),
|
|
24
|
+
);
|
|
25
|
+
const go = el("button", { className: "btn primary", textContent: "Run once now" });
|
|
26
|
+
const out = el("div", { style: "margin-top:9px" });
|
|
27
|
+
box.append(el("div", { className: "row" }, [go]), out);
|
|
28
|
+
|
|
29
|
+
go.onclick = async () => {
|
|
30
|
+
const yes = await askYes({
|
|
31
|
+
title: "Start a daily run for " + opts.name + " now?",
|
|
32
|
+
hint: "It is a real run, and it costs what one does.",
|
|
33
|
+
confirm: "Start the run",
|
|
34
|
+
});
|
|
35
|
+
if (!yes) return;
|
|
36
|
+
go.disabled = true;
|
|
37
|
+
const say = (text, cls) => out.replaceChildren(el("p", { className: cls ?? "sub", textContent: text }));
|
|
38
|
+
say("Starting…");
|
|
39
|
+
let run;
|
|
40
|
+
try {
|
|
41
|
+
run = await startRun(opts.staff);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
say(String(err.message || err), "err");
|
|
44
|
+
go.disabled = false;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const link = el("a", { href: run.url, target: "_blank", rel: "noopener", textContent: "the log" });
|
|
48
|
+
if (!run.id) {
|
|
49
|
+
out.replaceChildren(el("p", { className: "sub" }, ["Started. It has not shown up yet; follow it at ", link, "."]));
|
|
50
|
+
go.disabled = false;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* Polled rather than streamed: a run takes minutes, the status changes a handful of times,
|
|
55
|
+
and a clock beside it is what shows the page has not hung. */
|
|
56
|
+
const began = Date.now();
|
|
57
|
+
const tick = async () => {
|
|
58
|
+
let now;
|
|
59
|
+
try {
|
|
60
|
+
now = await runStatus(opts.staff, run.id);
|
|
61
|
+
if (now.error) throw new Error(now.error);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
say(String(err.message || err), "err");
|
|
64
|
+
go.disabled = false;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const mins = Math.floor((Date.now() - began) / 60000);
|
|
68
|
+
if (now.status !== "completed") {
|
|
69
|
+
out.replaceChildren(
|
|
70
|
+
el("p", { className: "sub" }, [now.status.replace("_", " ") + ", " + mins + " min so far. ", link]),
|
|
71
|
+
);
|
|
72
|
+
setTimeout(tick, 15000);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
go.disabled = false;
|
|
76
|
+
const ok = now.conclusion === "success";
|
|
77
|
+
const done = el("div");
|
|
78
|
+
done.append(
|
|
79
|
+
el("p", { className: ok ? "" : "err" }, [
|
|
80
|
+
ok
|
|
81
|
+
? "It worked. The App, its grant, the secrets and the callers are proven, and Health now says so. "
|
|
82
|
+
: "It ended " + (now.conclusion || "without a conclusion") + ". ",
|
|
83
|
+
el("a", { href: now.url, target: "_blank", rel: "noopener", textContent: "The log" }),
|
|
84
|
+
]),
|
|
85
|
+
);
|
|
86
|
+
for (const f of now.failedAt ?? []) done.append(el("p", { className: "sub", textContent: "Failed at " + f }));
|
|
87
|
+
if (!ok) done.append(el("p", { className: "sub", textContent: "Troubleshooting, in the docs, has what each failure usually means." }));
|
|
88
|
+
out.replaceChildren(done);
|
|
89
|
+
opts.onDone?.(ok);
|
|
90
|
+
};
|
|
91
|
+
tick();
|
|
92
|
+
};
|
|
93
|
+
return box;
|
|
94
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/* Every staff member's recent runs, and what the last 30 days cost.
|
|
2
|
+
*
|
|
3
|
+
* The one screen that is only ever on GitHub: runs are not in any repo, so offline it says so
|
|
4
|
+
* rather than drawing an empty table that reads as "nothing ran". */
|
|
5
|
+
|
|
6
|
+
import { getRuns } from "../api.js";
|
|
7
|
+
import { ago, el, esc, skeleton } from "../dom.js";
|
|
8
|
+
import { render } from "../router.js";
|
|
9
|
+
import { S } from "../state.js";
|
|
10
|
+
|
|
11
|
+
/** How many runs a staff member's table shows. The spend covers all of the last 30 days. */
|
|
12
|
+
const SHOWN = 15;
|
|
13
|
+
|
|
14
|
+
export function viewRuns(m) {
|
|
15
|
+
m.append(el("h1", { textContent: "Runs" }));
|
|
16
|
+
m.append(
|
|
17
|
+
el("p", {
|
|
18
|
+
className: "sub",
|
|
19
|
+
textContent:
|
|
20
|
+
"What each staff member ran in the last 30 days, how it ended, and what it cost where " +
|
|
21
|
+
"the agent said. Read from GitHub through your own gh.",
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
if (!S.runs) {
|
|
26
|
+
const wait = el("div", { className: "runs" }, skeleton("row", 4));
|
|
27
|
+
m.append(wait);
|
|
28
|
+
load(false);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
paint(m, S.runs);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let asking = false;
|
|
35
|
+
|
|
36
|
+
async function load(force) {
|
|
37
|
+
if (asking) return;
|
|
38
|
+
asking = true;
|
|
39
|
+
try {
|
|
40
|
+
S.runs = await getRuns(force);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
S.runs = { online: false, error: e.message, staff: [] };
|
|
43
|
+
} finally {
|
|
44
|
+
asking = false;
|
|
45
|
+
}
|
|
46
|
+
if (S.view === "runs") render();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function paint(m, data) {
|
|
50
|
+
const again = el("button", { className: "ghbtn", textContent: "Ask GitHub again" });
|
|
51
|
+
again.onclick = () => {
|
|
52
|
+
S.runs = null;
|
|
53
|
+
load(true);
|
|
54
|
+
render();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
if (!data.online) {
|
|
58
|
+
const box = el("div", { className: "notice" });
|
|
59
|
+
box.innerHTML =
|
|
60
|
+
"<b>Runs live on GitHub, and it could not be reached.</b><br>" +
|
|
61
|
+
esc(data.error ?? "gh is unavailable") +
|
|
62
|
+
"<br>Everything else in the portal reads from disk and still works.";
|
|
63
|
+
m.append(box, el("div", { className: "row hacts" }, [again]));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const g = el("div", { className: "grid runsum" });
|
|
68
|
+
g.append(tile("Last 30 days", money(data.spend), priced(data.spend), over(data.spend, data.budget)));
|
|
69
|
+
if (data.budget) {
|
|
70
|
+
g.append(tile("Budget", "$" + data.budget, "org.yaml · 30 days, all staff", over(data.spend, data.budget)));
|
|
71
|
+
}
|
|
72
|
+
g.append(tile("Runs", String(data.spend?.runs ?? 0), "skipped mentions are not counted"));
|
|
73
|
+
m.append(g);
|
|
74
|
+
|
|
75
|
+
for (const s of data.staff ?? []) m.append(...staffSection(s));
|
|
76
|
+
|
|
77
|
+
m.append(
|
|
78
|
+
el("div", { className: "row hacts" }, [
|
|
79
|
+
again,
|
|
80
|
+
el("span", { className: "meta", textContent: data.fetchedAt ? "read " + ago(data.fetchedAt) : "" }),
|
|
81
|
+
]),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function staffSection(s) {
|
|
86
|
+
const head = el("div", { className: "hsect" });
|
|
87
|
+
head.append(el("span", { textContent: s.name }));
|
|
88
|
+
const total = el("i", {
|
|
89
|
+
textContent: money(s.spend) + (s.budget ? " of $" + s.budget : "") + " · 30 days",
|
|
90
|
+
});
|
|
91
|
+
if (over(s.spend, s.budget)) total.className = "warn";
|
|
92
|
+
head.append(total);
|
|
93
|
+
|
|
94
|
+
const body = el("div", { className: "hbody runs" });
|
|
95
|
+
for (const e of s.errors ?? []) body.append(el("p", { className: "hnote err", textContent: e }));
|
|
96
|
+
if (!(s.runs ?? []).length) {
|
|
97
|
+
body.append(el("p", { className: "hnote", textContent: "No runs in the last 30 days." }));
|
|
98
|
+
return [head, body];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const table = el("table", { className: "runtable" });
|
|
102
|
+
table.innerHTML =
|
|
103
|
+
"<thead><tr><th>When</th><th>Kind</th><th>Outcome</th><th>Took</th><th>Turns</th>" +
|
|
104
|
+
"<th>Cost</th><th></th></tr></thead>";
|
|
105
|
+
const rows = el("tbody");
|
|
106
|
+
for (const r of s.runs.slice(0, SHOWN)) rows.append(row(r));
|
|
107
|
+
table.append(rows);
|
|
108
|
+
body.append(table);
|
|
109
|
+
if (s.runs.length > SHOWN) {
|
|
110
|
+
body.append(
|
|
111
|
+
el("p", {
|
|
112
|
+
className: "hnote",
|
|
113
|
+
textContent: s.runs.length - SHOWN + " older runs count towards the total and are not listed.",
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return [head, body];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function row(r) {
|
|
121
|
+
const tr = el("tr");
|
|
122
|
+
const out = outcome(r);
|
|
123
|
+
const rec = r.record ?? {};
|
|
124
|
+
const mins = rec.duration_s != null ? rec.duration_s / 60 : r.minutes;
|
|
125
|
+
tr.innerHTML =
|
|
126
|
+
'<td title="' + esc(r.createdAt) + '">' + esc(ago(r.createdAt)) + "</td>" +
|
|
127
|
+
"<td>" + esc(r.kind) + "</td>" +
|
|
128
|
+
'<td><span class="chip ' + out.tone + '">' + esc(out.word) + "</span></td>" +
|
|
129
|
+
"<td>" + (mins != null ? Math.round(mins) + "m" : "–") + "</td>" +
|
|
130
|
+
"<td>" + (rec.turns ?? "–") + "</td>" +
|
|
131
|
+
"<td>" + (typeof rec.cost_usd === "number" ? "$" + rec.cost_usd.toFixed(2) : "–") + "</td>" +
|
|
132
|
+
'<td><a class="ref" href="' + esc(r.url) + '" target="_blank" rel="noopener">log</a></td>';
|
|
133
|
+
return tr;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The record's word when there is one, which knows a setup failure from an agent failure. */
|
|
137
|
+
function outcome(r) {
|
|
138
|
+
if (r.status !== "completed") return { word: r.status.replace("_", " "), tone: "" };
|
|
139
|
+
const word = r.record?.outcome ?? r.conclusion ?? "unknown";
|
|
140
|
+
const tone = word === "success" ? "ok" : word === "cancelled" ? "warm" : "hot";
|
|
141
|
+
return { word, tone };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function money(spend) {
|
|
145
|
+
return "$" + (spend?.usd ?? 0).toFixed(2);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A total is only as good as the runs it could price, so it says how many that was. */
|
|
149
|
+
function priced(spend) {
|
|
150
|
+
if (!spend?.runs) return "no runs";
|
|
151
|
+
if (spend.known === spend.runs) return "every run priced";
|
|
152
|
+
return "cost known for " + spend.known + " of " + spend.runs + " runs";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function over(spend, budget) {
|
|
156
|
+
return !!budget && (spend?.usd ?? 0) > budget;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function tile(k, v, sub, bad) {
|
|
160
|
+
const d = el("div", { className: "kv" + (bad ? " bad" : "") });
|
|
161
|
+
d.innerHTML =
|
|
162
|
+
'<div class="k">' + esc(k) + '</div><div class="v">' + esc(v) +
|
|
163
|
+
(sub ? "<small>" + esc(sub) + "</small>" : "") + "</div>";
|
|
164
|
+
return d;
|
|
165
|
+
}
|