@nanocollective/roster 0.1.0-alpha.3 → 0.1.0-alpha.30

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.
Files changed (108) hide show
  1. package/README.md +70 -84
  2. package/dist/cli.js +4817 -2374
  3. package/docs/README.md +19 -11
  4. package/docs/agents.md +328 -13
  5. package/docs/architecture.md +13 -5
  6. package/docs/charters/analyst.md +65 -0
  7. package/docs/charters/cmo.md +69 -0
  8. package/docs/charters/community.md +63 -0
  9. package/docs/charters/cto.md +71 -0
  10. package/docs/charters/designer.md +65 -0
  11. package/docs/charters/devops.md +65 -0
  12. package/docs/charters/pm.md +70 -0
  13. package/docs/charters/qa.md +65 -0
  14. package/docs/charters/support.md +60 -0
  15. package/docs/charters/writer.md +63 -0
  16. package/docs/commands.md +95 -11
  17. package/docs/concepts.md +64 -12
  18. package/docs/cost.md +39 -3
  19. package/docs/developing.md +16 -21
  20. package/docs/doctor-codes.md +21 -6
  21. package/docs/export.md +4 -1
  22. package/docs/extending.md +13 -4
  23. package/docs/getting-started.md +133 -79
  24. package/docs/images/brain.jpg +0 -0
  25. package/docs/images/org.jpg +0 -0
  26. package/docs/images/prompt.jpg +0 -0
  27. package/docs/images/setup-org.jpg +0 -0
  28. package/docs/images/setup-plan.jpg +0 -0
  29. package/docs/images/staff.jpg +0 -0
  30. package/docs/manual-steps.md +95 -101
  31. package/docs/memory.md +29 -8
  32. package/docs/org-yaml.md +76 -11
  33. package/docs/portal.md +290 -49
  34. package/docs/prompts.md +77 -11
  35. package/docs/security.md +51 -7
  36. package/docs/session-workflow.md +51 -21
  37. package/docs/staff-yaml.md +17 -7
  38. package/docs/troubleshooting.md +23 -20
  39. package/docs/upgrading.md +9 -3
  40. package/docs/writing-a-charter.md +46 -17
  41. package/package.json +1 -1
  42. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +7 -0
  43. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +16 -4
  44. package/templates/brain/CHARTER.md +3 -3
  45. package/templates/brain/README.md +1 -0
  46. package/templates/brain/log/decisions.md +3 -0
  47. package/templates/brain/staff.yaml +0 -1
  48. package/templates/brain/strategy/ideas.md +7 -0
  49. package/templates/briefs/priorities.md +46 -0
  50. package/templates/ops/.github/workflows/session.yaml +117 -40
  51. package/templates/ops/agents.mjs +127 -8
  52. package/templates/ops/compose.mjs +77 -7
  53. package/templates/ops/inflight.mjs +157 -0
  54. package/templates/ops/org/operating.md +21 -7
  55. package/templates/ops/org/voice.md +9 -0
  56. package/templates/ops/prompts/_identity.md +8 -1
  57. package/templates/ops/prompts/_inflight.md +14 -0
  58. package/templates/ops/prompts/_paths.md +2 -1
  59. package/templates/ops/prompts/daily.md +16 -7
  60. package/templates/ops/prompts/mention.md +18 -2
  61. package/templates/ops/run-record.mjs +144 -0
  62. package/templates/portal/css/base.css +245 -64
  63. package/templates/portal/css/brain.css +30 -20
  64. package/templates/portal/css/diff.css +15 -10
  65. package/templates/portal/css/graph.css +12 -7
  66. package/templates/portal/css/health.css +32 -11
  67. package/templates/portal/css/inbox.css +117 -14
  68. package/templates/portal/css/layout.css +114 -41
  69. package/templates/portal/css/markdown.css +57 -15
  70. package/templates/portal/css/runs.css +13 -0
  71. package/templates/portal/css/setup.css +126 -39
  72. package/templates/portal/index.html +25 -3
  73. package/templates/portal/js/api.js +74 -4
  74. package/templates/portal/js/app.js +156 -14
  75. package/templates/portal/js/dialog.js +129 -4
  76. package/templates/portal/js/dom.js +25 -0
  77. package/templates/portal/js/icons.js +45 -1
  78. package/templates/portal/js/inflight.js +18 -0
  79. package/templates/portal/js/lightbox.js +273 -0
  80. package/templates/portal/js/md.js +23 -6
  81. package/templates/portal/js/mdedit.js +84 -0
  82. package/templates/portal/js/mention.js +264 -0
  83. package/templates/portal/js/readiness.js +35 -0
  84. package/templates/portal/js/refresh.js +136 -6
  85. package/templates/portal/js/state.js +59 -8
  86. package/templates/portal/js/views/app.js +24 -7
  87. package/templates/portal/js/views/checklist.js +29 -10
  88. package/templates/portal/js/views/credential.js +84 -0
  89. package/templates/portal/js/views/docs.js +94 -4
  90. package/templates/portal/js/views/files.js +58 -14
  91. package/templates/portal/js/views/graph.js +1 -1
  92. package/templates/portal/js/views/health.js +178 -37
  93. package/templates/portal/js/views/hire.js +583 -0
  94. package/templates/portal/js/views/inbox.js +957 -126
  95. package/templates/portal/js/views/memory.js +16 -1
  96. package/templates/portal/js/views/org.js +124 -104
  97. package/templates/portal/js/views/orgedit.js +234 -0
  98. package/templates/portal/js/views/paste.js +87 -21
  99. package/templates/portal/js/views/prompt.js +61 -67
  100. package/templates/portal/js/views/repos.js +20 -15
  101. package/templates/portal/js/views/runonce.js +94 -0
  102. package/templates/portal/js/views/runs.js +165 -0
  103. package/templates/portal/js/views/setup.js +257 -75
  104. package/templates/portal/js/views/staff.js +157 -182
  105. package/templates/portal/js/views/todo.js +62 -0
  106. package/templates/portal/js/yaml.js +134 -0
  107. package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +0 -50
  108. package/templates/ops/prompts/pr-mention.md +0 -57
