seisin 0.6.1 → 0.6.3

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/ui/index.html CHANGED
@@ -447,6 +447,28 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
447
447
  .preview input{width:100%}
448
448
  .places li{flex-direction:column;gap:2px}
449
449
  }
450
+
451
+ /* ── a directory of runs (seisin ui --runs) ─────────────────────────────── */
452
+ /* `hidden` has to win over the display these set, or the runs view keeps the policy's nav and cards. */
453
+ .side a[hidden],.side .cap[hidden],.cards[hidden],.range[hidden]{display:none}
454
+ .banner.run{border-left:3px solid var(--blue)}
455
+ .banner.run .meta{color:var(--mid);font-size:12px;margin-top:4px}
456
+ .runs-filters{display:flex;flex-wrap:wrap;gap:10px 16px;align-items:center}
457
+ .runs-filters label{display:flex;gap:6px;align-items:center;font-size:12px;color:var(--mid)}
458
+ .runs-filters select,.runs-filters input{font:inherit;font-size:12px;padding:4px 6px;border:1px solid var(--line);
459
+ border-radius:6px;background:var(--panel);color:var(--fg);max-width:220px}
460
+ table.runs{width:100%;border-collapse:collapse;font-size:12px}
461
+ table.runs th{text-align:left;font-weight:600;color:var(--dim);padding:6px 10px;border-bottom:1px solid var(--line);white-space:nowrap}
462
+ table.runs td{padding:6px 10px;border-bottom:1px solid var(--line2);vertical-align:top}
463
+ table.runs td.num,table.runs th.num{text-align:right;font-variant-numeric:tabular-nums}
464
+ table.runs td code{font-family:var(--mono);font-size:11px;word-break:break-all}
465
+ table.runs tr.sys td{color:var(--dim)}
466
+ .oc{font-weight:600;font-size:11px;padding:1px 6px;border-radius:9px;border:1px solid var(--line)}
467
+ .oc.clean{color:var(--green);border-color:var(--green)}
468
+ .oc.denied{color:var(--amber);border-color:var(--amber)}
469
+ .oc.refused,.oc.unread{color:var(--fg);background:var(--amber-bg);border-color:var(--amber)}
470
+ .ro-ask{font-size:12px;color:var(--mid);max-width:320px}
471
+ .ro-ask code{font-family:var(--mono)}
450
472
  </style>
451
473
  </head>
452
474
  <body>
@@ -462,7 +484,13 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
462
484
  </div>
463
485
  </div>
464
486
  <div class="list">
465
- <div class="cap">Policy</div>
487
+ <!-- Over a directory of runs (seisin ui --runs) the first item is every run, and the
488
+ rest is the one run that is open; with none open, only this one is shown. -->
489
+ <div id="nav-runs" hidden>
490
+ <div class="cap">Runs</div>
491
+ <a href="./" id="nav-all" title="Every run in the directory, read only"><i>☰</i>All runs<span class="pin" id="runs-pin"></span></a>
492
+ </div>
493
+ <div class="cap" id="nav-cap">Policy</div>
466
494
  <a class="on" href="#requests" id="nav-req" aria-current="page" title="Requests: access an agent asked for and cannot have"><i>⚖</i>Waiting on you<span class="pin" id="req-pin"></span></a>
467
495
  <a href="#denied" title="Every denial in the window: what an agent tried and the OS denied"><i>⊘</i>Denied<span class="pin" id="dn-pin"></span></a>
468
496
  <a href="#walls" title="A wall: something a role was denied twice or more and would still be denied today"><i>▦</i>Walls<span class="pin" id="wl-pin"></span></a>
@@ -490,6 +518,8 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
490
518
  <code>seisin ui</code> in it.
491
519
  </div>
492
520
  <div class="banner off" id="off-banner" role="alert" hidden></div>
521
+ <!-- One run out of a directory of them: who it was, and that nothing here is granted. -->
522
+ <div class="banner run" id="run-banner" hidden></div>
493
523
 
