@monster-orange/den 0.4.15 → 0.4.17
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/CHANGELOG.md +125 -0
- package/README.md +7 -7
- package/docs/distributions.md +137 -11
- package/package.json +1 -1
- package/src/shared/agents/conversations.ts +122 -4
- package/src/shared/agents/den-claude.ts +13 -1
- package/src/shared/agents/records.ts +19 -0
- package/src/shared/agents/runtime.ts +5 -0
- package/src/shared/github-app.ts +7 -0
- package/src/ui/cli/agent-ops.ts +304 -1
- package/src/ui/cli/agents-command.ts +71 -7
- package/src/ui/server/agent-routes.ts +92 -2
- package/src/ui/server/check-pool.ts +72 -0
- package/src/ui/server/github-routes.ts +1 -1
- package/src/ui/server/index.ts +127 -15
- package/src/ui/server/public/agents-page.js +358 -38
- package/src/ui/server/public/agents.html +10 -0
- package/src/ui/server/public/environments.html +223 -140
- package/src/ui/server/public/error-page.js +70 -15
- package/src/ui/server/public/error.html +18 -0
- package/src/ui/server/public/graph.css +18 -0
- package/src/ui/server/public/graph.js +23 -6
- package/src/ui/server/public/i18n/es.js +47 -5
- package/src/ui/server/public/i18n/ja.js +47 -5
- package/src/ui/server/public/index.html +6 -0
- package/src/ui/server/public/local-dev.html +188 -25
- package/src/ui/server/public/panel.js +1 -1
- package/src/ui/server/public/persona-ui.css +11 -0
- package/src/ui/server/public/release.html +19 -1
- package/src/ui/server/public/shell.js +187 -12
- package/src/ui/server/public/topbar.js +34 -1
- package/src/ui/server/public/view-scope.js +61 -0
- package/src/ui/server/public/workspace-picker.js +2 -0
- package/src/ui/server/route-classes.ts +8 -0
- package/src/ui/server/run-history.ts +181 -0
- package/src/ui/server/session-routes.ts +97 -17
- package/src/ui/server/ui-assets.ts +2 -0
|
@@ -62,8 +62,9 @@
|
|
|
62
62
|
* setOrgs([{ id, title, workspaces? }], currentId, onSelect?)
|
|
63
63
|
* two or more: the Org ▾ menu; a pick
|
|
64
64
|
* calls onSelect(id), fires `den:org`
|
|
65
|
-
* setWorkspaces({ label, title, items, focus, onSelect })
|
|
65
|
+
* setWorkspaces({ label, title, items, focus, names, onSelect })
|
|
66
66
|
* the Workspace ▾ menu (header.js)
|
|
67
|
+
* workspaceNames() every workspace's name, from it
|
|
67
68
|
* syncEnv() repaint Env ▾ from #env-sel
|
|
68
69
|
* registerCommand({ id, label, keywords?, kind?, run })
|
|
69
70
|
* a ⌘K palette row (R17, dev.20). label
|
|
@@ -77,6 +78,8 @@
|
|
|
77
78
|
* unregisterCommand(id), commands()
|
|
78
79
|
* openMenu(anchor, menu) a .pop menu below anchor (the scope
|
|
79
80
|
* parts' and the user menu's)
|
|
81
|
+
* topLayer.show(el, anchor, opts) a page's own menu in the top layer
|
|
82
|
+
* (0.4.17), placed by anchor; hide(el)
|
|
80
83
|
*
|
|
81
84
|
* Loading before shell.js: push functions onto window.denShellQueue (an
|
|
82
85
|
* array); each runs with denShell once it exists, and a later push runs at
|
|
@@ -160,6 +163,8 @@
|
|
|
160
163
|
"b.down.one": "{n} down",
|
|
161
164
|
"act.pin": "Pin",
|
|
162
165
|
"act.unpin": "Unpin",
|
|
166
|
+
"act.visit": "Visit",
|
|
167
|
+
"act.visitX": "Visit {s} (opens a new tab)",
|
|
163
168
|
"act.pinX": "Pin {s}",
|
|
164
169
|
"act.unpinX": "Unpin {s}",
|
|
165
170
|
"toast.pinned": "Pinned {s}",
|
|
@@ -220,6 +225,8 @@
|
|
|
220
225
|
"kind.app": "App",
|
|
221
226
|
"kind.action": "Action",
|
|
222
227
|
"kind.setting": "Setting",
|
|
228
|
+
"kind.workspace": "Workspace",
|
|
229
|
+
"pal.goWs": "Go to workspace {ws}",
|
|
223
230
|
"pal.repos": "{n} repos",
|
|
224
231
|
"pal.repos.one": "{n} repo",
|
|
225
232
|
"pal.restart": "Restart {s}",
|
|
@@ -366,12 +373,18 @@
|
|
|
366
373
|
"err.signin.title": "Sign in to {name}",
|
|
367
374
|
"err.signin.where":
|
|
368
375
|
"You're opening {name} from another computer. {name} runs on {host}, so it needs to know this is you.",
|
|
369
|
-
"err.signin.
|
|
370
|
-
"
|
|
371
|
-
"err.signin.
|
|
372
|
-
"
|
|
376
|
+
"err.signin.ghStep1":
|
|
377
|
+
"On {host}, run {cmd}, or use Settings › Access there.",
|
|
378
|
+
"err.signin.ghStep2":
|
|
379
|
+
"It shows a code. Enter it at github.com/login/device and approve.",
|
|
380
|
+
"err.signin.ghStep3":
|
|
381
|
+
"Then come back here and press Sign in with GitHub, with the same GitHub account.",
|
|
382
|
+
"err.signin.ghOwner": "Owner: @{login}",
|
|
383
|
+
"err.signin.ghOnly":
|
|
384
|
+
"Only the owner's GitHub account can sign in this way. For anyone else, use Approve this computer.",
|
|
385
|
+
"err.signin.ghLearn": "Learn more",
|
|
373
386
|
"err.signin.githubRefused":
|
|
374
|
-
"@{login} isn't the owner of this {name}.
|
|
387
|
+
"@{login} isn't the owner of this {name}. Only the owner's account can use Sign in with GitHub. Use Approve this computer instead.",
|
|
375
388
|
"err.signin.approveOr": "Or approve this computer",
|
|
376
389
|
"err.signin.approve": "Approve this computer",
|
|
377
390
|
"err.signin.step1": "On {host} (not this computer), run:",
|
|
@@ -599,6 +612,33 @@
|
|
|
599
612
|
"ag.bring.agent": "Agent {id}",
|
|
600
613
|
"ag.bring.note":
|
|
601
614
|
"Continues it in the folder it ran in, with Den's tools and guards from now on. It keeps running if you close the pane. Close it in any other terminal first: Den only sees a conversation as open if it was written in the last minute.",
|
|
615
|
+
"ag.keep.model": "Keeps {m} from the conversation.",
|
|
616
|
+
"ag.keep.modelNew": "Runs on {m} from now on.",
|
|
617
|
+
"ag.keep.from":
|
|
618
|
+
"From now on it works as {p}: its role, its permissions ({mode}), Den's tools and guards. Its history stays.",
|
|
619
|
+
"ag.keep.lost":
|
|
620
|
+
"It won't have: {s}. Den runs agents with Den's tools only.",
|
|
621
|
+
"ag.keep.opt": "Keep {m}",
|
|
622
|
+
"ag.keep.optNone": "Keep the model it runs",
|
|
623
|
+
"ag.keep.advNote":
|
|
624
|
+
"The model it runs stays unless you pick another here. Permissions come from the persona.",
|
|
625
|
+
"ag.pc.item": "Change persona…",
|
|
626
|
+
"ag.pc.title": "Change {id}'s persona",
|
|
627
|
+
"ag.pc.note":
|
|
628
|
+
"Den stops it and resumes the same conversation as the new persona. Its id, name, folder and tab stay.",
|
|
629
|
+
"ag.pc.go": "Change persona",
|
|
630
|
+
"ag.pc.going": "Changing…",
|
|
631
|
+
"ag.pc.current": "current",
|
|
632
|
+
"ag.pc.busy": "It's working. Change after this turn, or stop it now?",
|
|
633
|
+
"ag.pc.after": "After this turn",
|
|
634
|
+
"ag.pc.now": "Stop it now",
|
|
635
|
+
"ag.pc.fresh":
|
|
636
|
+
"{id} is now {p}: nothing to resume yet; started fresh (same conversation id, folder and scope)",
|
|
637
|
+
"ag.freshLine":
|
|
638
|
+
"Nothing to resume yet; started fresh (same conversation id, folder and scope)",
|
|
639
|
+
"ag.pc.done": "{id} is now {p}",
|
|
640
|
+
"ag.pc.pending": "{id} becomes {p} when this turn ends",
|
|
641
|
+
"ag.more": "More for {id}",
|
|
602
642
|
"ag.bring.go": "Bring in",
|
|
603
643
|
"ag.bring.going": "Bringing in…",
|
|
604
644
|
"ag.bring.done": "Brought in as {id}",
|
|
@@ -745,7 +785,11 @@
|
|
|
745
785
|
"Its token was revoked (web logout --all): stop and resume it, or start it again, to give it a new one",
|
|
746
786
|
"ag.ended":
|
|
747
787
|
"This session has ended. Its record stays until it is resumed or removed.",
|
|
788
|
+
"ag.switched": "Switched to {p}",
|
|
748
789
|
"ag.exitedLine": "[the session exited{code}]",
|
|
790
|
+
"ag.refusedUnknown": "the server refused the terminal",
|
|
791
|
+
"ag.refusedStatus": "the server refused the terminal ({status})",
|
|
792
|
+
"ag.goWs": "Open {ws} on Workspaces",
|
|
749
793
|
"ag.lost": "[disconnected: {why}]",
|
|
750
794
|
"ag.reconnect": "Reconnect",
|
|
751
795
|
"ag.noBackend":
|
|
@@ -1381,6 +1425,116 @@
|
|
|
1381
1425
|
return el;
|
|
1382
1426
|
}
|
|
1383
1427
|
|
|
1428
|
+
// ── the top layer (0.4.17): one placement for every menu ─────────────
|
|
1429
|
+
// A menu shown with topShow is in the browser's top layer (a manual
|
|
1430
|
+
// popover), placed by its anchor: below it, above when there's more room
|
|
1431
|
+
// there, kept inside the window. No overflow or stacking ancestor can
|
|
1432
|
+
// clip or cover it, and it stays where it is in the page, so the page's
|
|
1433
|
+
// own click handlers still reach it. It closes when the page hides it
|
|
1434
|
+
// (display: none), its anchor goes, or topHide.
|
|
1435
|
+
var topOpen = [];
|
|
1436
|
+
var TOP_MARGIN = 8;
|
|
1437
|
+
function topSupported(el) {
|
|
1438
|
+
return typeof el.showPopover === "function";
|
|
1439
|
+
}
|
|
1440
|
+
function topPlace(el, anchor, opts) {
|
|
1441
|
+
var r = anchor.getBoundingClientRect();
|
|
1442
|
+
var gap = opts.gap != null ? opts.gap : 4;
|
|
1443
|
+
el.style.maxHeight = "";
|
|
1444
|
+
var w = el.offsetWidth;
|
|
1445
|
+
var h = el.offsetHeight;
|
|
1446
|
+
var vw = window.innerWidth;
|
|
1447
|
+
var vh = window.innerHeight;
|
|
1448
|
+
var below = vh - r.bottom - gap - TOP_MARGIN;
|
|
1449
|
+
var above = r.top - gap - TOP_MARGIN;
|
|
1450
|
+
var up = h > below && above > below;
|
|
1451
|
+
var room = Math.max(80, up ? above : below);
|
|
1452
|
+
if (h > room) {
|
|
1453
|
+
el.style.maxHeight = room + "px";
|
|
1454
|
+
h = room;
|
|
1455
|
+
}
|
|
1456
|
+
var top = up ? r.top - gap - h : r.bottom + gap;
|
|
1457
|
+
var left = opts.align === "right" ? r.right - w : r.left;
|
|
1458
|
+
left = Math.max(TOP_MARGIN, Math.min(left, vw - w - TOP_MARGIN));
|
|
1459
|
+
// Important, so no page or UA rule (a popover's inset) moves it.
|
|
1460
|
+
el.style.setProperty("top", Math.max(TOP_MARGIN, top) + "px", "important");
|
|
1461
|
+
el.style.setProperty("left", left + "px", "important");
|
|
1462
|
+
}
|
|
1463
|
+
function topIndex(el) {
|
|
1464
|
+
for (var i = 0; i < topOpen.length; i++) if (topOpen[i].el === el) return i;
|
|
1465
|
+
return -1;
|
|
1466
|
+
}
|
|
1467
|
+
function topShow(el, anchor, opts) {
|
|
1468
|
+
if (!el || !anchor) return;
|
|
1469
|
+
opts = opts || {};
|
|
1470
|
+
var i = topIndex(el);
|
|
1471
|
+
if (i >= 0) {
|
|
1472
|
+
topOpen[i].anchor = anchor;
|
|
1473
|
+
topOpen[i].opts = opts;
|
|
1474
|
+
} else {
|
|
1475
|
+
if (topSupported(el)) {
|
|
1476
|
+
if (!el.hasAttribute("popover")) {
|
|
1477
|
+
el.setAttribute("popover", "manual");
|
|
1478
|
+
el.setAttribute("data-den-top-added", "");
|
|
1479
|
+
}
|
|
1480
|
+
try {
|
|
1481
|
+
if (!el.matches(":popover-open")) el.showPopover();
|
|
1482
|
+
} catch (e) {
|
|
1483
|
+
/* not in the document yet: placed as fixed */
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
var o = { el: el, anchor: anchor, opts: opts, mo: null };
|
|
1487
|
+
if (typeof MutationObserver === "function") {
|
|
1488
|
+
o.mo = new MutationObserver(function () {
|
|
1489
|
+
topCheck(o);
|
|
1490
|
+
});
|
|
1491
|
+
o.mo.observe(el, {
|
|
1492
|
+
attributes: true,
|
|
1493
|
+
attributeFilter: ["style", "class", "hidden"],
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
topOpen.push(o);
|
|
1497
|
+
}
|
|
1498
|
+
el.classList.add("den-toplayer");
|
|
1499
|
+
topPlace(el, anchor, opts);
|
|
1500
|
+
}
|
|
1501
|
+
function topHide(el) {
|
|
1502
|
+
var i = topIndex(el);
|
|
1503
|
+
if (i < 0) return;
|
|
1504
|
+
var o = topOpen.splice(i, 1)[0];
|
|
1505
|
+
if (o.mo) o.mo.disconnect();
|
|
1506
|
+
try {
|
|
1507
|
+
if (el.matches && el.matches(":popover-open")) el.hidePopover();
|
|
1508
|
+
} catch (e) {
|
|
1509
|
+
/* already gone */
|
|
1510
|
+
}
|
|
1511
|
+
if (el.hasAttribute("data-den-top-added")) {
|
|
1512
|
+
el.removeAttribute("popover");
|
|
1513
|
+
el.removeAttribute("data-den-top-added");
|
|
1514
|
+
}
|
|
1515
|
+
el.classList.remove("den-toplayer");
|
|
1516
|
+
el.style.removeProperty("top");
|
|
1517
|
+
el.style.removeProperty("left");
|
|
1518
|
+
}
|
|
1519
|
+
/** The page hid it, or its anchor went: it's closed. */
|
|
1520
|
+
function topCheck(o) {
|
|
1521
|
+
if (
|
|
1522
|
+
!o.el.isConnected ||
|
|
1523
|
+
!o.anchor.isConnected ||
|
|
1524
|
+
o.el.hidden ||
|
|
1525
|
+
getComputedStyle(o.el).display === "none"
|
|
1526
|
+
)
|
|
1527
|
+
topHide(o.el);
|
|
1528
|
+
}
|
|
1529
|
+
function topReplace() {
|
|
1530
|
+
topOpen.slice().forEach(function (o) {
|
|
1531
|
+
topCheck(o);
|
|
1532
|
+
if (topIndex(o.el) >= 0) topPlace(o.el, o.anchor, o.opts);
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
window.addEventListener("resize", topReplace);
|
|
1536
|
+
document.addEventListener("scroll", topReplace, true);
|
|
1537
|
+
|
|
1384
1538
|
// ── menus (.pop, R18): the scope parts' popovers ─────────────────────
|
|
1385
1539
|
// A menu is { label, items: [{ heading } | { hr: true } | { label, tag?,
|
|
1386
1540
|
// checked?, href?, onSelect? }] }. Rows with `checked` are radios (✓ when
|
|
@@ -1392,6 +1546,7 @@
|
|
|
1392
1546
|
if (!openPop) return;
|
|
1393
1547
|
var o = openPop;
|
|
1394
1548
|
openPop = null;
|
|
1549
|
+
topHide(o.el);
|
|
1395
1550
|
if (o.el.remove) o.el.remove();
|
|
1396
1551
|
o.anchor.setAttribute("aria-expanded", "false");
|
|
1397
1552
|
// aria-controls names the menu only while it exists.
|
|
@@ -1467,15 +1622,14 @@
|
|
|
1467
1622
|
}
|
|
1468
1623
|
});
|
|
1469
1624
|
pop.innerHTML = html;
|
|
1470
|
-
|
|
1625
|
+
// Inside a modal, in it: anything outside a modal is inert.
|
|
1626
|
+
var host =
|
|
1627
|
+
(anchor.closest && anchor.closest("dialog[open]")) || document.body;
|
|
1628
|
+
host.appendChild(pop);
|
|
1471
1629
|
openPop = { el: pop, anchor: anchor };
|
|
1472
1630
|
anchor.setAttribute("aria-expanded", "true");
|
|
1473
1631
|
anchor.setAttribute("aria-controls", pop.id);
|
|
1474
|
-
|
|
1475
|
-
var w = pop.offsetWidth;
|
|
1476
|
-
pop.style.top = r.bottom + 6 + "px";
|
|
1477
|
-
pop.style.left =
|
|
1478
|
-
Math.max(8, Math.min(r.left, window.innerWidth - w - 8)) + "px";
|
|
1632
|
+
topShow(pop, anchor, { gap: 6 });
|
|
1479
1633
|
pop.addEventListener("click", function (e) {
|
|
1480
1634
|
var row = e.target.closest ? e.target.closest(".den-mi") : null;
|
|
1481
1635
|
if (!row) return;
|
|
@@ -1913,7 +2067,14 @@
|
|
|
1913
2067
|
* a link to manage them. onSelect(value, focus): choosing a workspace
|
|
1914
2068
|
* also makes it the CLI default (header.js).
|
|
1915
2069
|
*/
|
|
2070
|
+
// Every workspace's name, from the Workspace menu's model (header.js).
|
|
2071
|
+
var workspaceNameList = [];
|
|
2072
|
+
function workspaceNames() {
|
|
2073
|
+
return workspaceNameList.slice();
|
|
2074
|
+
}
|
|
1916
2075
|
function setWorkspaces(model) {
|
|
2076
|
+
workspaceNameList =
|
|
2077
|
+
model && Array.isArray(model.names) ? model.names.slice() : [];
|
|
1917
2078
|
var btn = document.getElementById("ws-pill");
|
|
1918
2079
|
if (!btn) return;
|
|
1919
2080
|
var sep = btn.nextElementSibling;
|
|
@@ -4347,6 +4508,7 @@
|
|
|
4347
4508
|
setOrg: setOrg,
|
|
4348
4509
|
setOrgs: setOrgs,
|
|
4349
4510
|
setWorkspaces: setWorkspaces,
|
|
4511
|
+
workspaceNames: workspaceNames,
|
|
4350
4512
|
/** Repaint the Env part from #env-sel (after setting its value). */
|
|
4351
4513
|
syncEnv: syncEnv,
|
|
4352
4514
|
syncPinToggles: syncPinToggles,
|
|
@@ -4355,6 +4517,12 @@
|
|
|
4355
4517
|
commands: commands,
|
|
4356
4518
|
/** A .pop menu below `anchor` (the model as the scope menus'). */
|
|
4357
4519
|
openMenu: openMenu,
|
|
4520
|
+
/**
|
|
4521
|
+
* The top layer (0.4.17): show(el, anchor, { align: "left"|"right",
|
|
4522
|
+
* gap }) puts a page's own menu above everything, placed by its
|
|
4523
|
+
* anchor; hide(el). The page hiding it (display: none) closes it too.
|
|
4524
|
+
*/
|
|
4525
|
+
topLayer: { show: topShow, hide: topHide },
|
|
4358
4526
|
closeMenu: function () {
|
|
4359
4527
|
closeMenu(false);
|
|
4360
4528
|
},
|
|
@@ -4514,6 +4682,13 @@
|
|
|
4514
4682
|
" max-height: calc(100vh - 72px); overflow-y: auto; box-sizing: border-box;",
|
|
4515
4683
|
" font: 14px/1.45 var(--sans); color: var(--text); }",
|
|
4516
4684
|
".den-pop:focus { outline: none; }",
|
|
4685
|
+
// The top layer (0.4.17): a page's menu placed by its anchor (topShow), not
|
|
4686
|
+
// where the UA puts a popover.
|
|
4687
|
+
// No inset here: top and left are topPlace's (an !important inset beat them,
|
|
4688
|
+
// and every menu sat at the window's corner, 0.4.17-dev.0).
|
|
4689
|
+
".den-toplayer { position: fixed !important; margin: 0 !important;",
|
|
4690
|
+
" right: auto !important; bottom: auto !important; z-index: 1000; overflow-y: auto;",
|
|
4691
|
+
" color: var(--text); }",
|
|
4517
4692
|
// LK-1: the admin-password prompt is a <dialog> in the top layer (a popover, or
|
|
4518
4693
|
// a modal over another modal): placed by its top/left, no UA box.
|
|
4519
4694
|
"dialog.den-pop { margin: 0; inset: auto; max-width: none; }",
|
|
@@ -421,7 +421,15 @@ export function runOf(url, base = "http://localhost/") {
|
|
|
421
421
|
|
|
422
422
|
const LS_LAST_RUN = "__DEN_CLI__-last-run";
|
|
423
423
|
const SS_PENDING = "__DEN_CLI__-pending-action";
|
|
424
|
-
const KIND = [
|
|
424
|
+
const KIND = [
|
|
425
|
+
"page",
|
|
426
|
+
"service",
|
|
427
|
+
"app",
|
|
428
|
+
"agent",
|
|
429
|
+
"workspace",
|
|
430
|
+
"action",
|
|
431
|
+
"setting",
|
|
432
|
+
];
|
|
425
433
|
const kindLabel = (k) =>
|
|
426
434
|
KIND.includes(k)
|
|
427
435
|
? t(`kind.${k}`)
|
|
@@ -906,6 +914,31 @@ function paletteItems() {
|
|
|
906
914
|
run: () => go(`/agents?open=${encodeURIComponent(x.id)}`),
|
|
907
915
|
});
|
|
908
916
|
}
|
|
917
|
+
// 0.4.17: Go to workspace <ws>, for every workspace, the front agent's
|
|
918
|
+
// first (on the Agents page); an agent's name finds its workspace too.
|
|
919
|
+
const byWs = new Map();
|
|
920
|
+
const front = document.querySelector("#agr")?.dataset.ws;
|
|
921
|
+
if (front) byWs.set(front, []);
|
|
922
|
+
// Every workspace: the Workspace menu's list (the header read it).
|
|
923
|
+
for (const ws of s?.workspaceNames?.() ?? [])
|
|
924
|
+
if (!byWs.has(ws)) byWs.set(ws, []);
|
|
925
|
+
for (const x of palSessions) {
|
|
926
|
+
if (!x.workspace) continue;
|
|
927
|
+
const names = byWs.get(x.workspace) ?? [];
|
|
928
|
+
names.push(x.label, x.displayName ?? "", x.persona ?? "");
|
|
929
|
+
byWs.set(x.workspace, names);
|
|
930
|
+
}
|
|
931
|
+
const wsBase =
|
|
932
|
+
s?.nav?.().find((n) => n.id === "local-dev")?.href ?? "/local-dev";
|
|
933
|
+
for (const [ws, names] of byWs)
|
|
934
|
+
items.push({
|
|
935
|
+
key: `ws:${ws}`,
|
|
936
|
+
kind: "workspace",
|
|
937
|
+
label: t("pal.goWs", { ws }),
|
|
938
|
+
keywords: [ws, "workspace", "workspaces", ...names.filter(Boolean)],
|
|
939
|
+
hideEmpty: true,
|
|
940
|
+
run: () => go(`${wsBase}?ws=${encodeURIComponent(ws)}`),
|
|
941
|
+
});
|
|
909
942
|
if (s?.nav?.().some((n) => n.id === "agents"))
|
|
910
943
|
items.push({
|
|
911
944
|
key: "agent:start",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One rule for org and workspace on a page (0.4.17, ORG-SPEC §5): a page
|
|
3
|
+
* shows its own org picker only when it filters by org. Otherwise it
|
|
4
|
+
* follows the top bar's View (Org and Workspace ▾), and says so with the
|
|
5
|
+
* same note. Environments and Releases both use this.
|
|
6
|
+
*
|
|
7
|
+
* viewScope() → { scope: "all" | <workspace>, inView: [names] }
|
|
8
|
+
* viewNoteHtml(view) → the "Follows the top bar" note's HTML
|
|
9
|
+
* viewQuery(view) → "&workspace=<ws>" for one workspace, else ""
|
|
10
|
+
* viewRepos(view) → the repos in view (a Set), or null for all
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const esc = (s) =>
|
|
14
|
+
String(s ?? "").replace(
|
|
15
|
+
/[&<>"']/g,
|
|
16
|
+
(c) =>
|
|
17
|
+
({
|
|
18
|
+
"&": "&",
|
|
19
|
+
"<": "<",
|
|
20
|
+
">": ">",
|
|
21
|
+
'"': """,
|
|
22
|
+
"'": "'",
|
|
23
|
+
})[c],
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
/** The top bar's View: all active workspaces, or one. */
|
|
27
|
+
export async function viewScope() {
|
|
28
|
+
const v = await fetch("/api/scope", { headers: { accept: "json" } })
|
|
29
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
30
|
+
.catch(() => null);
|
|
31
|
+
return {
|
|
32
|
+
scope: typeof v?.scope === "string" && v.scope ? v.scope : "all",
|
|
33
|
+
inView: Array.isArray(v?.inView) ? v.inView : [],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The same note on every page that follows the View. */
|
|
38
|
+
export function viewNoteHtml(view) {
|
|
39
|
+
const what =
|
|
40
|
+
view.scope === "all"
|
|
41
|
+
? "all active workspaces"
|
|
42
|
+
: `workspace <b>${esc(view.scope)}</b>`;
|
|
43
|
+
return `<span class="note view-note" data-view="${esc(view.scope)}">Shows ${what}, as the top bar's View. Change it there.</span>`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The health and services query for the View: one workspace, or none. */
|
|
47
|
+
export function viewQuery(view) {
|
|
48
|
+
return view.scope === "all"
|
|
49
|
+
? ""
|
|
50
|
+
: `&workspace=${encodeURIComponent(view.scope)}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The repos in the View's workspace, or null when it is all active. */
|
|
54
|
+
export async function viewRepos(view) {
|
|
55
|
+
if (view.scope === "all") return null;
|
|
56
|
+
const d = await fetch("/api/workspaces", { headers: { accept: "json" } })
|
|
57
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
58
|
+
.catch(() => null);
|
|
59
|
+
const ws = d?.workspaces?.[view.scope];
|
|
60
|
+
return ws ? new Set(Object.keys(ws.services ?? {})) : null;
|
|
61
|
+
}
|
|
@@ -93,6 +93,8 @@ export function buildScopeMenuModel(scope, data, t = enT) {
|
|
|
93
93
|
return {
|
|
94
94
|
label: active.length === 0 ? t("ws.noneLabel") : (one ?? t("ws.all")),
|
|
95
95
|
items,
|
|
96
|
+
// Every workspace, active or not: ⌘K's Go to workspace (0.4.17).
|
|
97
|
+
names: Object.keys(data?.workspaces ?? {}),
|
|
96
98
|
focus: focus ?? null,
|
|
97
99
|
title: t(focus ? "ws.viewTitleFocus" : "ws.viewTitle", {
|
|
98
100
|
ws: inView.join(", ") || t("ws.noneLabel"),
|
|
@@ -127,6 +127,8 @@ export const ROUTES: readonly RouteEntry[] = [
|
|
|
127
127
|
"/api/release/config",
|
|
128
128
|
"/api/release/status",
|
|
129
129
|
"/api/release/runs",
|
|
130
|
+
// 0.4.17: each repo's last test run, from the run-history file.
|
|
131
|
+
"/api/runs/last",
|
|
130
132
|
"/api/release/branches",
|
|
131
133
|
"/api/release/deploy/active",
|
|
132
134
|
"/api/changelog",
|
|
@@ -367,6 +369,11 @@ export const ROUTES: readonly RouteEntry[] = [
|
|
|
367
369
|
"/api/workspaces/:ws/agents/:id/fresh",
|
|
368
370
|
"starts an agent afresh: a runtime in a PTY",
|
|
369
371
|
),
|
|
372
|
+
S(
|
|
373
|
+
["POST"],
|
|
374
|
+
"/api/workspaces/:ws/agents/:id/persona",
|
|
375
|
+
"changes an agent's persona (AG-18): its session stops and the same conversation resumes, a runtime in a PTY; a person's only",
|
|
376
|
+
),
|
|
370
377
|
S(
|
|
371
378
|
["POST"],
|
|
372
379
|
"/api/workspaces/:ws/agents/:id/relaunch",
|
|
@@ -468,6 +475,7 @@ export const REGEX_ROUTES: Readonly<Record<string, readonly string[]>> = {
|
|
|
468
475
|
"/api/workspaces/:ws/agents/:id/remove",
|
|
469
476
|
"/api/workspaces/:ws/agents/:id/name",
|
|
470
477
|
"/api/workspaces/:ws/agents/:id/paths",
|
|
478
|
+
"/api/workspaces/:ws/agents/:id/persona",
|
|
471
479
|
],
|
|
472
480
|
"agent-routes.ts:RUNTIME_PATH": ["/api/agents/runtimes/:id/path"],
|
|
473
481
|
"agent-routes.ts:PERSONA_PATH": ["/api/personas/:id"],
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server's test-run history (0.4.17): every `/api/run` from every
|
|
3
|
+
* browser, kept in `<state>/run-history.jsonl`, one JSON line per start and
|
|
4
|
+
* one per end. Workspaces' "Last test run" column reads each repo's latest
|
|
5
|
+
* from it, so it fills in whoever ran it and survives a restart.
|
|
6
|
+
*
|
|
7
|
+
* The file is bounded: past MAX_LINES it keeps the newest KEEP_LINES.
|
|
8
|
+
* Never throws: a history it can't read or write is an empty one.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "fs";
|
|
11
|
+
import * as path from "path";
|
|
12
|
+
|
|
13
|
+
/** One test run, as its lines describe it. */
|
|
14
|
+
export interface RunEntry {
|
|
15
|
+
id: string;
|
|
16
|
+
pillar: string;
|
|
17
|
+
env: string;
|
|
18
|
+
/** The repos it tested (the pillar's sources), when known. */
|
|
19
|
+
repos: string[];
|
|
20
|
+
startedAt: string;
|
|
21
|
+
endedAt?: string;
|
|
22
|
+
exitCode?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type Line =
|
|
26
|
+
| {
|
|
27
|
+
t: "start";
|
|
28
|
+
id: string;
|
|
29
|
+
pillar: string;
|
|
30
|
+
env: string;
|
|
31
|
+
repos: string[];
|
|
32
|
+
at: string;
|
|
33
|
+
}
|
|
34
|
+
| { t: "end"; id: string; at: string; exitCode: number };
|
|
35
|
+
|
|
36
|
+
/** What `reposOfRun` asks about the repos (index.ts gives the real ones). */
|
|
37
|
+
export interface RunRepoSources {
|
|
38
|
+
/** synthetic, smoke, journeys, security: the repos the pillar reads. */
|
|
39
|
+
pillarSources(pillar: string): string[];
|
|
40
|
+
/** suites: the repos whose `tests:` suites the run will run. */
|
|
41
|
+
suiteRepos(env: string, only: string[]): string[];
|
|
42
|
+
/** performance: every module's name (a repo's is `<repo>/<module>`). */
|
|
43
|
+
perfModules(): Promise<string[]>;
|
|
44
|
+
/** Every repo's name. */
|
|
45
|
+
repoNames(): string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The repos a run tests, for its history (0.4.17): every run kind that
|
|
50
|
+
* runs a repo's own tests fills its Last test run. integration is Den's
|
|
51
|
+
* own: no repo. Never throws: a source it can't read gives none.
|
|
52
|
+
*/
|
|
53
|
+
export async function reposOfRun(
|
|
54
|
+
pillar: string,
|
|
55
|
+
env: string,
|
|
56
|
+
modules: string[],
|
|
57
|
+
src: RunRepoSources,
|
|
58
|
+
): Promise<string[]> {
|
|
59
|
+
const uniq = (xs: string[]) => [...new Set(xs.filter(Boolean))];
|
|
60
|
+
try {
|
|
61
|
+
if (["synthetic", "smoke", "journeys", "security"].includes(pillar))
|
|
62
|
+
return uniq(src.pillarSources(pillar));
|
|
63
|
+
if (pillar === "suites") return uniq(src.suiteRepos(env, modules));
|
|
64
|
+
if (pillar === "performance") {
|
|
65
|
+
const repos = new Set(src.repoNames());
|
|
66
|
+
const chosen = modules.length ? modules : await src.perfModules();
|
|
67
|
+
return uniq(
|
|
68
|
+
chosen
|
|
69
|
+
.filter((n) => n.includes("/"))
|
|
70
|
+
.map((n) => n.split("/")[0])
|
|
71
|
+
.filter((r) => repos.has(r)),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
/* no repos known: the history still has the run */
|
|
76
|
+
}
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const MAX_LINES = 2000;
|
|
81
|
+
export const KEEP_LINES = 1000;
|
|
82
|
+
|
|
83
|
+
export function runHistoryFile(stateDir: string): string {
|
|
84
|
+
return path.join(stateDir, "run-history.jsonl");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function append(stateDir: string, line: Line): void {
|
|
88
|
+
const file = runHistoryFile(stateDir);
|
|
89
|
+
try {
|
|
90
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
91
|
+
fs.appendFileSync(file, `${JSON.stringify(line)}\n`, { mode: 0o600 });
|
|
92
|
+
const text = fs.readFileSync(file, "utf-8");
|
|
93
|
+
const lines = text.split("\n").filter(Boolean);
|
|
94
|
+
if (lines.length > MAX_LINES) {
|
|
95
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
96
|
+
fs.writeFileSync(tmp, `${lines.slice(-KEEP_LINES).join("\n")}\n`, {
|
|
97
|
+
mode: 0o600,
|
|
98
|
+
});
|
|
99
|
+
fs.renameSync(tmp, file);
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
/* no history: the column says "—" */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A run began: its pillar, environment and the repos it tests. */
|
|
107
|
+
export function recordRunStart(
|
|
108
|
+
stateDir: string,
|
|
109
|
+
run: { id: string; pillar: string; env: string; repos: string[] },
|
|
110
|
+
now = new Date(),
|
|
111
|
+
): void {
|
|
112
|
+
append(stateDir, { t: "start", ...run, at: now.toISOString() });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A run ended with `exitCode`. */
|
|
116
|
+
export function recordRunEnd(
|
|
117
|
+
stateDir: string,
|
|
118
|
+
id: string,
|
|
119
|
+
exitCode: number,
|
|
120
|
+
now = new Date(),
|
|
121
|
+
): void {
|
|
122
|
+
append(stateDir, { t: "end", id, at: now.toISOString(), exitCode });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Every run in the file, oldest first. */
|
|
126
|
+
export function readRuns(stateDir: string): RunEntry[] {
|
|
127
|
+
let text = "";
|
|
128
|
+
try {
|
|
129
|
+
text = fs.readFileSync(runHistoryFile(stateDir), "utf-8");
|
|
130
|
+
} catch {
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
const byId = new Map<string, RunEntry>();
|
|
134
|
+
for (const raw of text.split("\n")) {
|
|
135
|
+
if (!raw) continue;
|
|
136
|
+
let l: Partial<Line> & Record<string, unknown>;
|
|
137
|
+
try {
|
|
138
|
+
l = JSON.parse(raw) as typeof l;
|
|
139
|
+
} catch {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (typeof l.id !== "string" || typeof l.at !== "string") continue;
|
|
143
|
+
if (l.t === "start") {
|
|
144
|
+
byId.set(l.id, {
|
|
145
|
+
id: l.id,
|
|
146
|
+
pillar: String(l.pillar ?? ""),
|
|
147
|
+
env: String(l.env ?? ""),
|
|
148
|
+
repos: Array.isArray(l.repos) ? l.repos.map(String) : [],
|
|
149
|
+
startedAt: l.at,
|
|
150
|
+
});
|
|
151
|
+
} else if (l.t === "end") {
|
|
152
|
+
const r = byId.get(l.id);
|
|
153
|
+
if (r) {
|
|
154
|
+
r.endedAt = l.at;
|
|
155
|
+
r.exitCode = typeof l.exitCode === "number" ? l.exitCode : 1;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return [...byId.values()];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Each repo's latest run. A run that never ended counts as running only
|
|
164
|
+
* while this server runs it (`live`); one a restart cut off has no end and
|
|
165
|
+
* is skipped for an earlier one.
|
|
166
|
+
*/
|
|
167
|
+
export function lastRunsByRepo(
|
|
168
|
+
stateDir: string,
|
|
169
|
+
live: ReadonlySet<string> = new Set(),
|
|
170
|
+
): Record<string, RunEntry & { running: boolean }> {
|
|
171
|
+
const out: Record<string, RunEntry & { running: boolean }> = {};
|
|
172
|
+
for (const r of readRuns(stateDir)) {
|
|
173
|
+
const running = r.endedAt === undefined && live.has(r.id);
|
|
174
|
+
if (r.endedAt === undefined && !running) continue;
|
|
175
|
+
for (const repo of r.repos) {
|
|
176
|
+
const cur = out[repo];
|
|
177
|
+
if (!cur || cur.startedAt <= r.startedAt) out[repo] = { ...r, running };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|