@@ -7,19 +7,27 @@
7
7
  * Nothing here writes. The panel parses, shows a diff, and waits for a second click. */
8
8
 
9
9
  import { getBrief, parsePaste, saveFile } from "../api.js";
10
+ import { mdlite } from "../md.js";
10
11
  import { el, toClipboard } from "../dom.js";
11
12
  import { unifiedDiff, diffStat } from "../textdiff.js";
12
13
 
13
14
  /**
14
- * @param {{kind: string, staff?: string, title?: string, onSaved?: () => void}} opts
15
+ * @param {{kind: string, staff?: string, title?: string, about?: string, onSaved?: () => void}} opts
16
+ * `about` is a sentence on what the role does, added to a charter brief.
15
17
  */
16
18
  export function paste(opts) {
17
19
  const box = el("div", { className: "paste" });
18
- box.append(el("h3", { textContent: opts.title ?? "Write it with your own AI" }));
20
+ if (opts.title !== "") box.append(el("h3", { textContent: opts.title ?? "Write it with your own AI" }));
19
21
 
20
22
  const status = el("p", { className: "sub", textContent: "Building the prompt…" });
21
23
  box.append(status);
22
24
 
25
+ /* A charter brief carries a worked example to model the shape on, matched to the role. The
26
+ choice is on the page because the match is a guess, and "none" is a fair answer. */
27
+ const model = el("select", { hidden: true });
28
+ const modelRow = el("label", { className: "sub", hidden: true }, ["Example to follow: ", model]);
29
+ box.append(modelRow);
30
+
23
31
  const actions = el("div", { className: "row" });
24
32
  const copy = el("button", { className: "btn primary", textContent: "Copy the prompt" });
25
33
  const show = el("button", { className: "btn", textContent: "Show it" });
@@ -32,8 +40,7 @@ export function paste(opts) {
32
40
  const answer = el("textarea", {
33
41
  className: "pastebox",
34
42
  placeholder:
35
- "Paste the whole reply here, including anything your AI said around the file. " +
36
- "Only the part between the sentinels is used.",
43
+ "Paste its whole reply here.",
37
44
  });
38
45
  answer.rows = 8;
39
46
 
@@ -42,16 +49,49 @@ export function paste(opts) {
42
49
 
43
50
  let brief = null;
44
51
 
45
- getBrief(opts.kind, opts.staff)
52
+ const NAMES = {
53
+ cto: "the CTO example",
54
+ cmo: "the CMO example",
55
+ support: "the support example",
56
+ pm: "the product manager example",
57
+ designer: "the designer example",
58
+ qa: "the QA example",
59
+ devops: "the DevOps example",
60
+ writer: "the writer example",
61
+ analyst: "the analyst example",
62
+ community: "the community example",
63
+ none: "no example",
64
+ };
65
+ model.onchange = () => {
66
+ copy.disabled = true;
67
+ getBrief(opts.kind, opts.staff, model.value, opts.about)
68
+ .then((data) => {
69
+ if (data.error) throw new Error(data.error);
70
+ brief = data;
71
+ if (!preview.hidden) preview.textContent = brief.text;
72
+ })
73
+ .catch((err) => {
74
+ status.className = "err";
75
+ status.textContent = String(err.message || err);
76
+ })
77
+ .finally(() => {
78
+ copy.disabled = false;
79
+ });
80
+ };
81
+
82
+ getBrief(opts.kind, opts.staff, undefined, opts.about)
46
83
  .then((data) => {
47
84
  if (data.error) throw new Error(data.error);
48
85
  brief = data;
86
+ if (data.examples) {
87
+ model.replaceChildren(...data.examples.map((e) => el("option", { value: e, textContent: NAMES[e] ?? e })));
88
+ model.value = data.example ?? "none";
89
+ model.hidden = modelRow.hidden = false;
90
+ }
49
91
  status.textContent =
50
- "Paste this into Claude, ChatGPT or anything else. It carries every file it refers to, " +
51
- "so there is nothing to attach and nothing for it to ask you for. It will interview you " +
52
- "first, then hand back the finished file.";
92
+ "Copy the prompt into Claude or ChatGPT and answer its questions.";
53
93
  box.append(
54
- el("p", { className: "sub", textContent: "Then paste its whole reply back here:" }),
94
+ el("p", { className: "sub", textContent: "Then paste its reply here:" }),
55
95
  answer,
56
96
  el("div", { className: "row" }, [check]),
57
97
  result,
@@ -97,23 +137,47 @@ export function paste(opts) {
97
137
  result.append(note);
98
138
  }
99
139
 
140
+ // What the AI said around the file: its caveats and "check these" are worth reading first.
141
+ const notes = String(answer.value ?? "")
142
+ .replace(/<<<ROSTER FILE [^>]+>>>[\s\S]*?<<<ROSTER END>>>/g, "")
143
+ .trim();
144
+
100
145
  for (const file of data.files ?? []) {
101
- if (file.unchanged) continue;
102
146
  const card = el("div", { className: "pastefile" });
103
- const stat = diffStat(file.before, file.text);
147
+ const name = file.path.split("/").slice(-2).join("/");
148
+ if (file.unchanged) {
149
+ card.append(el("p", { className: "verdict", textContent: "✓ Same as what's already in " + name + ". Nothing to save." }));
150
+ result.append(card);
151
+ continue;
152
+ }
104
153
  card.append(
105
- el("b", { textContent: file.path }),
106
- el("span", {
107
- className: "meta",
108
- textContent: `+${stat.added} −${stat.removed}` + (file.writable ? "" : " · not writable"),
154
+ el("p", {
155
+ className: "verdict" + (file.writable ? " ok" : " bad"),
156
+ textContent: file.writable
157
+ ? "✓ Looks good. Saving replaces " + name + " with this:"
158
+ : "✗ " + file.path + " can't be written from here.",
109
159
  }),
110
160
  );
111
- const diff = el("pre", { className: "diff" });
161
+ card.append(el("div", { className: "md doc pastepreview", innerHTML: mdlite(file.text) }));
162
+
163
+ if (notes) {
164
+ const said = el("details", { className: "pastenotes", open: true });
165
+ said.append(el("summary", { textContent: "Your AI also said" }), el("div", { className: "md doc", innerHTML: mdlite(notes) }));
166
+ card.append(said);
167
+ }
168
+
169
+ const diff = el("pre", { className: "diff", hidden: true });
112
170
  diff.textContent = unifiedDiff(file.before, file.text, file.path);
113
- card.append(diff);
171
+ const stat = diffStat(file.before, file.text);
172
+ const toggle = el("button", { className: "ghbtn", textContent: `Show changes (+${stat.added} −${stat.removed})` });
173
+ toggle.onclick = () => {
174
+ diff.hidden = !diff.hidden;
175
+ toggle.textContent = (diff.hidden ? "Show changes" : "Hide changes") + ` (+${stat.added} −${stat.removed})`;
176
+ };
114
177
 
178
+ const row = el("div", { className: "row" });
115
179
  if (file.writable) {
116
- const save = el("button", { className: "btn primary", textContent: "Save and commit" });
180
+ const save = el("button", { className: "btn primary", textContent: "Save" });
117
181
  save.onclick = async () => {
118
182
  save.disabled = true;
119
183
  save.textContent = "Saving…";
@@ -124,17 +188,19 @@ export function paste(opts) {
124
188
  opts.onSaved?.();
125
189
  } catch (err) {
126
190
  save.disabled = false;
127
- save.textContent = "Save and commit";
191
+ save.textContent = "Save";
128
192
  card.append(el("p", { className: "err", textContent: String(err.message || err) }));
129
193
  }
130
194
  };
131
- card.append(save);
195
+ row.append(save);
132
196
  }
197
+ row.append(toggle);
198
+ card.append(row, diff);
133
199
  result.append(card);
134
200
  }
135
201
 
136
202
  if (!(data.problems ?? []).length && !(data.files ?? []).length) {
137
- result.append(el("p", { className: "sub", textContent: "Nothing to do." }));
203
+ result.append(el("p", { className: "verdict bad", textContent: "✗ No file found in that reply." }));
138
204
  }
139
205
  };
140
206
 
@@ -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
- /* Build the paste-ready brief on the server, where compose.mjs lives, and copy it.
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
- if (view.problems?.length) {
144
- const bad = group("Problems", String(view.problems.length));
145
- for (const p of view.problems) bad.append(problemRow(p));
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
- viewer.append(ta);
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
- if (!confirm("Commit " + layer.path + " to " + layer.repo + " and push?\n\n" +
392
- "Picked up by " + reach + ".")) return;
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: string[], onAdded?: () => void}} opts
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
- box.append(
17
- el("b", { textContent: "Which repos the staff work in" }),
18
- el("p", {
19
- textContent:
20
- "Repos marked as products are the ones staff contribute to without owning. A new hire " +
21
- "picks them up automatically.",
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
- listRepos(opts.org)
30
- .then((data) => {
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(opts.known ?? []);
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
+ }