494
524
  <div class="head">
495
525
  <div>
@@ -533,6 +563,34 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
533
563
  <h2>spent retrying</h2><div class="n" id="c-retry">0</div><div class="s" id="c-retry-s">calls on known walls</div></a>
534
564
  </div>
535
565
 
566
+ <!-- Every run in the directory (seisin ui --runs): filtered, summed, newest first. -->
567
+ <section data-view="runs" hidden>
568
+ <div class="box">
569
+ <header>
570
+ <h2>Filter</h2>
571
+ <span class="note" id="rn-count"></span>
572
+ </header>
573
+ <div class="pad runs-filters" id="rn-filters">
574
+ <label>team <select data-f="team"></select></label>
575
+ <label>role <select data-f="role"></select></label>
576
+ <label>work item <select data-f="work_item"></select></label>
577
+ <label>status <select data-f="status"></select></label>
578
+ <label>outcome <select data-f="outcome"></select></label>
579
+ <label>search <input type="search" id="rn-q" spellcheck="false" autocomplete="off" placeholder="run id, work item, repo"></label>
580
+ </div>
581
+ </div>
582
+ <div class="cards" id="rn-kpis"></div>
583
+ <div class="box">
584
+ <header><h2>Denied across these runs</h2><span class="note">the same denial in many runs is one line · the OS's own housekeeping last</span></header>
585
+ <div class="scrollx"><table class="runs" id="rn-causes"></table></div>
586
+ </div>
587
+ <div class="box">
588
+ <header><h2>Runs</h2><span class="note">newest first · open one to see its policy, its denials and its log</span></header>
589
+ <div class="scrollx"><table class="runs" id="rn-list"></table></div>
590
+ <p class="blank quiet" id="rn-more" hidden></p>
591
+ </div>
592
+ </section>
593
+
536
594
  <section data-view="roles">
537
595
 
538
596
  <div class="box">
@@ -587,7 +645,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
587
645
  <section data-view="requests" hidden>
588
646
  <div class="box" id="requests">
589
647
  <header>
590
- <h2>Pending decisions</h2>
648
+ <h2 id="req-h">Pending decisions</h2>
591
649
  <span class="note" id="req-note">granting edits seisin.toml · here or in the terminal, never through MCP</span>
592
650
  <span class="bulk" id="bulk" hidden>
593
651
  <input id="why-all" placeholder="reason, for all of them (required)" aria-label="Reason for declining all">
@@ -656,7 +714,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
656
714
  <div class="foot">
657
715
  <b id="foot-mode">Preview.</b> <span id="foot-text">Opened as a file, this page edits an example policy in memory and
658
716
  shows what would be generated. Nothing is saved; run <b>seisin ui</b> in a repository to see yours.</span><br>
659
- The JSON matches <b>@anthropic-ai/sandbox-runtime 0.0.78</b>, whose schema the test suite exercises against the real binary.
717
+ The JSON is <b>@anthropic-ai/sandbox-runtime</b>'s settings schema, which the test suite exercises against the real binary.
660
718
  </div>
661
719
  </main>
662
720
  </div>
@@ -1245,7 +1303,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
1245
1303
  aria: "instruction files, for every role",
1246
1304
  click: function(){ propose({ protect: !prot }, "ctl-sw-protect", "instructions", "pw"); }
1247
1305
  }));
1248
- if (!r) { drawPreview(); return; }
1306
+ if (!r) { drawPreview(); if (READONLY) document.querySelectorAll("#pw button").forEach(function(b){ b.disabled = true; }); return; }
1249
1307
  ["ide", "instructions"].forEach(function(f){
1250
1308
  var mine = (r.controlFiles || []).indexOf(f) !== -1;
1251
1309
  var idle = f === "instructions" && !prot;
@@ -1267,6 +1325,9 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
1267
1325
  }));
1268
1326
  });
1269
1327
  drawPreview();
1328
+ if (READONLY) document.querySelectorAll("#pw button, #ctl button").forEach(function(b){
1329
+ b.disabled = true; b.title = "read only: this run's policy was lent by its launcher";
1330
+ });
1270
1331
  }
