@nanocollective/roster 0.1.0-alpha.6 → 0.1.0-alpha.7
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 +65 -84
- package/dist/cli.js +4153 -2708
- package/docs/README.md +9 -6
- package/docs/agents.md +24 -20
- 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 +81 -5
- package/docs/concepts.md +48 -14
- package/docs/cost.md +36 -1
- package/docs/developing.md +16 -21
- package/docs/doctor-codes.md +8 -2
- package/docs/extending.md +2 -2
- package/docs/getting-started.md +100 -77
- package/docs/manual-steps.md +93 -123
- package/docs/memory.md +21 -3
- package/docs/org-yaml.md +37 -2
- package/docs/portal.md +59 -33
- package/docs/prompts.md +25 -4
- package/docs/security.md +29 -5
- package/docs/session-workflow.md +49 -17
- package/docs/staff-yaml.md +13 -2
- package/docs/troubleshooting.md +8 -8
- package/docs/upgrading.md +6 -0
- package/docs/writing-a-charter.md +15 -0
- package/package.json +1 -1
- package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +6 -0
- package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +6 -0
- 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/strategy/ideas.md +7 -0
- package/templates/ops/.github/workflows/session.yaml +108 -14
- package/templates/ops/agents.mjs +7 -3
- package/templates/ops/compose.mjs +16 -3
- package/templates/ops/inflight.mjs +157 -0
- package/templates/ops/org/operating.md +21 -1
- package/templates/ops/org/voice.md +9 -0
- 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 +2 -0
- package/templates/ops/run-record.mjs +144 -0
- package/templates/portal/css/runs.css +13 -0
- package/templates/portal/index.html +4 -0
- package/templates/portal/js/api.js +29 -3
- package/templates/portal/js/app.js +4 -1
- package/templates/portal/js/state.js +3 -1
- package/templates/portal/js/views/app.js +23 -5
- package/templates/portal/js/views/credential.js +93 -0
- package/templates/portal/js/views/health.js +15 -3
- package/templates/portal/js/views/org.js +2 -0
- package/templates/portal/js/views/paste.js +29 -0
- package/templates/portal/js/views/runonce.js +88 -0
- package/templates/portal/js/views/runs.js +165 -0
- package/templates/portal/js/views/setup.js +67 -26
- package/templates/portal/js/views/staff.js +47 -10
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* it is one browser, one origin, and nothing extra to explain.
|
|
7
7
|
*
|
|
8
8
|
* What it still cannot do is install the App. Installing is a grant of access to specific
|
|
9
|
-
* repositories and GitHub asks a human to
|
|
10
|
-
* be worked around. */
|
|
9
|
+
* repositories and GitHub asks a human to confirm them, which is correct behaviour and should not
|
|
10
|
+
* be worked around. The link it hands back has the right repos already selected. */
|
|
11
11
|
|
|
12
12
|
import { appResult, startApp } from "../api.js";
|
|
13
13
|
import { el } from "../dom.js";
|
|
@@ -48,6 +48,11 @@ export function appPanel(opts) {
|
|
|
48
48
|
it: an App that exists is not a problem, it is a step you have already done. */
|
|
49
49
|
const note = el("div");
|
|
50
50
|
note.append(el("p", { className: "err", textContent: String(err.message || err) }));
|
|
51
|
+
if (err.data?.install) {
|
|
52
|
+
note.append(
|
|
53
|
+
el("a", { className: "btn", href: err.data.install, target: "_blank", textContent: "Install it" }),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
51
56
|
out.replaceChildren(note);
|
|
52
57
|
return;
|
|
53
58
|
}
|
|
@@ -102,9 +107,21 @@ export function appPanel(opts) {
|
|
|
102
107
|
el("p", {
|
|
103
108
|
className: "sub",
|
|
104
109
|
textContent:
|
|
105
|
-
"
|
|
106
|
-
"
|
|
110
|
+
"The link opens GitHub's install page with the org and the repos already selected: " +
|
|
111
|
+
"this staff member's brain, every peer tracker it writes to, and the product repos. " +
|
|
112
|
+
"Check the list and confirm.",
|
|
107
113
|
}),
|
|
114
|
+
...((result.preselected ?? []).length
|
|
115
|
+
? [el("p", { className: "sub", textContent: "Selected: " + result.preselected.join(", ") })]
|
|
116
|
+
: []),
|
|
117
|
+
...((result.missing ?? []).length
|
|
118
|
+
? [
|
|
119
|
+
el("p", {
|
|
120
|
+
className: "err",
|
|
121
|
+
textContent: "Tick these yourself, their ids could not be read: " + result.missing.join(", "),
|
|
122
|
+
}),
|
|
123
|
+
]
|
|
124
|
+
: []),
|
|
108
125
|
el("a", {
|
|
109
126
|
className: "btn primary",
|
|
110
127
|
href: result.install,
|
|
@@ -115,7 +132,8 @@ export function appPanel(opts) {
|
|
|
115
132
|
className: "sub",
|
|
116
133
|
textContent:
|
|
117
134
|
"Do not verify this by reading the API. It reports what an App declares separately " +
|
|
118
|
-
"from what an installation was granted. Only a run that finished proves the chain
|
|
135
|
+
"from what an installation was granted. Only a run that finished proves the chain, " +
|
|
136
|
+
"which is what Run once now on the staff card is for.",
|
|
119
137
|
}),
|
|
120
138
|
);
|
|
121
139
|
out.replaceChildren(done);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/* The agent's credential, stored once for the org.
|
|
2
|
+
*
|
|
3
|
+
* The value goes from this box to the local server in a POST, and from there to gh on its
|
|
4
|
+
* standard input. It is not kept in the page after it is sent, and nothing echoes it back. */
|
|
5
|
+
|
|
6
|
+
import { getCredential, storeCredential } from "../api.js";
|
|
7
|
+
import { el } from "../dom.js";
|
|
8
|
+
|
|
9
|
+
export function credentialPanel() {
|
|
10
|
+
const box = el("div", { className: "manual" });
|
|
11
|
+
box.append(el("b", { textContent: "Your agent's credential, once" }));
|
|
12
|
+
const body = el("div");
|
|
13
|
+
box.append(body);
|
|
14
|
+
body.append(el("p", { className: "sub", textContent: "Looking at where it would go…" }));
|
|
15
|
+
|
|
16
|
+
getCredential()
|
|
17
|
+
.then((c) => draw(body, c))
|
|
18
|
+
.catch((err) => body.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) })));
|
|
19
|
+
return box;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function draw(body, c) {
|
|
23
|
+
body.replaceChildren();
|
|
24
|
+
if (c.howTo) body.append(el("p", { textContent: "Where to get one: " + c.howTo }));
|
|
25
|
+
|
|
26
|
+
if (!c.brains.length) {
|
|
27
|
+
body.append(
|
|
28
|
+
el("p", {
|
|
29
|
+
className: "sub",
|
|
30
|
+
textContent:
|
|
31
|
+
"Nothing reads it until somebody is hired, so this box opens once there is a staff " +
|
|
32
|
+
"member. It is asked for once, not once per hire.",
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (c.orgSecret) {
|
|
39
|
+
body.append(
|
|
40
|
+
el("p", {
|
|
41
|
+
className: "sub",
|
|
42
|
+
textContent:
|
|
43
|
+
c.name + " is already an org secret, shared with " + c.orgSecret.visibility + " repos. " +
|
|
44
|
+
"Each hire adds its brain to it. Paste a new one only to replace it.",
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
body.append(
|
|
50
|
+
el("p", {
|
|
51
|
+
className: "sub",
|
|
52
|
+
textContent:
|
|
53
|
+
(c.plan.mode === "org"
|
|
54
|
+
? "Stored as one org secret, " + c.name + ", shared with " + c.brains.join(", ") + ". "
|
|
55
|
+
: "Stored as " + c.name + " on each of " + c.brains.join(", ") + ", because " + c.plan.reason + ". ") +
|
|
56
|
+
"It goes to gh on standard input and is never written to disk.",
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const input = el("input", { type: "password", placeholder: c.name, autocomplete: "off" });
|
|
61
|
+
input.style.width = "100%";
|
|
62
|
+
const go = el("button", { className: "btn primary", textContent: "Store it" });
|
|
63
|
+
const out = el("div", { style: "margin-top:9px" });
|
|
64
|
+
body.append(input, el("div", { className: "row", style: "margin-top:9px" }, [go]), out);
|
|
65
|
+
|
|
66
|
+
go.onclick = async () => {
|
|
67
|
+
const value = input.value.trim();
|
|
68
|
+
if (!value) {
|
|
69
|
+
input.focus();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
go.disabled = true;
|
|
73
|
+
out.replaceChildren(el("p", { className: "sub", textContent: "Storing…" }));
|
|
74
|
+
try {
|
|
75
|
+
const r = await storeCredential(value, false);
|
|
76
|
+
input.value = "";
|
|
77
|
+
if (!r.ok) throw new Error(r.error);
|
|
78
|
+
out.replaceChildren(
|
|
79
|
+
...(r.fellBack ? [el("p", { className: "err", textContent: "The org secret was refused: " + r.fellBack })] : []),
|
|
80
|
+
el("p", {
|
|
81
|
+
textContent:
|
|
82
|
+
r.mode === "org"
|
|
83
|
+
? "Stored once for the org, shared with " + r.repos.length + " brains."
|
|
84
|
+
: "Stored on " + r.repos.join(", ") + ".",
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) }));
|
|
89
|
+
} finally {
|
|
90
|
+
go.disabled = false;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -7,6 +7,7 @@ import { render } from "../router.js";
|
|
|
7
7
|
import { S, staff } from "../state.js";
|
|
8
8
|
import { checklist } from "./checklist.js";
|
|
9
9
|
import { copyAmendBrief } from "./prompt.js";
|
|
10
|
+
import { runOnce } from "./runonce.js";
|
|
10
11
|
|
|
11
12
|
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
12
13
|
|
|
@@ -81,7 +82,18 @@ export function viewHealth(m) {
|
|
|
81
82
|
doctor, bordered boxes for the prompt audit, cards with two buttons each for lint. Same
|
|
82
83
|
question in all three, so one shape: a heading that carries its own count, a line saying
|
|
83
84
|
what the section is, the findings as rows, and the section's actions at the end. */
|
|
84
|
-
|
|
85
|
+
/* A workflow that has never run is unproven, and the way to prove it is to run it. Here
|
|
86
|
+
because Health is where "unproven" is read; a success re-runs the checks below. */
|
|
87
|
+
let doctor = null;
|
|
88
|
+
m.append(
|
|
89
|
+
runOnce({
|
|
90
|
+
staff: s.handle,
|
|
91
|
+
name: s.name,
|
|
92
|
+
onDone: (ok) => ok && doctor && checklist(doctor.body, { shell: doctor }),
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
doctor = section(m, "The org, from roster doctor", "doctor");
|
|
85
97
|
checklist(doctor.body, { shell: doctor });
|
|
86
98
|
|
|
87
99
|
/* What is wrong with what this agent is sent. It used to sit in the tree on the Prompt
|
|
@@ -132,7 +144,7 @@ function memoryProblems({ body, actions, say }, s) {
|
|
|
132
144
|
const { row, body: text } = finding(
|
|
133
145
|
p.level === "error" ? "error" : "warning",
|
|
134
146
|
{ html: inline(p.message) },
|
|
135
|
-
[p.rule, p.line ? "INDEX.md:" + p.line : ""],
|
|
147
|
+
[p.rule, p.line ? "INDEX.md:" + p.line : (p.file ?? "")],
|
|
136
148
|
);
|
|
137
149
|
const status = el("span", { className: "meta" });
|
|
138
150
|
const btn = el("button", { className: "ghbtn", textContent: "Ask " + s.handle.toUpperCase() + " to fix" });
|
|
@@ -263,7 +275,7 @@ export async function askToFix(s, problems, btn, status) {
|
|
|
263
275
|
"",
|
|
264
276
|
"| rule | where | what |",
|
|
265
277
|
"|---|---|---|",
|
|
266
|
-
...problems.map((p) => "| `" + p.rule + "` | " + (p.line ? "INDEX.md:" + p.line : "—") + " | " +
|
|
278
|
+
...problems.map((p) => "| `" + p.rule + "` | " + (p.line ? "INDEX.md:" + p.line : (p.file ?? "—")) + " | " +
|
|
267
279
|
String(p.message).replace(/\|/g, "\\|") + " |"),
|
|
268
280
|
"",
|
|
269
281
|
"Fix in your next session and close this.",
|
|
@@ -27,6 +27,8 @@ const KNOWN = {
|
|
|
27
27
|
"org.yaml": "Who and what: the humans, the repos, the defaults, which agent runs them.",
|
|
28
28
|
"org/business.md":
|
|
29
29
|
"What the business actually is. Every prompt is composed on top of it, and it is the file that stops the agents writing generic slop.",
|
|
30
|
+
"org/priorities.md":
|
|
31
|
+
"What matters this month, ranked, and what is out of scope. Every daily run reads it, and every PR says which priority it serves.",
|
|
30
32
|
"org/operating.md":
|
|
31
33
|
"The autonomy contract: what they do without asking, what they escalate, how a run starts and ends.",
|
|
32
34
|
"org/guardrails.md":
|
|
@@ -20,6 +20,12 @@ export function paste(opts) {
|
|
|
20
20
|
const status = el("p", { className: "sub", textContent: "Building the prompt…" });
|
|
21
21
|
box.append(status);
|
|
22
22
|
|
|
23
|
+
/* A charter brief carries a worked example to model the shape on, matched to the role. The
|
|
24
|
+
choice is on the page because the match is a guess, and "none" is a fair answer. */
|
|
25
|
+
const model = el("select", { hidden: true });
|
|
26
|
+
const modelRow = el("label", { className: "sub", hidden: true }, ["Model it on: ", model]);
|
|
27
|
+
box.append(modelRow);
|
|
28
|
+
|
|
23
29
|
const actions = el("div", { className: "row" });
|
|
24
30
|
const copy = el("button", { className: "btn primary", textContent: "Copy the prompt" });
|
|
25
31
|
const show = el("button", { className: "btn", textContent: "Show it" });
|
|
@@ -42,10 +48,33 @@ export function paste(opts) {
|
|
|
42
48
|
|
|
43
49
|
let brief = null;
|
|
44
50
|
|
|
51
|
+
const NAMES = { cto: "the CTO example", cmo: "the CMO example", support: "the support example", none: "no example" };
|
|
52
|
+
model.onchange = () => {
|
|
53
|
+
copy.disabled = true;
|
|
54
|
+
getBrief(opts.kind, opts.staff, model.value)
|
|
55
|
+
.then((data) => {
|
|
56
|
+
if (data.error) throw new Error(data.error);
|
|
57
|
+
brief = data;
|
|
58
|
+
if (!preview.hidden) preview.textContent = brief.text;
|
|
59
|
+
})
|
|
60
|
+
.catch((err) => {
|
|
61
|
+
status.className = "err";
|
|
62
|
+
status.textContent = String(err.message || err);
|
|
63
|
+
})
|
|
64
|
+
.finally(() => {
|
|
65
|
+
copy.disabled = false;
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
45
69
|
getBrief(opts.kind, opts.staff)
|
|
46
70
|
.then((data) => {
|
|
47
71
|
if (data.error) throw new Error(data.error);
|
|
48
72
|
brief = data;
|
|
73
|
+
if (data.examples) {
|
|
74
|
+
model.replaceChildren(...data.examples.map((e) => el("option", { value: e, textContent: NAMES[e] ?? e })));
|
|
75
|
+
model.value = data.example ?? "none";
|
|
76
|
+
model.hidden = modelRow.hidden = false;
|
|
77
|
+
}
|
|
49
78
|
status.textContent =
|
|
50
79
|
"Paste this into Claude, ChatGPT or anything else. It carries every file it refers to, " +
|
|
51
80
|
"so there is nothing to attach and nothing for it to ask you for. It will interview you " +
|
|
@@ -0,0 +1,88 @@
|
|
|
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 { el } from "../dom.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {{staff: string, name: string, onDone?: (ok: boolean) => void}} opts
|
|
12
|
+
*/
|
|
13
|
+
export function runOnce(opts) {
|
|
14
|
+
const box = el("div", { className: "manual" });
|
|
15
|
+
box.append(
|
|
16
|
+
el("b", { textContent: "Run " + opts.name + " once, now" }),
|
|
17
|
+
el("p", {
|
|
18
|
+
className: "sub",
|
|
19
|
+
textContent:
|
|
20
|
+
"A real daily run: it does a day's work and spends what a scheduled one would. " +
|
|
21
|
+
"When it finishes you get the outcome and the log.",
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
24
|
+
const go = el("button", { className: "btn primary", textContent: "Run once now" });
|
|
25
|
+
const out = el("div", { style: "margin-top:9px" });
|
|
26
|
+
box.append(el("div", { className: "row" }, [go]), out);
|
|
27
|
+
|
|
28
|
+
go.onclick = async () => {
|
|
29
|
+
if (!confirm("Start a daily run for " + opts.name + " now?\n\nIt is a real run, and it costs what one does.")) return;
|
|
30
|
+
go.disabled = true;
|
|
31
|
+
const say = (text, cls) => out.replaceChildren(el("p", { className: cls ?? "sub", textContent: text }));
|
|
32
|
+
say("Starting…");
|
|
33
|
+
let run;
|
|
34
|
+
try {
|
|
35
|
+
run = await startRun(opts.staff);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
say(String(err.message || err), "err");
|
|
38
|
+
go.disabled = false;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const link = el("a", { href: run.url, target: "_blank", rel: "noopener", textContent: "the log" });
|
|
42
|
+
if (!run.id) {
|
|
43
|
+
out.replaceChildren(el("p", { className: "sub" }, ["Started. It has not shown up yet; follow it at ", link, "."]));
|
|
44
|
+
go.disabled = false;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* Polled rather than streamed: a run takes minutes, the status changes a handful of times,
|
|
49
|
+
and a clock beside it is what shows the page has not hung. */
|
|
50
|
+
const began = Date.now();
|
|
51
|
+
const tick = async () => {
|
|
52
|
+
let now;
|
|
53
|
+
try {
|
|
54
|
+
now = await runStatus(opts.staff, run.id);
|
|
55
|
+
if (now.error) throw new Error(now.error);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
say(String(err.message || err), "err");
|
|
58
|
+
go.disabled = false;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const mins = Math.floor((Date.now() - began) / 60000);
|
|
62
|
+
if (now.status !== "completed") {
|
|
63
|
+
out.replaceChildren(
|
|
64
|
+
el("p", { className: "sub" }, [now.status.replace("_", " ") + ", " + mins + " min so far. ", link]),
|
|
65
|
+
);
|
|
66
|
+
setTimeout(tick, 15000);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
go.disabled = false;
|
|
70
|
+
const ok = now.conclusion === "success";
|
|
71
|
+
const done = el("div");
|
|
72
|
+
done.append(
|
|
73
|
+
el("p", { className: ok ? "" : "err" }, [
|
|
74
|
+
ok
|
|
75
|
+
? "It worked. The App, its grant, the secrets and the callers are proven, and Health now says so. "
|
|
76
|
+
: "It ended " + (now.conclusion || "without a conclusion") + ". ",
|
|
77
|
+
el("a", { href: now.url, target: "_blank", rel: "noopener", textContent: "The log" }),
|
|
78
|
+
]),
|
|
79
|
+
);
|
|
80
|
+
for (const f of now.failedAt ?? []) done.append(el("p", { className: "sub", textContent: "Failed at " + f }));
|
|
81
|
+
if (!ok) done.append(el("p", { className: "sub", textContent: "Troubleshooting, in the docs, has what each failure usually means." }));
|
|
82
|
+
out.replaceChildren(done);
|
|
83
|
+
opts.onDone?.(ok);
|
|
84
|
+
};
|
|
85
|
+
tick();
|
|
86
|
+
};
|
|
87
|
+
return box;
|
|
88
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* is rather than as it was left. Setup cannot be finished in one sitting, so it must survive
|
|
6
6
|
* being abandoned halfway. */
|
|
7
7
|
|
|
8
|
-
import { checkOrg, createTenant, getSetup, joinOrg, planTenant } from "../api.js";
|
|
8
|
+
import { checkOrg, createTenant, getAccess, getSetup, joinOrg, planTenant, setAccess } from "../api.js";
|
|
9
9
|
import { $, el, esc, toClipboard } from "../dom.js";
|
|
10
10
|
import { checklist } from "./checklist.js";
|
|
11
|
+
import { credentialPanel } from "./credential.js";
|
|
11
12
|
import { repoPicker } from "./repos.js";
|
|
12
13
|
import { paste } from "./paste.js";
|
|
13
14
|
|
|
@@ -30,8 +31,9 @@ function render(main) {
|
|
|
30
31
|
el("p", {
|
|
31
32
|
className: "sub",
|
|
32
33
|
textContent:
|
|
33
|
-
"
|
|
34
|
-
"
|
|
34
|
+
"Three things here, then what is left: a setting roster tries for you, the repos your " +
|
|
35
|
+
"staff work in, and the one file only you can write. Nothing is created until you have " +
|
|
36
|
+
"read the plan.",
|
|
35
37
|
}),
|
|
36
38
|
);
|
|
37
39
|
|
|
@@ -259,7 +261,10 @@ function stepOrg(main) {
|
|
|
259
261
|
out.replaceChildren();
|
|
260
262
|
out.append(
|
|
261
263
|
el("p", {
|
|
262
|
-
textContent:
|
|
264
|
+
textContent:
|
|
265
|
+
`${result.files.length} files in ${result.dir}, and one private repo, ${p.org}/roster-ops, ` +
|
|
266
|
+
"with its Actions access set so every repo in the org can call its workflow. If GitHub " +
|
|
267
|
+
"refuses that, you get the reason and the page to click.",
|
|
263
268
|
}),
|
|
264
269
|
);
|
|
265
270
|
const list = el("div", { className: "filelist" });
|
|
@@ -307,30 +312,12 @@ function afterCreate() {
|
|
|
307
312
|
card.append(
|
|
308
313
|
el("p", {
|
|
309
314
|
textContent:
|
|
310
|
-
"
|
|
311
|
-
"
|
|
315
|
+
"The first is a setting roster tries for you. The last is the file that decides whether " +
|
|
316
|
+
"any of this is worth running, and only you can write it.",
|
|
312
317
|
}),
|
|
313
318
|
);
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
one.append(
|
|
317
|
-
el("b", { textContent: "Let the ops repo's workflow be called" }),
|
|
318
|
-
el("p", {
|
|
319
|
-
textContent:
|
|
320
|
-
"Settings → Actions → General → Access → “Accessible from repositories in the " +
|
|
321
|
-
"organisation”, on roster-ops.",
|
|
322
|
-
}),
|
|
323
|
-
el("small", {
|
|
324
|
-
textContent:
|
|
325
|
-
"Skip it and every workflow fails with “workflow not found”, which reads like a typo in " +
|
|
326
|
-
"a path and is not one. It is an org permission on a repo, so it is yours to click.",
|
|
327
|
-
}),
|
|
328
|
-
);
|
|
329
|
-
if (S.tenant.org) {
|
|
330
|
-
const url = `https://github.com/${S.tenant.org}/roster-ops/settings/actions`;
|
|
331
|
-
one.append(el("a", { className: "btn", href: url, target: "_blank", textContent: "Open that page" }));
|
|
332
|
-
}
|
|
333
|
-
card.append(one);
|
|
319
|
+
card.append(accessStep());
|
|
320
|
+
card.append(credentialPanel());
|
|
334
321
|
|
|
335
322
|
if (S.tenant.org) {
|
|
336
323
|
card.append(repoPicker({ org: S.tenant.org, known: S.tenant.repos ?? [] }));
|
|
@@ -352,6 +339,60 @@ function afterCreate() {
|
|
|
352
339
|
return card;
|
|
353
340
|
}
|
|
354
341
|
|
|
342
|
+
/**
|
|
343
|
+
* The ops repo's Actions access. Read on arrival, so a page reloaded after it was set says it
|
|
344
|
+
* is done without anybody pressing anything; the button asks the server to set it, and a
|
|
345
|
+
* refusal comes back with GitHub's reason and the page to click instead.
|
|
346
|
+
*
|
|
347
|
+
* Skipped, every workflow fails with "workflow not found", which reads like a typo in a path.
|
|
348
|
+
*/
|
|
349
|
+
function accessStep() {
|
|
350
|
+
const box = el("div", { className: "manual" });
|
|
351
|
+
box.append(el("b", { textContent: "Let every repo in the org call the ops repo's workflow" }));
|
|
352
|
+
const out = el("div");
|
|
353
|
+
box.append(out);
|
|
354
|
+
const say = (text, cls) => el("p", { className: cls ?? "sub", textContent: text });
|
|
355
|
+
|
|
356
|
+
const offer = (why) => {
|
|
357
|
+
const go = el("button", { className: "btn primary", textContent: "Set it for me" });
|
|
358
|
+
out.replaceChildren(
|
|
359
|
+
say(
|
|
360
|
+
(why ? why + " " : "") +
|
|
361
|
+
"Settings → Actions → General → Access on roster-ops. Skip it and every workflow fails " +
|
|
362
|
+
"with “workflow not found”.",
|
|
363
|
+
),
|
|
364
|
+
el("div", { className: "row" }, [go]),
|
|
365
|
+
);
|
|
366
|
+
go.onclick = async () => {
|
|
367
|
+
go.disabled = true;
|
|
368
|
+
try {
|
|
369
|
+
const r = await setAccess();
|
|
370
|
+
if (r.ok) {
|
|
371
|
+
out.replaceChildren(say("Set. Every repo in the org can call roster-ops."));
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
out.replaceChildren(
|
|
375
|
+
say("GitHub refused: " + r.error + ".", "err"),
|
|
376
|
+
el("a", { className: "btn", href: r.link, target: "_blank", textContent: "Set it by hand" }),
|
|
377
|
+
say("Access → “Accessible from repositories in the organisation”."),
|
|
378
|
+
);
|
|
379
|
+
} catch (err) {
|
|
380
|
+
out.replaceChildren(say(String(err.message || err), "err"));
|
|
381
|
+
go.disabled = false;
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
out.replaceChildren(say("Checking…"));
|
|
387
|
+
getAccess()
|
|
388
|
+
.then((r) => {
|
|
389
|
+
if (r.ok) out.replaceChildren(say("Done. Every repo in the org can call roster-ops."));
|
|
390
|
+
else offer(r.level ? "It is set to “" + r.level + "”." : "");
|
|
391
|
+
})
|
|
392
|
+
.catch(() => offer(""));
|
|
393
|
+
return box;
|
|
394
|
+
}
|
|
395
|
+
|
|
355
396
|
/* ---------------------------------- furniture ---------------------------------- */
|
|
356
397
|
|
|
357
398
|
function step(n, title, done) {
|