@monster-orange/den 0.4.16 → 0.4.18

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 (33) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +94 -1
  4. package/package.json +1 -1
  5. package/src/ui/cli/agent-ops.ts +50 -0
  6. package/src/ui/cli/agents-command.ts +5 -5
  7. package/src/ui/server/agent-routes.ts +13 -1
  8. package/src/ui/server/check-pool.ts +72 -0
  9. package/src/ui/server/index.ts +105 -10
  10. package/src/ui/server/public/agents-page.js +98 -16
  11. package/src/ui/server/public/agents.html +22 -0
  12. package/src/ui/server/public/environments.html +223 -140
  13. package/src/ui/server/public/graph.css +40 -0
  14. package/src/ui/server/public/graph.js +47 -6
  15. package/src/ui/server/public/header.js +7 -0
  16. package/src/ui/server/public/i18n/es.js +46 -0
  17. package/src/ui/server/public/i18n/ja.js +46 -0
  18. package/src/ui/server/public/index.html +6 -0
  19. package/src/ui/server/public/local-dev.html +199 -27
  20. package/src/ui/server/public/log-source.js +26 -0
  21. package/src/ui/server/public/logs.html +7 -20
  22. package/src/ui/server/public/panel.js +1 -1
  23. package/src/ui/server/public/release.html +19 -1
  24. package/src/ui/server/public/services.html +18 -3
  25. package/src/ui/server/public/shell.js +360 -32
  26. package/src/ui/server/public/svc-panel.js +672 -0
  27. package/src/ui/server/public/topbar.js +34 -1
  28. package/src/ui/server/public/view-scope.js +61 -0
  29. package/src/ui/server/public/workspace-picker.js +2 -0
  30. package/src/ui/server/route-classes.ts +2 -0
  31. package/src/ui/server/run-history.ts +181 -0
  32. package/src/ui/server/session-routes.ts +94 -17
  33. package/src/ui/server/ui-assets.ts +6 -0
@@ -31,6 +31,8 @@ import { moduleScanner } from "../../shared/test-discovery/module-scanner.js";
31
31
  import { resolveFieldEnv } from "../../shared/perf/optional-fields.js";
32
32
  import { testRunner } from "../cli/test-runner.js";
33
33
  import { discoverRepos } from "../../pillars/smoke/discover.js";
