@nanocollective/roster 0.1.0-alpha.51 → 0.1.0-alpha.53

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/dist/cli.js CHANGED
@@ -2892,30 +2892,36 @@ function overBudget(doc, dir, budgets) {
2892
2892
  }
2893
2893
 
2894
2894
  // src/lib/runs.ts
2895
- import { existsSync as existsSync15, mkdtempSync as mkdtempSync2, readFileSync as readFileSync14, rmSync as rmSync2 } from "fs";
2896
- import { tmpdir as tmpdir2 } from "os";
2895
+ import { existsSync as existsSync15, mkdirSync as mkdirSync3, mkdtempSync as mkdtempSync2, readFileSync as readFileSync14, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "fs";
2896
+ import { homedir, tmpdir as tmpdir2 } from "os";
2897
2897
  import { join as join16 } from "path";
2898
2898
  var KINDS2 = ["daily", "mention"];
2899
2899
  var records = /* @__PURE__ */ new Map();
2900
- async function staffRuns(brain, handle, days = 30) {
2900
+ var fetching = /* @__PURE__ */ new Set();
2901
+ function cacheDir() {
2902
+ return process.env.ROSTER_CACHE_DIR ?? join16(homedir(), ".cache", "roster", "runs");
2903
+ }
2904
+ function cacheFile(brain, id) {
2905
+ return join16(cacheDir(), brain.replace("/", "__"), `${id}.json`);
2906
+ }
2907
+ function knownRecord(brain, id) {
2908
+ const key = `${brain}#${id}`;
2909
+ if (records.has(key)) return records.get(key);
2910
+ const file = cacheFile(brain, id);
2911
+ if (!existsSync15(file)) return void 0;
2912
+ try {
2913
+ const record = JSON.parse(readFileSync14(file, "utf8")).record;
2914
+ records.set(key, record);
2915
+ return record;
2916
+ } catch {
2917
+ return void 0;
2918
+ }
2919
+ }
2920
+ async function staffRuns(brain, handle, days = 30, opts = {}) {
2901
2921
  const since = new Date(Date.now() - days * 864e5).toISOString().slice(0, 10);
2902
2922
  const lists = await Promise.all(
2903
2923
  KINDS2.map(
2904
- (kind) => ghJson([
2905
- "run",
2906
- "list",
2907
- "--repo",
2908
- brain,
2909
- "--workflow",
2910
- `${handle}-${kind}.yaml`,
2911
- "--created",
2912
- `>=${since}`,
2913
- "--limit",
2914
- // Skipped mentions (the gate saying no) count towards the limit, and there are hundreds.
2915
- kind === "mention" ? "500" : "100",
2916
- "--json",
2917
- "databaseId,conclusion,status,createdAt,updatedAt,url"
2918
- ]).then((res) => ({ kind, res }))
2924
+ (kind) => realRuns(brain, `${handle}-${kind}.yaml`, since).then((res) => ({ kind, res }))
2919
2925
  )
2920
2926
  );
2921
2927
  const errors = [];
@@ -2943,14 +2949,71 @@ async function staffRuns(brain, handle, days = 30) {
2943
2949
  }
2944
2950
  }
2945
2951
  rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
2946
- await pool(
2947
- rows.filter((r) => r.status === "completed"),
2948
- 6,
2949
- async (row) => {
2950
- row.record = await recordFor(brain, row.id);
2952
+ const missing = [];
2953
+ for (const row of rows.filter((r) => r.status === "completed")) {
2954
+ const known = knownRecord(brain, row.id);
2955
+ if (known === void 0) missing.push(row);
2956
+ else row.record = known;
2957
+ }
2958
+ if (opts.wait === false) {
2959
+ const todo = missing.filter((r) => !fetching.has(`${brain}#${r.id}`));
2960
+ for (const r of todo) fetching.add(`${brain}#${r.id}`);
2961
+ void pool(todo, 6, async (row) => {
2962
+ await recordFor(brain, row.id);
2963
+ fetching.delete(`${brain}#${row.id}`);
2964
+ });
2965
+ return { runs: rows, errors, pending: missing.length };
2966
+ }
2967
+ await pool(missing, 6, async (row) => {
2968
+ row.record = await recordFor(brain, row.id);
2969
+ });
2970
+ return { runs: rows, errors, pending: 0 };
2971
+ }
2972
+ var OUTCOMES = [
2973
+ "success",
2974
+ "failure",
2975
+ "cancelled",
2976
+ "timed_out",
2977
+ "action_required",
2978
+ "startup_failure",
2979
+ "in_progress",
2980
+ "queued"
2981
+ ];
2982
+ async function realRuns(brain, workflow, since) {
2983
+ const one = async (status) => {
2984
+ const out = [];
2985
+ for (let page2 = 1; page2 <= 10; page2++) {
2986
+ const res = await ghJson([
2987
+ "api",
2988
+ `repos/${brain}/actions/workflows/${workflow}/runs?created=>=${since}&status=${status}&per_page=100&page=${page2}`
2989
+ ]);
2990
+ if (!res.ok) return { ok: false, error: res.error };
2991
+ const runs = res.data?.workflow_runs ?? [];
2992
+ out.push(...runs);
2993
+ if (runs.length < 100) break;
2951
2994
  }
2952
- );
2953
- return { runs: rows, errors };
2995
+ return { ok: true, runs: out };
2996
+ };
2997
+ const answers = await Promise.all(OUTCOMES.map(one));
2998
+ const failed = answers.find((a) => !a.ok);
2999
+ if (failed && !failed.ok) return { ok: false, error: failed.error };
3000
+ const seen = /* @__PURE__ */ new Set();
3001
+ const data = [];
3002
+ for (const a of answers) {
3003
+ for (const r of a.ok ? a.runs : []) {
3004
+ if (seen.has(r.id)) continue;
3005
+ seen.add(r.id);
3006
+ data.push({
3007
+ databaseId: r.id,
3008
+ conclusion: r.conclusion,
3009
+ status: r.status,
3010
+ createdAt: r.created_at,
3011
+ updatedAt: r.updated_at,
3012
+ url: r.html_url
3013
+ });
3014
+ }
3015
+ }
3016
+ return { ok: true, data };
2954
3017
  }
2955
3018
  async function recordFor(brain, id) {
2956
3019
  const key = `${brain}#${id}`;
@@ -2971,6 +3034,13 @@ async function recordFor(brain, id) {
2971
3034
  const file = join16(dir, "run.json");
2972
3035
  const record = res.ok && existsSync15(file) ? JSON.parse(readFileSync14(file, "utf8")) : null;
2973
3036
  records.set(key, record);
3037
+ if (res.ok || /no valid artifacts|not found|no artifact/i.test(res.error ?? "")) {
3038
+ try {
3039
+ mkdirSync3(join16(cacheDir(), brain.replace("/", "__")), { recursive: true });
3040
+ writeFileSync4(cacheFile(brain, id), JSON.stringify({ record }));
3041
+ } catch {
3042
+ }
3043
+ }
2974
3044
  return record;
2975
3045
  } catch {
2976
3046
  records.set(key, null);
@@ -3005,19 +3075,19 @@ function budgetOf(value) {
3005
3075
  import { execFileSync as execFileSync5 } from "child_process";
3006
3076
  import {
3007
3077
  existsSync as existsSync16,
3008
- mkdirSync as mkdirSync3,
3078
+ mkdirSync as mkdirSync4,
3009
3079
  mkdtempSync as mkdtempSync4,
3010
3080
  readdirSync as readdirSync5,
3011
3081
  readFileSync as readFileSync15,
3012
3082
  rmSync as rmSync4,
3013
- writeFileSync as writeFileSync5
3083
+ writeFileSync as writeFileSync6
3014
3084
  } from "fs";
3015
3085
  import { tmpdir as tmpdir4 } from "os";
3016
3086
  import { dirname as dirname6, join as join18 } from "path";
3017
3087
 
3018
3088
  // src/lib/merge.ts
3019
3089
  import { execFileSync as execFileSync4 } from "child_process";
3020
- import { mkdtempSync as mkdtempSync3, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "fs";
3090
+ import { mkdtempSync as mkdtempSync3, rmSync as rmSync3, writeFileSync as writeFileSync5 } from "fs";
3021
3091
  import { tmpdir as tmpdir3 } from "os";
3022
3092
  import { join as join17 } from "path";
3023
3093
  function merge3(current, base2, incoming) {
@@ -3026,9 +3096,9 @@ function merge3(current, base2, incoming) {
3026
3096
  const c = join17(dir, "current");
3027
3097
  const b = join17(dir, "base");
3028
3098
  const i = join17(dir, "incoming");
3029
- writeFileSync4(c, current);
3030
- writeFileSync4(b, base2);
3031
- writeFileSync4(i, incoming);
3099
+ writeFileSync5(c, current);
3100
+ writeFileSync5(b, base2);
3101
+ writeFileSync5(i, incoming);
3032
3102
  try {
3033
3103
  const text = execFileSync4(
3034
3104
  "git",
@@ -3237,8 +3307,8 @@ function apply(opsDir, seedDir, tplDir, plans) {
3237
3307
  if (p.verdict === "edited-managed") edited++;
3238
3308
  if (p.verdict === "conflict") {
3239
3309
  const out = join18(opsDir, p.rel + ".roster-merge");
3240
- mkdirSync3(dirname6(out), { recursive: true });
3241
- writeFileSync5(out, p.rejected);
3310
+ mkdirSync4(dirname6(out), { recursive: true });
3311
+ writeFileSync6(out, p.rejected);
3242
3312
  process.stdout.write(` wrote ${p.rel}.roster-merge \u2014 resolve it, then re-run
3243
3313
  `);
3244
3314
  left++;
@@ -3253,8 +3323,8 @@ function apply(opsDir, seedDir, tplDir, plans) {
3253
3323
  continue;
3254
3324
  }
3255
3325
  const dest = join18(opsDir, p.rel);
3256
- mkdirSync3(dirname6(dest), { recursive: true });
3257
- writeFileSync5(dest, p.next);
3326
+ mkdirSync4(dirname6(dest), { recursive: true });
3327
+ writeFileSync6(dest, p.next);
3258
3328
  seed(seedDir, tplDir, p.rel);
3259
3329
  wrote++;
3260
3330
  }
@@ -3263,7 +3333,7 @@ function apply(opsDir, seedDir, tplDir, plans) {
3263
3333
  process.stdout.write(left ? `, ${left} left for you
3264
3334
 
3265
3335
  ` : "\n\n");
3266
- if (!left) writeFileSync5(join18(opsDir, ".roster-version"), stamp() + "\n");
3336
+ if (!left) writeFileSync6(join18(opsDir, ".roster-version"), stamp() + "\n");
3267
3337
  if (edited) {
3268
3338
  process.stdout.write(
3269
3339
  ` ${edited} framework file${edited === 1 ? "" : "s"} still carr${edited === 1 ? "ies" : "y"} a local edit. Move it into the framework, or doctor keeps failing.
@@ -3275,8 +3345,8 @@ function apply(opsDir, seedDir, tplDir, plans) {
3275
3345
  }
3276
3346
  function seed(seedDir, tplDir, rel) {
3277
3347
  const dest = join18(seedDir, rel);
3278
- mkdirSync3(dirname6(dest), { recursive: true });
3279
- writeFileSync5(dest, readFileSync15(join18(tplDir, rel), "utf8"));
3348
+ mkdirSync4(dirname6(dest), { recursive: true });
3349
+ writeFileSync6(dest, readFileSync15(join18(tplDir, rel), "utf8"));
3280
3350
  }
3281
3351
  function writeBaseline(opsDir, seedDir, tplDir, ref) {
3282
3352
  const framework = join18(tplDir, "..", "..");
@@ -3301,10 +3371,10 @@ function writeBaseline(opsDir, seedDir, tplDir, ref) {
3301
3371
  maxBuffer: 16 * 1024 * 1024
3302
3372
  });
3303
3373
  const dest = join18(seedDir, rel);
3304
- mkdirSync3(dirname6(dest), { recursive: true });
3305
- writeFileSync5(dest, text);
3374
+ mkdirSync4(dirname6(dest), { recursive: true });
3375
+ writeFileSync6(dest, text);
3306
3376
  }
3307
- writeFileSync5(join18(opsDir, ".roster-version"), `${ref}
3377
+ writeFileSync6(join18(opsDir, ".roster-version"), `${ref}
3308
3378
  `);
3309
3379
  process.stdout.write(
3310
3380
  `
@@ -3408,8 +3478,8 @@ function planBrains(ws, parseYaml) {
3408
3478
  function unified(before, after) {
3409
3479
  const dir = mkdtempSync4(join18(tmpdir4(), "roster-diff-"));
3410
3480
  try {
3411
- writeFileSync5(join18(dir, "a"), before);
3412
- writeFileSync5(join18(dir, "b"), after);
3481
+ writeFileSync6(join18(dir, "a"), before);
3482
+ writeFileSync6(join18(dir, "b"), after);
3413
3483
  try {
3414
3484
  execFileSync5(
3415
3485
  "diff",
@@ -3481,8 +3551,8 @@ function applyBrains(brains) {
3481
3551
  }
3482
3552
  if (f.next === void 0) continue;
3483
3553
  const dest = join18(b.root, f.rel);
3484
- mkdirSync3(dirname6(dest), { recursive: true });
3485
- writeFileSync5(dest, f.next);
3554
+ mkdirSync4(dirname6(dest), { recursive: true });
3555
+ writeFileSync6(dest, f.next);
3486
3556
  wrote++;
3487
3557
  }
3488
3558
  }
@@ -4516,7 +4586,7 @@ function unkindedAsks(scope, repo, issues, humans) {
4516
4586
  }
4517
4587
 
4518
4588
  // src/commands/export.ts
4519
- import { writeFileSync as writeFileSync6 } from "fs";
4589
+ import { writeFileSync as writeFileSync7 } from "fs";
4520
4590
 
4521
4591
  // src/lib/export.ts
4522
4592
  import { execFileSync as execFileSync6 } from "child_process";
@@ -4707,7 +4777,7 @@ async function exportCommand(argv) {
4707
4777
  const data = buildExport(ws, readOrg(ws.opsDir, parseYaml), parseYaml);
4708
4778
  const json2 = JSON.stringify(data, null, 2);
4709
4779
  if (out) {
4710
- writeFileSync6(out, json2 + "\n");
4780
+ writeFileSync7(out, json2 + "\n");
4711
4781
  const facts = data.staff.reduce((n, s) => n + s.facts.length, 0);
4712
4782
  process.stdout.write(
4713
4783
  ` ${out} ${data.staff.length} staff, ${facts} facts, ${Math.round(json2.length / 1024)}kb
@@ -5062,7 +5132,7 @@ function parseFlags7(argv) {
5062
5132
 
5063
5133
  // src/commands/init.ts
5064
5134
  import { execFileSync as execFileSync7 } from "child_process";
5065
- import { existsSync as existsSync20, mkdirSync as mkdirSync4, readFileSync as readFileSync19, writeFileSync as writeFileSync7 } from "fs";
5135
+ import { existsSync as existsSync20, mkdirSync as mkdirSync5, readFileSync as readFileSync19, writeFileSync as writeFileSync8 } from "fs";
5066
5136
  import { dirname as dirname7, join as join22, resolve as resolve3 } from "path";
5067
5137
  var initHelp = `
5068
5138
  roster init --org <github-org> [--name "Acme"] [--human <login>] [--apply]
@@ -5155,16 +5225,16 @@ async function initCommand(argv) {
5155
5225
  process.stdout.write(" Nothing was created. Re-run with --apply.\n\n");
5156
5226
  return 0;
5157
5227
  }
5158
- mkdirSync4(opsDir, { recursive: true });
5228
+ mkdirSync5(opsDir, { recursive: true });
5159
5229
  for (const [rel, text] of files) {
5160
5230
  const dest = join22(opsDir, rel);
5161
- mkdirSync4(dirname7(dest), { recursive: true });
5162
- writeFileSync7(dest, text);
5231
+ mkdirSync5(dirname7(dest), { recursive: true });
5232
+ writeFileSync8(dest, text);
5163
5233
  }
5164
5234
  for (const rel of templateFiles(opsTemplateDir())) {
5165
5235
  const dest = join22(opsDir, ".roster", "seed", rel);
5166
- mkdirSync4(dirname7(dest), { recursive: true });
5167
- writeFileSync7(dest, readFileSync19(join22(opsTemplateDir(), rel), "utf8"));
5236
+ mkdirSync5(dirname7(dest), { recursive: true });
5237
+ writeFileSync8(dest, readFileSync19(join22(opsTemplateDir(), rel), "utf8"));
5168
5238
  }
5169
5239
  process.stdout.write(` wrote ${files.size} files and the recorded base
5170
5240
  `);
@@ -5776,7 +5846,7 @@ function execWithStdin(args, input) {
5776
5846
 
5777
5847
  // src/lib/attach.ts
5778
5848
  import { execFileSync as execFileSync8 } from "child_process";
5779
- import { existsSync as existsSync21, mkdirSync as mkdirSync5, writeFileSync as writeFileSync8 } from "fs";
5849
+ import { existsSync as existsSync21, mkdirSync as mkdirSync6, writeFileSync as writeFileSync9 } from "fs";
5780
5850
  import { basename, extname, join as join24 } from "path";
5781
5851
  var MAX_UPLOAD = 25 * 1024 * 1024;
5782
5852
  var ATTACH_DIR = "attachments";
@@ -5791,8 +5861,8 @@ function attach(opts) {
5791
5861
  }
5792
5862
  const rel = free(repoDir, `${ATTACH_DIR}/${today}-${safeName(opts.name)}`);
5793
5863
  const full = join24(repoDir, rel);
5794
- mkdirSync5(join24(repoDir, ATTACH_DIR), { recursive: true });
5795
- writeFileSync8(full, data);
5864
+ mkdirSync6(join24(repoDir, ATTACH_DIR), { recursive: true });
5865
+ writeFileSync9(full, data);
5796
5866
  const git4 = (args) => execFileSync8("git", ["-C", repoDir, ...args], {
5797
5867
  encoding: "utf8",
5798
5868
  stdio: ["ignore", "pipe", "pipe"]
@@ -6501,7 +6571,7 @@ function portalAsset(rel) {
6501
6571
  }
6502
6572
 
6503
6573
  // src/commands/retire.ts
6504
- import { existsSync as existsSync24, readdirSync as readdirSync8, readFileSync as readFileSync21, writeFileSync as writeFileSync9 } from "fs";
6574
+ import { existsSync as existsSync24, readdirSync as readdirSync8, readFileSync as readFileSync21, writeFileSync as writeFileSync10 } from "fs";
6505
6575
  import { join as join27 } from "path";
6506
6576
  var retireHelp = `
6507
6577
  roster retire <handle>
@@ -6637,7 +6707,7 @@ async function applyRetirePlan(ws, plan2) {
6637
6707
  for (const p of plan2.peers) {
6638
6708
  const path = join27(ws.root, p.dir, "staff.yaml");
6639
6709
  const text = readFileSync21(path, "utf8");
6640
- writeFileSync9(path, removePeerLine(text, plan2.handle));
6710
+ writeFileSync10(path, removePeerLine(text, plan2.handle));
6641
6711
  process.stdout.write(` removed from ${p.dir}/staff.yaml
6642
6712
  `);
6643
6713
  if (!p.brain) continue;
@@ -6665,7 +6735,7 @@ async function applyRetirePlan(ws, plan2) {
6665
6735
  function removeFromOrgYaml(ws, plan2) {
6666
6736
  const path = join27(ws.opsDir, "org.yaml");
6667
6737
  const text = readFileSync21(path, "utf8");
6668
- writeFileSync9(path, removeOrgLines(text, plan2.handle, plan2.dir));
6738
+ writeFileSync10(path, removeOrgLines(text, plan2.handle, plan2.dir));
6669
6739
  }
6670
6740
  function removeOrgLines(text, handle, dir) {
6671
6741
  return text.split("\n").filter((line) => {
@@ -7777,7 +7847,9 @@ async function portalCommand(argv) {
7777
7847
  const staff = await Promise.all(
7778
7848
  (org.staff ?? []).map(async (s) => {
7779
7849
  const brain = `${org.org}/${s.dir ?? s.handle}`;
7780
- const { runs, errors } = await staffRuns(brain, s.handle);
7850
+ const { runs, errors, pending: pending2 } = await staffRuns(brain, s.handle, 30, {
7851
+ wait: false
7852
+ });
7781
7853
  return {
7782
7854
  handle: s.handle,
7783
7855
  name: s.name ?? s.handle,
@@ -7785,7 +7857,8 @@ async function portalCommand(argv) {
7785
7857
  budget: budgetOf(s.budget),
7786
7858
  spend: spend(runs),
7787
7859
  runs,
7788
- errors
7860
+ errors,
7861
+ pending: pending2
7789
7862
  };
7790
7863
  })
7791
7864
  );
@@ -7795,14 +7868,17 @@ async function portalCommand(argv) {
7795
7868
  total.known += s.spend.known;
7796
7869
  total.runs += s.spend.runs;
7797
7870
  }
7871
+ const pending = staff.reduce((n, s) => n + s.pending, 0);
7798
7872
  const body2 = {
7799
7873
  online: true,
7800
7874
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
7801
7875
  budget: budgetOf(org.budget),
7802
7876
  spend: total,
7803
- staff
7877
+ staff,
7878
+ // Costs still being read. The screen asks again until this is 0.
7879
+ pending
7804
7880
  };
7805
- runsCache = { at: Date.now(), body: body2 };
7881
+ if (!pending) runsCache = { at: Date.now(), body: body2 };
7806
7882
  return body2;
7807
7883
  })().then((body2) => json(res, body2)).catch(
7808
7884
  (err) => json(res, { online: false, error: String(err?.message ?? err), staff: [] })
@@ -8149,7 +8225,7 @@ function parseFlags11(argv) {
8149
8225
 
8150
8226
  // src/commands/prompt.ts
8151
8227
  import { execFileSync as execFileSync10 } from "child_process";
8152
- import { existsSync as existsSync26, mkdtempSync as mkdtempSync5, readFileSync as readFileSync24, writeFileSync as writeFileSync10 } from "fs";
8228
+ import { existsSync as existsSync26, mkdtempSync as mkdtempSync5, readFileSync as readFileSync24, writeFileSync as writeFileSync11 } from "fs";
8153
8229
  import { tmpdir as tmpdir5 } from "os";
8154
8230
  import { join as join29 } from "path";
8155
8231
 
@@ -8220,8 +8296,8 @@ async function promptCommand(argv) {
8220
8296
  const dir = mkdtempSync5(join29(tmpdir5(), "roster-diff-"));
8221
8297
  const a = join29(dir, "live.md");
8222
8298
  const b = join29(dir, "composed.md");
8223
- writeFileSync10(a, normalise(live));
8224
- writeFileSync10(b, normalise(composed));
8299
+ writeFileSync11(a, normalise(live));
8300
+ writeFileSync11(b, normalise(composed));
8225
8301
  const entry = org.staff?.find((s) => s.handle === handle);
8226
8302
  process.stdout.write(`
8227
8303
  ${entry?.name ?? handle} \xB7 ${kind}
@@ -8257,7 +8333,7 @@ async function gatherInflight(opsDir, root, handle) {
8257
8333
  const manifest = existsSync26(manifestPath) ? parseYaml(readFileSync24(manifestPath, "utf8"), "staff.yaml") : {};
8258
8334
  const gh = (args) => execFileSync10("gh", args, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
8259
8335
  const dir = mkdtempSync5(join29(tmpdir5(), "roster-inflight-"));
8260
- writeFileSync10(join29(dir, "inflight.md"), describe2(gather2({ org, manifest, gh })));
8336
+ writeFileSync11(join29(dir, "inflight.md"), describe2(gather2({ org, manifest, gh })));
8261
8337
  return dir;
8262
8338
  }
8263
8339
  function normalise(text) {
Binary file
Binary file
Binary file
Binary file
Binary file
package/docs/portal.md CHANGED
@@ -80,12 +80,15 @@ goes away; the repo picker lives on under [Org](#org).
80
80
 
81
81
  ## Home
82
82
 
83
+ ![Home: latest reports, what needs you, and the Ask box](images/home.jpg)
84
+
83
85
  Where the portal opens. An Ask box at the top, then these, in order.
84
86
 
85
87
  - **Ask.** Pick a staff member, type what you want, **Send**. It opens an issue on their
86
88
  tracker with their `@handle` in front, which wakes them in about a minute.
87
89
  - **Latest reports.** Each staff member's run report from the last day: the three lines they
88
- post on their status issue at the end of a daily run. Nothing shows until there is one.
90
+ post on their status issue at the end of a daily run, with the issues and pull requests it
91
+ names as links. Click a report to open their status issue. Nothing shows until there is one.
89
92
  - **Needs you.** Every ask a staff member has put on you, and every pull request ready to
90
93
  merge, oldest first. Each card has one action on its right:
91
94
  - a `decision` has **Reply**, and **Go with the default** when it carries one. The default
@@ -260,8 +263,14 @@ than drawing an empty table that reads as "nothing ran".
260
263
 
261
264
  Cost comes from the record each run leaves behind (see [cost](cost.md#what-each-run-cost)). A
262
265
  run from before records existed, or from an agent that does not report cost, shows a dash, and
263
- a total says how many runs it could price. Each record is downloaded once and kept for as long
264
- as the portal runs, so the first visit is the slow one.
266
+ a total says how many runs it could price. Each record is downloaded once, ever, and kept in
267
+ `~/.cache/roster/runs` (or `ROSTER_CACHE_DIR`), since a finished run's record never changes.
268
+ The table shows at once with the costs already known; any not yet read are fetched in the
269
+ background, and the screen says how many and fills them in as they arrive.
270
+
271
+ The run list asks GitHub for each outcome separately (succeeded, failed, cancelled and so on)
272
+ rather than paging through every run, because most of a mention caller's runs are comments that
273
+ woke nobody and were skipped.
265
274
 
266
275
  A skipped mention is not a run and is not listed. A `setup-failure` is a run that failed before
267
276
  the agent started, usually a token or a checkout. Past a [`budget`](org-yaml.md#budget), the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanocollective/roster",
3
- "version": "0.1.0-alpha.51",
3
+ "version": "0.1.0-alpha.53",
4
4
  "description": "An agent-run org, powered by GitHub. Scaffold AI staff members whose brain is a repo.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -66,19 +66,25 @@
66
66
  .homecard.gone,.homeline.gone{opacity:0;transform:translateX(12px);
67
67
  transition:opacity .2s var(--ease),transform .2s var(--ease)}
68
68
 
69
- /* Activity you have not seen, as Trackers marks it: a blue edge and a bold title. Opening the
70
- thread marks it read, here and on GitHub. */
69
+ /* Activity you have not seen: a blue dot and a bold title, and on a card in Needs you a blue
70
+ edge that follows its corners. A row inside a list card carries no edge, since a bar drawn to
71
+ the card's side squares off its rounded corners. Opening the thread marks it read, here and on
72
+ GitHub. */
71
73
  .homecard.unread{box-shadow:inset 3px 0 0 var(--blue)}
72
- .homeline.unread{box-shadow:inset 3px 0 0 var(--blue);margin:0 -16px;padding-left:13px;padding-right:16px}
73
74
  .unread .homeopen,.unread .homerowtitle{font-weight:700}
74
75
  .unread .homeopen::before,.unread .homerowtitle::before{content:"";display:inline-block;width:7px;height:7px;border-radius:50%;
75
76
  background:var(--blue);margin:0 7px 1px 0;vertical-align:middle}
76
77
 
77
78
  .homereports{display:flex;flex-direction:column;gap:14px;padding:14px 16px}
78
- .homereport{display:grid;grid-template-columns:190px 1fr;gap:12px;font-size:13px;line-height:1.5}
79
- .homereport p{margin:0 0 3px;color:var(--ink-dim)}
80
- .homereport p:first-child{color:var(--ink)}
81
- .homereport .homewho2{align-items:baseline;flex-wrap:wrap;min-width:0}
79
+ .homereport{display:grid;grid-template-columns:190px 1fr;gap:12px;font-size:13px;line-height:1.5;
80
+ cursor:pointer;border-radius:8px;padding:6px 8px;margin:-6px -8px}
81
+ .homereport:hover{background:var(--hover)}
82
+ .homereport:focus-visible{outline:2px solid var(--accent);outline-offset:0}
83
+ .homereportwho{display:flex;flex-direction:column;gap:2px;min-width:0}
84
+ .homereportwho .meta{padding-left:21px}
85
+ .homereportbody p{margin:0 0 4px;color:var(--ink-dim);font-size:13px;line-height:1.5}
86
+ .homereportbody p:first-child{color:var(--ink)}
87
+ .homereportbody a{color:var(--accent)}
82
88
  @media (max-width:640px){.homereport{grid-template-columns:1fr;gap:4px}}
83
89
 
84
90
  @media (max-width:640px){
@@ -8,6 +8,7 @@ import { getLive, post } from "../api.js";
8
8
  import { sheet } from "../dialog.js";
9
9
  import { ago, el, skeleton } from "../dom.js";
10
10
  import { daysUntil, sortHome } from "../homesort.js";
11
+ import { mdlite } from "../md.js";
11
12
  import { icon, iconHTML, staffIcon } from "../icons.js";
12
13
  import { ensureInbox, stampCounts } from "../refresh.js";
13
14
  import { go, render } from "../router.js";
@@ -58,16 +59,29 @@ export function viewHome(m) {
58
59
  page.append(section("Latest reports"));
59
60
  const box = el("div", { className: "card homereports" });
60
61
  for (const r of h.reports) {
61
- box.append(
62
- el("div", { className: "homereport" }, [
63
- el("div", { className: "homewho2" }, [
64
- icon(staffIcon(r.staff), "ic"),
65
- el("b", { textContent: r.staff.name }),
66
- el("span", { className: "meta", textContent: ago(r.at) }),
67
- ]),
68
- el("div", {}, r.lines.map((l) => el("p", { textContent: l }))),
62
+ /* The report is markdown the staff member wrote, with links to the work it names. The
63
+ whole report opens their status issue, where it was posted; a link inside it goes
64
+ where it points. */
65
+ const row = el("div", { className: "homereport homeclick", tabIndex: 0, role: "button" }, [
66
+ el("div", { className: "homereportwho" }, [
67
+ el("span", { className: "homewho2" }, [icon(staffIcon(r.staff), "ic"), el("b", { textContent: r.staff.name })]),
68
+ el("span", { className: "meta", textContent: ago(r.at) }),
69
69
  ]),
70
- );
70
+ el("div", {
71
+ className: "md homereportbody",
72
+ innerHTML: mdlite(r.lines.join("\n\n"), { repo: r.staff.brain }),
73
+ }),
74
+ ]);
75
+ row.onclick = (ev) => {
76
+ if (!ev.target.closest("a, button")) openThread(r.item);
77
+ };
78
+ row.onkeydown = (ev) => {
79
+ if (ev.target === row && (ev.key === "Enter" || ev.key === " ")) {
80
+ ev.preventDefault();
81
+ openThread(r.item);
82
+ }
83
+ };
84
+ box.append(row);
71
85
  }
72
86
  page.append(box);
73
87
  }
@@ -77,6 +77,20 @@ function paint(m, data) {
77
77
  g.append(tile("Runs", String(data.spend?.runs ?? 0), "skipped mentions are not counted"));
78
78
  m.append(g);
79
79
 
80
+ /* The table shows at once with the costs already known; the rest are read from GitHub in the
81
+ background, and the screen asks again until they are all in. */
82
+ if (data.pending) {
83
+ m.append(
84
+ el("p", {
85
+ className: "hnote",
86
+ textContent: "Reading the cost of " + data.pending + " run" + (data.pending === 1 ? "" : "s") + " from GitHub…",
87
+ }),
88
+ );
89
+ setTimeout(() => {
90
+ if (S.view === "runs") load(true);
91
+ }, 3000);
92
+ }
93
+
80
94
  for (const s of data.staff ?? []) m.append(...staffSection(s));
81
95
 
82
96
  m.append(