1271
1332
 
1272
1333
  async function callControls(body){
@@ -1687,7 +1748,8 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
1687
1748
  }
1688
1749
 
1689
1750
  set("k-wait", waiting ? "live" : "calm", waiting,
1690
- waiting ? (waiting === 1 ? "decision" : "decisions") : "nothing to decide");
1751
+ READONLY ? (waiting ? (waiting === 1 ? "request, asked for elsewhere" : "requests, asked for elsewhere") : "nothing asked for")
1752
+ : waiting ? (waiting === 1 ? "decision" : "decisions") : "nothing to decide");
1691
1753
  set("k-open", off ? "alarm" : "calm", off,
1692
1754
  off ? (off === 1 ? "role running unconfined" : "roles running unconfined") : "every role confined");
1693
1755
  set("k-ref", f.total ? "live" : "calm", f.total.toLocaleString(),
@@ -1759,6 +1821,12 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
1759
1821
  return narrowable(r) && f && f.checked ? { scope: "file", grant: r.target } : { scope: "folder", grant: r.grant };
1760
1822
  }
1761
1823
 
1824
+ /** Read only, where a request goes instead: the launcher that lent the run its territory. */
1825
+ function askedWhere(){
1826
+ var who = RUNINFO && RUNINFO.launcher;
1827
+ return who ? "Not granted here — ask for it in <b>" + esc(who) + "</b>:" : "Not granted here — ask for it where the run was launched:";
1828
+ }
1829
+
1762
1830
  function drawReqs(){
1763
1831
  var host = document.getElementById("reqs");
1764
1832
  pill("req-pin", REQS.length, REQS.length === 1 ? "request" : "requests");
@@ -1776,17 +1844,19 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
1776
1844
  return '<div class="req' + (r.stale ? ' stale' : '') + '">' +
1777
1845
  '<div class="who">' +
1778
1846
  "<div><b>" + esc(r.role) + "</b> wants to " + esc(r.action) + ' <span class="path">' + esc(r.target) + "</span></div>" +
1779
- scopeOf(r) + handoffOf(r) + scopeChoice(r, i) +
1847
+ scopeOf(r) + handoffOf(r) + (READONLY ? "" : scopeChoice(r, i)) +
1780
1848
  '<div class="sub"><span class="asked">' + times + "</span>" +
1781
1849
  (w.text ? ' · last <span title="' + escA(w.title) + '">' + esc(w.text) + "</span>" : "") + stale + "</div>" +
1782
1850
  '<div class="err" id="err-' + i + '" role="alert" hidden></div>' +
1783
1851
  "</div>" +
1784
- '<div class="act">' +
1852
+ (READONLY
1853
+ ? '<div class="act ro-ask">' + askedWhere() + ' <code>' + esc(r.grant) + "</code></div>"
1854
+ : '<div class="act">' +
1785
1855
  '<input id="why-' + i + '" placeholder="reason (required)" aria-label="Reason for ' +
1786
1856
  escA(r.role) + '’s request" aria-describedby="err-' + i + '">' +
1787
1857
  '<button class="btn primary" data-n="' + i + '" data-k="' + escA(r.key) + '" data-d="granted" type="button" data-write>Grant</button>' +
1788
1858
  '<button class="btn" data-n="' + i + '" data-k="' + escA(r.key) + '" data-d="declined" type="button" data-write>Decline</button>' +
1789
- "</div>" +
1859
+ "</div>") +
1790
1860
  "</div>";
1791
1861
  }).join("");
1792
1862
 
@@ -1829,7 +1899,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
1829
1899
  var bulk = document.getElementById("bulk");
1830
1900
  var btn = document.getElementById("decline-all");
1831
1901
  var why = document.getElementById("why-all");
1832
- bulk.hidden = REQS.length < 2;
1902
+ bulk.hidden = REQS.length < 2 || READONLY;
1833
1903
  btn.disabled = false;