34
+ import { planSuites } from "../../pillars/suites/run.js";
35
+ import { listServicesWithManifests } from "../../shared/manifest/workspace-manifests.js";
34
36
  import {
35
37
  awsProfileEnv,
36
38
  loadAwsProfiles,
@@ -856,6 +858,19 @@ import { heldActions } from "./held-actions.js";
856
858
  import type { ApprovalQueue } from "./approvals.js";
857
859
  import { processPathRules } from "../cli/onboard-reads.js";
858
860
  import { handleAgentRoutes, startAgentUpkeep } from "./agent-routes.js";
861
+ import {
862
+ lastRunsByRepo,
863
+ recordRunEnd,
864
+ recordRunStart,
865
+ reposOfRun,
866
+ } from "./run-history.js";
867
+ import {
868
+ CHECK_CONCURRENCY,
869
+ CHECK_TIMEOUT_MS,
870
+ runCapped,
871
+ timedOutText,
872
+ withTimeout,
873
+ } from "./check-pool.js";
859
874
  import {
860
875
  handleSessionList,
861
876
  handleSessionUpgrade,
@@ -1273,6 +1288,9 @@ function sshUserNow(): string {
1273
1288
  return sshUser(set, osUser);
1274
1289
  }
1275
1290
 
1291
+ /** Test runs this server is running now, by id (run-history.ts). */
1292
+ const LIVE_RUNS = new Set<string>();
1293
+
1276
1294
  /** The ssh target for this request: the Host it used, minus the port. */
1277
1295
  function sshTargetOf(req: http.IncomingMessage): string {
1278
1296
  return sshTarget(req.headers.host, os.hostname());
@@ -1969,8 +1987,10 @@ async function handleRequest(
1969
1987
  // AG-4a-term's session routes (`/api/sessions/<id>/io`): shell-grade, and
1970
1988
  // an agent's token reaches its own session's only (request-guard). The io
1971
1989
  // is a WebSocket (the server's upgrade handler): a plain GET of it is 426.
1972
- if (pathname.startsWith("/api/sessions/"))
1973
- return sendSessionHttp(req, res, pathname);
1990
+ if (pathname.startsWith("/api/sessions/")) {
1991
+ await sendSessionHttp(req, res, pathname);
1992
+ return;
1993
+ }
1974
1994
  // ON-3: an agent's held writes; Allow from the person's browser only.
1975
1995
  if (
1976
1996
  guard.ok &&
@@ -2782,15 +2802,55 @@ async function handleRequest(
2782
2802
  // Streaming emits in completion order, which varies per poll (shell probes
2783
2803
  // finish at wildly different speeds), so without a stable key the client
2784
2804
  // repaints the dots in a different order every 5s.
2785
- const results = await Promise.all(
2786
- sortedEntries.map((e, idx) =>
2787
- checkEntry(e).then((r) => {
2788
- const ranked = { ...r, openUrl: e.openUrl, links: e.links, idx };
2789
- onResult?.(ranked);
2790
- return ranked;
2791
- }),
2805
+ // 0.4.17: at most CHECK_CONCURRENCY at once, each with at most
2806
+ // CHECK_TIMEOUT_MS: a dead host's check says "timed out after 10 s" and
2807
+ // never holds up the run (check-pool.ts).
2808
+ const late = (e: (typeof sortedEntries)[number]): HealthResult => ({
2809
+ repo: e.repo,
2810
+ service: e.service,
2811
+ label: e.label,
2812
+ ...(e.checkType !== "none"
2813
+ ? healthCheckFields(e.checkType, e.target)
2814
+ : { url: null }),
2815
+ repoPath: e.repoPath,
2816
+ mode: e.mode,
2817
+ isEntrypoint: e.isEntrypoint,
2818
+ ...e.meta,
2819
+ ms: CHECK_TIMEOUT_MS,
2820
+ ok: false,
2821
+ error: timedOutText(CHECK_TIMEOUT_MS),
2822
+ });
2823
+ const run = (e: (typeof sortedEntries)[number], idx: number) =>
2824
+ withTimeout(checkEntry(e), CHECK_TIMEOUT_MS, () => late(e)).then((r) => {
2825
+ const ranked = { ...r, openUrl: e.openUrl, links: e.links, idx };
2826
+ onResult?.(ranked);
2827
+ return ranked;
2828
+ });
2829
+ // Compose and container checks are answered in-process from one shared
2830
+ // docker listing (the call in flight): they start no process, so they
2831
+ // run outside the cap, all at once, and a cycle still lists once.
2832
+ const inProcess = (e: (typeof sortedEntries)[number]) =>
2833
+ (e.checkType === "container" && !!e.container) ||
2834
+ (e.checkType === "shell" && !!e.compose && e.target === e.compose.cmd);
2835
+ const indexed = sortedEntries.map((e, idx) => ({ e, idx }));
2836
+ const results = new Array<HealthResult>(sortedEntries.length);
2837
+ await Promise.all([
2838
+ ...indexed
2839
+ .filter(({ e }) => inProcess(e))
2840
+ .map(({ e, idx }) =>
2841
+ run(e, idx).then((r) => {
2842
+ results[idx] = r;
2843
+ }),
2844
+ ),
2845
+ runCapped(
2846
+ indexed.filter(({ e }) => !inProcess(e)),
2847
+ CHECK_CONCURRENCY,
2848
+ ({ e, idx }) =>
2849
+ run(e, idx).then((r) => {
2850
+ results[idx] = r;
2851
+ }),
2792
2852
  ),
2793
- );
2853
+ ]);
2794
2854
  return results;
2795
2855
  }
2796
2856
 
@@ -3413,6 +3473,13 @@ async function handleRequest(
3413
3473
  return;
3414
3474
  }
3415
3475
 
3476
+ // ── API: each repo's last test run (0.4.17, run-history.ts) ─────────────────
3477
+ if (pathname === "/api/runs/last") {
3478
+ return sendJson(res, {
3479
+ repos: lastRunsByRepo(getStateDir(), LIVE_RUNS),
3480
+ });
3481
+ }
3482
+
3416
3483
  // ── API: running daemons list ──────────────────────────────────────────────
3417
3484
  if (pathname === "/api/service/daemons") {
3418
3485
  await startupPrune;
@@ -3878,8 +3945,36 @@ async function handleRequest(
3878
3945
 
3879
3946
  const startTime = Date.now();
3880
3947
  let overallExit = 0;
3948
+ // 0.4.17: the server's run history (every browser's runs, kept), which
3949
+ // Workspaces' Last test run reads per repo.
3950
+ // Every run kind that runs a repo's own tests names it, the suites
3951
+ // pillar (`tests:`) and a repo's performance modules included.
3952
+ const tested = await reposOfRun(String(pillar), String(env), modules, {
3953
+ pillarSources: (p) =>
3954
+ collectTestSources(p as Pillar).map((x) => x.repoName),
3955
+ suiteRepos: (e, only) =>
3956
+ planSuites(listServicesWithManifests(), e as Environment, only).run.map(
3957
+ (x) => x.repo,
3958
+ ),
3959
+ perfModules: async () =>
3960
+ (await moduleScanner.scanModules("performance")).map((m) => m.name),
3961
+ repoNames: () => discoverRepos().map((r) => r.repoName),
3962
+ });
3963
+ LIVE_RUNS.add(runId);
3964
+ recordRunStart(getStateDir(), {
3965
+ id: runId,
3966
+ pillar: String(pillar),
3967
+ env: String(env),
3968
+ repos: tested,
3969
+ });
3970
+ let recorded = false;
3881
3971
 
3882
3972
  const finishLifecycle = (exitCode: number, note?: string): void => {
3973
+ if (!recorded) {
3974
+ recorded = true;
3975
+ LIVE_RUNS.delete(runId);
3976
+ recordRunEnd(getStateDir(), runId, exitCode);
3977
+ }
3883
3978
  const dur = formatDuration(Date.now() - startTime);
3884
3979
  const extra = note ? ` · ${note}` : "";
3885
3980
  serverLog(
@@ -147,6 +147,9 @@ const st = {
147
147
  signedOut: false,
148
148
  open: [],
149
149
  panes: new Map(),
150
+ // AG-18: agents (ws\0id) whose persona change started fresh, for the
151
+ // pane's line once it follows them.
152
+ fresh: new Set(),
150
153
  timer: null,
151
154
  xterm: null,
152
155
  csrf: null,
@@ -200,6 +203,7 @@ function writeUrl() {
200
203
  else u.searchParams.delete("open");
201
204
  u.searchParams.set("layout", st.layout);
202
205
  u.searchParams.delete("start");
206
+ u.searchParams.delete("folder");
203
207
  const s = u.searchParams.toString().replace(/%2C/gi, ",");
204
208
  history.replaceState(history.state, "", u.pathname + (s ? `?${s}` : ""));
205
209
  paintSub();
@@ -326,6 +330,18 @@ function rotationWhy(rot) {
326
330
  );
327
331
  }
328
332
 
333
+ /** The Workspaces page, focused on workspace `ws` (0.4.17). */
334
+ function wsHref(ws) {
335
+ const nav = window.denShell?.nav?.() ?? [];
336
+ const base = nav.find((n) => n.id === "local-dev")?.href ?? "/local-dev";
337
+ return `${base}?ws=${encodeURIComponent(ws)}`;
338
+ }
339
+
340
+ /** Workspace `ws` as a link to it on Workspaces. */
341
+ function wsLinkHtml(ws) {
342
+ return `<a class="wslink" href="${esc(wsHref(ws))}" title="${esc(t("ag.goWs", { ws }))}">${esc(ws)}</a>`;
343
+ }
344
+
329
345
  /** Its rotation, when one is due: "ready" or "due" (den#4). */
330
346
  function rotationOf(r) {
331
347
  if (!r.rotation?.due) return "";
@@ -480,7 +496,7 @@ function paintList() {
480
496
  ? groups
481
497
  .map(
482
498
  (g) =>
483
- `<div class="wsg" data-ws="${esc(g.ws)}"><div class="wsh"><span>${esc(g.ws)}</span><span>${esc(g.rows.length)}</span></div>` +
499
+ `<div class="wsg" data-ws="${esc(g.ws)}"><div class="wsh"><span>${wsLinkHtml(g.ws)}</span><span>${esc(g.rows.length)}</span></div>` +
484
500
  g.rows.map(rowHtml).join("") +
485
501
  `</div>`,
486
502
  )
@@ -616,6 +632,11 @@ function followAgents() {
616
632
  }
617
633
  }
618
634
 
635
+ /** The pane's line for a resume that had nothing to continue (AG-14). */
636
+ function freshLine(p) {
637
+ p.term?.write(`\x1b[2m${t("ag.freshLine")}\x1b[0m\r\n`);
638
+ }
639
+
619
640
  /** Move pane `p` to session row `row`, where it was. */
620
641
  function retarget(p, row) {
621
642
  const old = p.id;
@@ -639,6 +660,7 @@ function retarget(p, row) {
639
660
  p.term?.write(
640
661
  `\r\n\x1b[2m${t("ag.switched", { p: row.persona ?? row.agent })}\x1b[0m\r\n`,
641
662
  );
663
+ if (st.fresh.delete(`${p.agentWs}\u0000${p.agent}`)) freshLine(p);
642
664
  paintBar(p);
643
665
  paintLayout();
644
666
  writeUrl();
@@ -769,6 +791,7 @@ function paintLayout() {
769
791
  if (n === 0) {
770
792
  box.className = "agempty";
771
793
  delete box.dataset.layout;
794
+ delete box.dataset.ws;
772
795
  box.innerHTML = `<div>${esc(t("ag.empty"))}</div>`;
773
796
  return;
774
797
  }
@@ -776,6 +799,10 @@ function paintLayout() {
776
799
  const shown = visibleIds();
777
800
  box.className = `agp n${Math.max(1, shown.length)}${layout === "tabs" ? " tabs" : ""}`;
778
801
  box.dataset.layout = layout;
802
+ // The front agent's workspace: ⌘K lists it first (0.4.17).
803
+ const front = rowOf(st.cur)?.workspace;
804
+ if (front) box.dataset.ws = front;
805
+ else delete box.dataset.ws;
779
806
  const empty = box.querySelector(":scope > div:not(.pane):not(.agtabs)");
780
807
  if (empty) empty.remove();
781
808
  let strip = box.querySelector(":scope > .agtabs");
@@ -919,10 +946,14 @@ function paintBar(p) {
919
946
  const r = rowOf(p.id);
920
947
  const bar = p.el.querySelector(".bar2");
921
948
  const name = r ? paneName(r) : p.label || p.id;
922
- // AG-4b: the session's name last, when it says more.
923
- const where = r
924
- ? [r.workspace, r.runtime, sessionExtra(r)].filter(Boolean).join(" · ")
925
- : p.workspace || "";
949
+ // AG-4b: the session's name last, when it says more. The workspace is a
950
+ // link to it on Workspaces (0.4.17).
951
+ const ws = r ? r.workspace : p.workspace || "";
952
+ const rest = r
953
+ ? [r.runtime, sessionExtra(r)].filter(Boolean).join(" · ")
954
+ : "";
955
+ const where =
956
+ (ws ? wsLinkHtml(ws) : "") + (ws && rest ? " · " : "") + esc(rest);
926
957
  const tone = r ? stateTone(r) : p.exited ? "off" : "pending";
927
958
  const stop =
928
959
  r?.kind === "agent" && (r.state === "running" || r.state === "stopping")
@@ -943,7 +974,7 @@ function paintBar(p) {
943
974
  : "";
944
975
  bar.innerHTML =
945
976
  `<span class="den-dot ${tone}" aria-hidden="true"></span>` +
946
- `<b>${esc(name)}</b><span class="where mono">${esc(where)}</span>` +
977
+ `<b>${esc(name)}</b><span class="where mono">${where}</span>` +
947
978
  (r?.tokenRevoked
948
979
  ? `<span class="tagx warn" title="${esc(t("ag.revokedTip"))}">${esc(t("ag.revoked"))}</span>`
949
980
  : "") +
@@ -1124,21 +1155,41 @@ async function connect(p) {
1124
1155
  }
1125
1156
  p.term?.write(new Uint8Array(e.data));
1126
1157
  };
1127
- sock.onclose = (e) => {
1158
+ sock.onclose = async (e) => {
1128
1159
  if (p.sock !== sock) return;
1129
1160
  p.sock = null;
1130
1161
  if (p.closed) return;
1131
- // Refused before it opened: beyond loopback without a session, the
1132
- // upgrade is 401 (a browser sees only the close).
1133
- if (!opened) void refresh();
1134
- const why =
1135
- e.reason || (opened ? String(e.code) : t("ag.signin", { cli: CLI }));
1162
+ // Refused before it opened: a browser sees only the close, so ask the
1163
+ // route why with a plain GET (0.4.17). "Sign in" only when it's 401.
1164
+ let why = e.reason || String(e.code);
1165
+ if (!opened) {
1166
+ void refresh();
1167
+ why = await refusalReason(p.id);
1168
+ if (p.closed || p.sock) return;
1169
+ }
1136
1170
  p.term?.write(`\r\n${t("ag.lost", { why })}\r\n`);
1137
1171
  p.lost = true;
1138
1172
  paintBar(p);
1139
1173
  };
1140
1174
  }
1141
1175
 
1176
+ /** Why session `id`'s io upgrade was refused, in words for its pane. */
1177
+ async function refusalReason(id) {
1178
+ try {
1179
+ const r = await fetch(`/api/sessions/${encodeURIComponent(id)}/io`, {
1180
+ headers: { accept: "application/json" },
1181
+ credentials: "same-origin",
1182
+ });
1183
+ if (r.status === 401) return t("ag.signin", { cli: CLI });
1184
+ const b = await r.json().catch(() => null);
1185
+ if (r.status === 426) return t("ag.refusedUnknown");
1186
+ if (typeof b?.error === "string" && b.error) return b.error;
1187
+ return t("ag.refusedStatus", { status: r.status });
1188
+ } catch {
1189
+ return t("ag.refusedUnknown");
1190
+ }
1191
+ }
1192
+
1142
1193
  // ── links and keys in a pane (AG-4b) ───────────────────────────────────
1143
1194
 
1144
1195
  /** What Shift+Enter sends to a pane's session (a plain session: Enter). */
@@ -2051,8 +2102,16 @@ function dialogMode(bring, change = null) {
2051
2102
  );
2052
2103
  $d("#ag-go").textContent = goLabel();
2053
2104
  $d("#ag-conv-f").hidden = !bring;
2105
+ // 0.4.18: a new agent's folder (Start agent here fills it).
2106
+ for (const id of ["#ag-folder-l", "#ag-folder-h"]) {
2107
+ const el = $d(id);
2108
+ if (el) el.hidden = !!(bring || change);
2109
+ }
2054
2110
  }
2055
2111
 
2112
+ /** 0.4.18: Start agent here (the service panel): ?start=1&ws=…&folder=…. */
2113
+ let startPrefill = null;
2114
+
2056
2115
  /** The submit button's words for the dialog's mode. */
2057
2116
  function goLabel() {
2058
2117
  return t(sd.change ? "ag.pc.go" : sd.bring ? "ag.bring.go" : "ag.go");
@@ -2074,12 +2133,18 @@ async function openStart(bring = false, change = null) {
2074
2133
  const dlg = $d("#ag-dlg");
2075
2134
  if (!dlg || dlg.open) return;
2076
2135
  dialogMode(bring, change);
2136
+ const pre = change || bring ? null : startPrefill;
2137
+ startPrefill = null;
2077
2138
  const shown = change ? [change.workspace] : viewWorkspaces();
2139
+ if (pre?.ws && !shown.includes(pre.ws)) shown.push(pre.ws);
2078
2140
  const wsSel = $d("#ag-ws");
2079
2141
  wsSel.innerHTML = shown
2080
2142
  .map((w) => `<option value="${esc(w)}">${esc(w)}</option>`)
2081
2143
  .join("");
2082
2144
  wsSel.disabled = shown.length <= 1;
2145
+ if (pre?.ws) wsSel.value = pre.ws;
2146
+ const folder = $d("#ag-folder");
2147
+ if (folder) folder.value = pre?.folder ?? "";
2083
2148
  sd.f = { q: "", cat: "", src: "" };
2084
2149
  sd.det = false;
2085
2150
  sd.what = false;
@@ -2120,6 +2185,8 @@ function startBody() {
2120
2185
  if (task) body.instruction = task;
2121
2186
  const name = $d("#ag-name").value.trim();
2122
2187
  if (name) body.name = name;
2188
+ const folder = $d("#ag-folder")?.value.trim();
2189
+ if (folder) body.folder = folder;
2123
2190
  return body;
2124
2191
  }
2125
2192
 
@@ -2306,11 +2373,20 @@ async function submitPersonaChange(when) {
2306
2373
  body,
2307
2374
  );
2308
2375
  window.denShell?.toast?.(
2309
- t(r?.pending ? "ag.pc.pending" : "ag.pc.done", {
2310
- id: ch.name,
2311
- p: personaName(p),
2312
- }),
2376
+ t(
2377
+ r?.pending ? "ag.pc.pending" : r?.fresh ? "ag.pc.fresh" : "ag.pc.done",
2378
+ { id: ch.name, p: personaName(p) },
2379
+ ),
2313
2380
  );
2381
+ // Never prompted: nothing was resumed. Its pane says so, now if it
2382
+ // has already moved to the new session, else when it does.
2383
+ if (r?.fresh) {
2384
+ const pane = [...st.panes.values()].find(
2385
+ (q) => q.agent === ch.id && q.agentWs === ch.workspace,
2386
+ );
2387
+ if (pane && pane.id === r.agent?.sessionId) freshLine(pane);
2388
+ else st.fresh.add(`${ch.workspace}\u0000${ch.id}`);
2389
+ }
2314
2390
  for (const w of r?.warnings ?? []) window.denShell?.toast?.(w);
2315
2391
  dlg.close?.();
2316
2392
  await refresh();
@@ -2569,6 +2645,8 @@ function translateStatic(root) {
2569
2645
  set("#ag-what-p", "pe.what");
2570
2646
  set("#ag-task-t", "ag.new.task");
2571
2647
  set("#ag-name-t", "ag.new.name");
2648
+ set("#ag-folder-t", "ag.new.folder");
2649
+ set("#ag-folder-h", "ag.new.folderHelp");
2572
2650
  set("#ag-adv-s", "ag.new.adv");
2573
2651
  set("#ag-note", "ag.new.note");
2574
2652
  const task = root.querySelector("#ag-task");
@@ -2600,6 +2678,10 @@ export async function mountAgentsPage(root) {
2600
2678
  const wanted = openFromUrl(location.search);
2601
2679
  const linked = layoutFromUrl(location.search);
2602
2680
  const startAsked = new URLSearchParams(location.search).get("start") === "1";
2681
+ if (startAsked) {
2682
+ const q = new URLSearchParams(location.search);
2683
+ startPrefill = { ws: q.get("ws") || "", folder: q.get("folder") || "" };
2684
+ }
2603
2685
 
2604
2686
  root.querySelector("#agl").addEventListener("click", (e) => {
2605
2687
  const act = e.target.closest("button[data-act]");
@@ -412,6 +412,15 @@
412
412
  flex: none;
413
413
  white-space: nowrap;
414
414
  }
415
+ .wslink {
416
+ color: inherit;
417
+ text-decoration: none;
418
+ }
419
+ .wslink:hover,
420
+ .wslink:focus-visible {
421
+ color: var(--text);
422
+ text-decoration: underline;
423
+ }
415
424
  .pane .bar2 .where {
416
425
  flex: 0 1 auto;
417
426
  min-width: 0;
@@ -730,6 +739,19 @@
730
739
  ><span id="ag-name-t">Name (optional)</span>
731
740
  <input id="ag-name" maxlength="40" autocomplete="off" />
732
741
  </label>
742
+ <label class="fld" id="ag-folder-l"
743
+ ><span id="ag-folder-t">Folder (optional)</span>
744
+ <input
745
+ id="ag-folder"
746
+ maxlength="4096"
747
+ autocomplete="off"
748
+ spellcheck="false"
749
+ aria-describedby="ag-folder-h"
750
+ />
751
+ </label>
752
+ <p class="helpl" id="ag-folder-h">
753
+ Inside the workspace's folder. Empty: the workspace's own.
754
+ </p>
733
755
  <p class="helpl" id="ag-as" role="status"></p>
734
756
  <details id="ag-adv">
735
757
  <summary id="ag-adv-s">Advanced: model and permissions</summary>