1834
1904
  var rest = "Decline all (" + REQS.length + ")";
1835
1905
  disarm(btn, rest);
@@ -1979,15 +2049,29 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
1979
2049
  * server, and it is moved into this tab's storage and wiped from the address
1980
2050
  * bar and the history entry at once.
1981
2051
  */
2052
+ /**
2053
+ * Over a directory of runs, the run that is open: `&run=<id>` in the link a
2054
+ * launcher hands out (beside the token, in the fragment), kept in the query
2055
+ * once the fragment is wiped, so a reload stays on it.
2056
+ */
2057
+ var RUN = (function(){
2058
+ var h = /(?:^#|&)run=([A-Za-z0-9][A-Za-z0-9._-]{0,127})(?:&|$)/.exec(location.hash || "");
2059
+ var q = new URLSearchParams(location.search).get("run");
2060
+ var id = h ? h[1] : q;
2061
+ return id && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id) ? id : null;
2062
+ })();
1982
2063
  var TOKEN = (function(){
1983
2064
  var m = /(?:^#|&)t=([a-f0-9]+)/.exec(location.hash || "");
1984
2065
  try {
1985
2066
  if (m) { sessionStorage.setItem("seisin-token", m[1]); }
1986
2067
  var t = m ? m[1] : (sessionStorage.getItem("seisin-token") || "");
1987
- if (m) history.replaceState(null, "", location.pathname + location.search);
2068
+ if (m) history.replaceState(null, "", location.pathname + (RUN ? "?run=" + encodeURIComponent(RUN) : location.search));
1988
2069
  return t;
1989
2070
  } catch (_) { return m ? m[1] : ""; }
1990
2071
  })();
2072
+ /** "policy" — one seisin.toml, the console as it always was — or "runs", read only. */
2073
+ var MODE = "policy";
2074
+ var READONLY = false;
1991
2075
 
1992
2076
  /** What to tell a person when a request to this server fails. */
1993
2077
  function whyFailed(e, status){
@@ -2050,6 +2134,8 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
2050
2134
  }
2051
2135
  function stateUrl(){
2052
2136
  var s = sinceIso();
2137
+ if (MODE === "runs" && RUN)
2138
+ return "/api/run?run=" + encodeURIComponent(RUN) + (s ? "&since=" + encodeURIComponent(s) : "");
2053
2139
  return "/api/state" + (s ? "?since=" + encodeURIComponent(s) : "");
2054
2140
  }
2055
2141
  function rangeLabel(){
@@ -2180,7 +2266,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
2180
2266
  if (ASKING[name]) return;
2181
2267
  ASKING[name] = true;
2182
2268
  try {
2183
- var r = await fetch("/api/settings?role=" + encodeURIComponent(name),
2269
+ var r = await fetch("/api/settings?role=" + encodeURIComponent(name) + (RUN ? "&run=" + encodeURIComponent(RUN) : ""),
2184
2270
  { cache: "no-store", headers: { "x-seisin-token": TOKEN } });
2185
2271
  var out = await r.json();
2186
2272
  SETTINGS[name] = r.ok ? { settings: out.settings } : { error: out.error || "could not read the settings" };
@@ -2198,6 +2284,12 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
2198
2284
  try {
2199
2285
  // Opened as a file there is no server to ask; the fetch only printed a CORS error.
2200
2286
  if (location.protocol === "file:") throw new Error("no server");
2287
+ var info = await fetch("/api/info", { cache: "no-store", headers: { "x-seisin-token": TOKEN } });
2288
+ if (info.ok) {
2289
+ var mode = (await info.json()).mode;
2290
+ if (mode === "runs") { MODE = "runs"; READONLY = true; }
2291
+ }
2292
+ if (MODE === "runs" && !RUN) { await bootRuns(); return; }
2201
2293
  got = await fetchState(true);
2202
2294
  // A live server that refuses this tab's token: `seisin ui` was restarted
2203
2295
  // and minted a new one. Falling back to the example here showed a policy
@@ -2236,6 +2328,10 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
2236
2328
  LIVE = true;
2237
2329
  applyState(s);
2238
2330
  LIVE_LABEL = s.config.replace(/^.*\//, "") + " · live";
2331
+ if (MODE === "runs") {
2332
+ RUNINFO = s.run || null; LIVE_LABEL = "run " + RUN + " · read only"; drawRunBanner();
2333
+ document.getElementById("req-note").textContent = "read only · asked for where the run was launched, not here";
2334
+ }
2239
2335
  label.textContent = LIVE_LABEL;
2240
2336
  label.style.color = "var(--green)";
2241
2337
  // Live, the what-if button goes for good: "run every role unconfined" against a real repo is
@@ -2246,10 +2342,19 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
2246
2342
  document.getElementById("kill").hidden = true;
2247
2343
  // And so does the card that counts what the button would do: live it could only say 0.
2248
2344
  document.getElementById("k-open").style.display = "none";
2249
- document.getElementById("toml-note").textContent = "the file on disk, as it stands";
2250
- document.getElementById("foot-mode").textContent = "Live.";
2251
- document.getElementById("foot-text").innerHTML =
2252
- "This page reads <b>" + esc(s.config) + "</b> and the log on disk. Two actions write, each in place and stamped " +
2345
+ // Read only nothing waits on the person here: what the run asked for is asked for where it was launched.
2346
+ if (READONLY) {
2347
+ var nav = document.getElementById("nav-req");
2348
+ nav.childNodes[1].textContent = "Asked for";
2349
+ document.querySelector("#k-wait h2").textContent = "asked for";
2350
+ document.getElementById("req-h").textContent = "What this run asked for";
2351
+ }
2352
+ document.getElementById("toml-note").textContent = READONLY ? "this run's policy, as its launcher archived it" : "the file on disk, as it stands";
2353
+ document.getElementById("foot-mode").textContent = READONLY ? "Live, read only." : "Live.";
2354
+ document.getElementById("foot-text").innerHTML = READONLY
2355
+ ? "This page reads <b>" + esc(s.config) + "</b>, one archived run's policy, and its log. Nothing is written from here: " +
2356
+ "the run's territory was lent by whoever launched it, and access is asked for there."
2357
+ : "This page reads <b>" + esc(s.config) + "</b> and the log on disk. Two actions write, each in place and stamped " +
2253
2358
  "with its reason: a decision on a request, and saving a control-files change after its preview. Territories " +
2254
2359
  "and keys are changed in seisin.toml itself.";
2255
2360
  draw();
@@ -2257,6 +2362,168 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
2257
2362
  poll();
2258
2363
  }
2259
2364
 
2365
+ /* ── a directory of runs ──────────────────────────────────────────────────
2366
+ * `seisin ui --runs`: every run, filtered and summed, read only. One run opens
2367
+ * as the console always looked — its policy, its denials, its log — with no
2368
+ * grant on it. */
2369
+ var RUNINFO = null;
2370
+ var RFILTER = (function(){
2371
+ var q = new URLSearchParams(location.search), out = {};
2372
+ ["team", "role", "work_item", "status", "outcome", "q"].forEach(function(k){ var v = q.get(k); if (v) out[k] = v; });
2373
+ return out;
2374
+ })();
2375
+ var RLAST = { url: null, etag: null, v: null };
2376
+
2377
+ function runsUrl(){
2378
+ var q = new URLSearchParams();
2379
+ Object.keys(RFILTER).forEach(function(k){ if (RFILTER[k]) q.set(k, RFILTER[k]); });
2380
+ var s = q.toString();
2381
+ return "/api/runs" + (s ? "?" + s : "");
2382
+ }
2383
+ async function fetchRuns(fresh){
2384
+ var url = runsUrl();
2385
+ var h = { "x-seisin-token": TOKEN };
2386
+ if (!fresh && RLAST.etag && RLAST.url === url) h["if-none-match"] = RLAST.etag;
2387
+ var r = await fetch(url, { cache: "no-store", headers: h });
2388
+ if (r.status === 304) return { same: true };
2389
+ if (!r.ok) return { status: r.status };
2390
+ var v = await r.json();
2391
+ RLAST = { url: url, etag: r.headers.get("etag"), v: v };
2392
+ return { v: v };
2393
+ }
2394
+
2395
+ function runMeta(r){
2396
+ var bits = [];
2397
+ if (r.team) bits.push("team <b>" + esc(r.team) + "</b>");
2398
+ if (r.role) bits.push("role <b>" + esc(r.role) + "</b>");
2399
+ if (r.work_item) bits.push("<b>" + esc(r.work_item) + "</b>");
2400
+ if (r.repo) bits.push("repo " + esc(r.repo));
2401
+ if (r.status) bits.push("status " + esc(r.status));
2402
+ var w = when(r.started_at || "");
2403
+ if (w.text) bits.push('started <span title="' + escA(w.title) + '">' + esc(w.text) + "</span>");
2404
+ return bits.join(" · ");
2405
+ }
2406
+ function outcomeTag(o){
2407
+ var says = { clean: "clean", denied: "denied", refused: "canary failed", unread: "policy unreadable" };
2408
+ return '<span class="oc ' + escA(o) + '">' + esc(says[o] || o) + "</span>";
2409
+ }
2410
+
2411
+ function drawRunBanner(){
2412
+ var b = document.getElementById("run-banner");
2413
+ var r = RUNINFO || { id: RUN };
2414
+ b.innerHTML = "<b>Run " + esc(r.run_id || r.id) + "</b> " + outcomeTag(r.outcome || "") +
2415
+ ' · <a href="./">all runs</a>' +
2416
+ '<div class="meta">' + (runMeta(r) || "no run.json: only what its policy and log say") +
2417
+ " · read only: nothing is granted from here</div>";
2418
+ b.hidden = false;
2419
+ document.getElementById("nav-runs").hidden = false;
2420
+ }
2421
+
2422
+ function drawRunsView(v){
2423
+ // The filters: every value any run has, with how many runs have it.
2424
+ document.querySelectorAll("#rn-filters select").forEach(function(sel){
2425
+ var f = sel.dataset.f;
2426
+ var opts = (v.facets[f] || []);
2427
+ var cur = RFILTER[f] || "";
2428
+ sel.innerHTML = '<option value="">all</option>' + opts.map(function(o){
2429
+ return '<option value="' + escA(o.value) + '"' + (o.value === cur ? " selected" : "") + ">" + esc(o.value) + " (" + o.runs + ")</option>";
2430
+ }).join("");
2431
+ sel.disabled = !opts.length;
2432
+ });
2433
+ document.getElementById("rn-count").textContent = v.totals.runs === v.total
2434
+ ? plural(v.total, "run") : plural(v.totals.runs, "run") + " of " + v.total;
2435
+ pill("runs-pin", v.total, v.total === 1 ? "run" : "runs");
2436
+
2437
+ var t = v.totals, k = t.byKind || {};
2438
+ function card(cls, title, n, sub){
2439
+ return '<div class="card kpi ' + cls + '"><h2>' + esc(title) + '</h2><div class="n">' + esc(Number(n).toLocaleString()) +
2440
+ '</div><div class="s">' + sub + "</div></div>";
2441
+ }
2442
+ document.getElementById("rn-kpis").innerHTML =
2443
+ card("b", "runs", t.runs, (t.outcome.denied || 0) + " denied something · " + (t.outcome.clean || 0) + " clean") +
2444
+ card(t.outcome.refused ? "a" : "g", "canary failed", t.outcome.refused || 0, "runs that never started") +
2445
+ card(t.denied ? "a" : "g", "denied", t.denied, (k.file || 0).toLocaleString() + " files · " + (k.network || 0).toLocaleString() + " network") +
2446
+ card(t.retried ? "a" : "g", "spent retrying", t.retried, "calls after the first denial of the same thing");
2447
+
2448
+ var causes = v.causes || [];
2449
+ document.getElementById("rn-causes").innerHTML = causes.length
2450
+ ? "<thead><tr><th>denied</th><th>what</th><th class=num>runs</th><th class=num>times</th><th>teams</th><th>roles</th></tr></thead><tbody>" +
2451
+ causes.map(function(c){
2452
+ return '<tr class="' + (c.about === "system" ? "sys" : "") + '"><td>' + esc(c.action) + "</td><td><code>" + esc(c.target) +
2453
+ "</code></td><td class=num>" + c.runs + "</td><td class=num>" + c.times + "</td><td>" + esc((c.teams || []).join(", ")) +
2454
+ "</td><td>" + esc((c.roles || []).join(", ")) + "</td></tr>";
2455
+ }).join("") + "</tbody>"
2456
+ : '<tbody><tr><td class="blank quiet">Nothing denied in these runs.</td></tr></tbody>';
2457
+
2458
+ var SHOWN = 300, runs = v.runs || [];
2459
+ document.getElementById("rn-list").innerHTML = runs.length
2460
+ ? "<thead><tr><th>run</th><th>started</th><th>team</th><th>role</th><th>work item</th><th>status</th><th>outcome</th>" +
2461
+ "<th class=num>denied</th><th class=num>retried</th><th>hosts denied</th></tr></thead><tbody>" +
2462
+ runs.slice(0, SHOWN).map(function(r){
2463
+ var w = when(r.started_at || "");
2464
+ return "<tr><td><a href=\"./?run=" + encodeURIComponent(r.id) + "#requests\"><code>" + esc(r.run_id || r.id) + "</code></a></td>" +
2465
+ '<td title="' + escA(w.title || "") + '">' + esc(w.text || "") + "</td><td>" + esc(r.team || "") + "</td><td>" + esc(r.role || "") +
2466
+ "</td><td>" + esc(r.work_item || "") + "</td><td>" + esc(r.status || "") + "</td><td>" + outcomeTag(r.outcome) +
2467
+ "</td><td class=num>" + r.denied + "</td><td class=num>" + r.retried + "</td><td>" +
2468
+ (r.hosts || []).slice(0, 3).map(function(h){ return "<code>" + esc(h.target) + "</code>"; }).join(" ") + "</td></tr>";
2469
+ }).join("") + "</tbody>"
2470
+ : '<tbody><tr><td class="blank quiet">' + (v.total ? "No run matches these filters." : "No runs in " + esc(v.dir) + " yet.") + "</td></tr></tbody>";
2471
+ var more = document.getElementById("rn-more");
2472
+ more.hidden = runs.length <= SHOWN;
2473
+ more.textContent = "Showing the newest " + SHOWN + " of " + runs.length + ": narrow the filters for the rest.";
2474
+ }
2475
+
2476
+ function setRunFilter(k, val){
2477
+ if (val) RFILTER[k] = val; else delete RFILTER[k];
2478
+ var q = new URLSearchParams();
2479
+ Object.keys(RFILTER).forEach(function(x){ q.set(x, RFILTER[x]); });
2480
+ history.replaceState(null, "", location.pathname + (q.toString() ? "?" + q : ""));
2481
+ fetchRuns(true).then(function(g){ if (g.v) drawRunsView(g.v); }).catch(function(){});
2482
+ }
2483
+
2484
+ async function bootRuns(){
2485
+ var label = document.getElementById("source");
2486
+ var got = await fetchRuns(true);
2487
+ if (!got.v) {
2488
+ label.textContent = got.status === 403 ? "link expired — open the new one" : "the console did not answer";
2489
+ label.style.color = "var(--amber)";
2490
+ return;
2491
+ }
2492
+ LIVE = true;
2493
+ LIVE_LABEL = "runs · read only";
2494
+ label.textContent = LIVE_LABEL;
2495
+ label.style.color = "var(--green)";
2496
+ document.getElementById("nav-runs").hidden = false;
2497
+ document.getElementById("nav-all").classList.add("on");
2498
+ document.getElementById("nav-cap").hidden = true;
2499
+ document.querySelectorAll(".side .list > a[href^='#']").forEach(function(a){ a.hidden = true; });
2500
+ // Inline, not `hidden`: the cards' own display rules outrank the attribute.
2501
+ ["kpis", "range", "kill"].forEach(function(id){ var e = document.getElementById(id); if (e) { e.hidden = true; e.style.display = "none"; } });
2502
+ document.getElementById("foot-mode").textContent = "Live, read only.";
2503
+ document.getElementById("foot-text").innerHTML = "This page reads the runs in <b>" + esc(got.v.dir) +
2504
+ "</b>, one folder each, as their launcher archived them. Nothing is written from here.";
2505
+ document.querySelectorAll("#rn-filters select").forEach(function(sel){
2506
+ sel.addEventListener("change", function(){ setRunFilter(sel.dataset.f, sel.value); });
2507
+ });
2508
+ var q = document.getElementById("rn-q");
2509
+ q.value = RFILTER.q || "";
2510
+ var tq = null;
2511
+ q.addEventListener("input", function(){ clearTimeout(tq); tq = setTimeout(function(){ setRunFilter("q", q.value.trim()); }, 250); });
2512
+ drawRunsView(got.v);
2513
+ show("runs");
2514
+ (function again(){
2515
+ setTimeout(async function(){
2516
+ try {
2517
+ var g = await fetchRuns(false);
2518
+ if (g.v) drawRunsView(g.v);
2519
+ if (g.v || g.same) { FAILS = 0; if (OFFLINE) setOffline(null); }
2520
+ else if (++FAILS >= 2) setOffline(g.status === 403 ? "expired" : "down");
2521
+ } catch (_) { if (++FAILS >= 2) setOffline("down"); }
2522
+ again();
2523
+ }, 2000);
2524
+ })();
2525
+ }
2526
+
2260
2527
  /** Every two seconds after the last answer, not every two seconds regardless. */
2261
2528
  function poll(){
2262
2529
  setTimeout(async function(){
@@ -2269,11 +2536,15 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
2269
2536
  * it has to keep working opened straight off the disk with no server at all. */
2270
2537
  function views(){
2271
2538
  return {
2539
+ runs: {view:"runs", title:"Runs",
2540
+ sub:"Every run in the directory, read only: what each was denied and what the denials cost. Open one for its policy and its log."},
2272
2541
  policy: {view:"roles", title:"Roles",
2273
2542
  sub:"Each role's territory — the folders it may write — and the keys it may read. " +
2274
2543
  (LIVE ? "Read-only here: the policy is seisin.toml." : "In this example you can edit them; nothing is saved.")},
2275
- requests: {view:"requests", title:"Waiting on you",
2276
- sub:"Requests: access an agent asked for and cannot have. Granting one adds a line to seisin.toml."},
2544
+ requests: {view:"requests", title: READONLY ? "Asked for" : "Waiting on you",
2545
+ sub: READONLY
2546
+ ? "Requests: access this run asked for and did not have. Read only: they are asked for where the run was launched."
2547
+ : "Requests: access an agent asked for and cannot have. Granting one adds a line to seisin.toml."},
2277
2548
  denied: {view:"denied", title:"Denied", tab:"path",
2278
2549
  sub:"Everything an agent tried in the window and the OS denied, grouped by what it was about."},
2279
2550
  causes: {view:"denied", title:"Denied", tab:"name",
@@ -2320,7 +2591,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
2320
2591
  }
2321
2592
 
2322
2593
  /** The landing view is what is waiting on you, not the policy table. */
2323
- function route(){ return (location.hash || "#requests").slice(1); }
2594
+ function route(){ return MODE === "runs" && !RUN ? "runs" : (location.hash || "#requests").slice(1); }
2324
2595
  window.addEventListener("hashchange", function(){ show(route()); });
2325
2596
  show(route());
2326
2597