@monster-orange/den 0.4.16 → 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 +58 -0
- package/README.md +7 -7
- package/docs/distributions.md +55 -0
- package/package.json +1 -1
- package/src/ui/cli/agent-ops.ts +7 -0
- package/src/ui/cli/agents-command.ts +5 -5
- package/src/ui/server/agent-routes.ts +4 -1
- package/src/ui/server/check-pool.ts +72 -0
- package/src/ui/server/index.ts +105 -10
- package/src/ui/server/public/agents-page.js +75 -16
- package/src/ui/server/public/agents.html +9 -0
- package/src/ui/server/public/environments.html +223 -140
- 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 +11 -0
- package/src/ui/server/public/i18n/ja.js +11 -0
- 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/release.html +19 -1
- package/src/ui/server/public/shell.js +152 -7
- 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 +2 -0
- package/src/ui/server/run-history.ts +181 -0
- package/src/ui/server/session-routes.ts +94 -17
- package/src/ui/server/ui-assets.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,64 @@ All notable changes to Den are documented here. Each release lists the
|
|
|
4
4
|
config and manifest changes a distribution must make to move to it under
|
|
5
5
|
**Migrations**.
|
|
6
6
|
|
|
7
|
+
## [0.4.17] - 2026-11-08
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **An agent's workspace is a link.** The agent pane's header and the
|
|
12
|
+
workspace's heading in the Agents list link to the Workspaces page
|
|
13
|
+
focused on it (`?ws=`). ⌘K has "Go to workspace <ws>" for every
|
|
14
|
+
workspace, the front agent's first on the Agents page, found by the
|
|
15
|
+
workspace's name or any of its agents'.
|
|
16
|
+
- **"Last test run" is the server's.** The Workspaces page's "Last run"
|
|
17
|
+
column is now "Last test run", read from the server's run history
|
|
18
|
+
(`<state>/run-history.jsonl`, `GET /api/runs/last`). It shows runs from
|
|
19
|
+
every browser and survives a restart. The service's own state ("up 2h")
|
|
20
|
+
is shown apart from it.
|
|
21
|
+
- **One org rule.** Environments and Releases have no org picker of their
|
|
22
|
+
own. Both follow the top bar's View and say so with the same note. A
|
|
23
|
+
page shows its own org picker only when it filters by org.
|
|
24
|
+
- **Environments never waits on a slow host.** Every row shows at once,
|
|
25
|
+
pulsing "checking", and each result streams into its row. The server
|
|
26
|
+
runs at most 12 checks at once, each capped at 10 s; one that runs out
|
|
27
|
+
of time says "timed out after 10 s". Re-check works while checks run.
|
|
28
|
+
- **An agent in an inactive workspace opens its terminal.** The terminal
|
|
29
|
+
route checks the person and the agent, not the workspace's active
|
|
30
|
+
flag. The pane says "Sign in" only when you're signed out; any other
|
|
31
|
+
refusal gives its real reason.
|
|
32
|
+
- **Menus are never clipped.** Every ⋯ menu in the shell (Workspaces
|
|
33
|
+
rows and the workspace menu, the top bar's menus) now opens in the
|
|
34
|
+
browser's top layer through one shared helper, so a scrolling table or
|
|
35
|
+
card no longer cuts it off. It stays beside its button, flips up near
|
|
36
|
+
the bottom of the window, and closes on an outside click as before.
|
|
37
|
+
- **The Tests module picker is never clipped.** It uses the same
|
|
38
|
+
top-layer menu.
|
|
39
|
+
- **Visit ↗ opens a service.** Workspaces (beside Start, Stop, Restart
|
|
40
|
+
and Logs, and as a link on the service's name), Services and Graph
|
|
41
|
+
cards open the service's edge URL when it is routed, else its local
|
|
42
|
+
URL, in a new tab. A service with no URL has no Visit. Below 1600 px
|
|
43
|
+
wide the Workspaces button shows only "↗". The detail panel's "Open ↗"
|
|
44
|
+
is now "Visit ↗".
|
|
45
|
+
- **Favourite a service from Workspaces and Graph.** Workspaces rows and
|
|
46
|
+
Graph cards (top-right) have the ☆ star. It uses the same list as
|
|
47
|
+
Services, so a favourite set on one page shows on all of them.
|
|
48
|
+
- **Change persona on an agent that was never prompted** said "same
|
|
49
|
+
conversation", though there was nothing to resume: it starts fresh under
|
|
50
|
+
the same conversation id, as Resume does. The CLI, the API (`note`, with
|
|
51
|
+
`fresh: true`), the toast and the pane now say "nothing to resume yet;
|
|
52
|
+
started fresh (same conversation id, folder and scope)".
|
|
53
|
+
- **Last test run counts a repo's own suites.** A run of the suites pillar
|
|
54
|
+
(a repo's `tests:`, such as `unit`) and a repo's performance modules
|
|
55
|
+
now name the repos they test, so a repo with only `tests: unit` fills
|
|
56
|
+
its Last test run. Den's own integration tests name none.
|
|
57
|
+
- **The Workspaces table keeps its height in a short window.** Under about
|
|
58
|
+
620 px tall it shrank to nothing and its rows and ⋯ were out of reach.
|
|
59
|
+
It keeps at least 200 px and scrolls.
|
|
60
|
+
|
|
61
|
+
### Migrations
|
|
62
|
+
|
|
63
|
+
None.
|
|
64
|
+
|
|
7
65
|
## [0.4.16] - 2026-11-08
|
|
8
66
|
|
|
9
67
|
### Added
|
package/README.md
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
12
|
<picture>
|
|
13
|
-
<source media="(prefers-color-scheme: dark)" srcset="docs/images/0.4.
|
|
14
|
-
<img src="docs/images/0.4.
|
|
13
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/images/0.4.17/hero-dark.png">
|
|
14
|
+
<img src="docs/images/0.4.17/hero-light.png" width="100%" alt="Den's Graph page: the demo workspace's services grouped by application, all healthy, with the status chip and the left rail">
|
|
15
15
|
</picture>
|
|
16
16
|
|
|
17
17
|
## Set up by your agent
|
|
@@ -38,7 +38,7 @@ It doesn't replace your tools. Your repos, Docker, your test runners, your secre
|
|
|
38
38
|
yours. Den connects them and turns on good practice from the start.
|
|
39
39
|
|
|
40
40
|
<p align="center">
|
|
41
|
-
<img src="docs/images/0.4.
|
|
41
|
+
<img src="docs/images/0.4.17/up.gif" width="100%" alt="make up in the Graph page: every stack down, then demo-api, demo-web and demo-notes come up in dependency order until the status chip reads All 3 healthy">
|
|
42
42
|
</p>
|
|
43
43
|
|
|
44
44
|
## How it helps
|
|
@@ -68,10 +68,10 @@ yours. Den connects them and turns on good practice from the start.
|
|
|
68
68
|
lives in your own repo.
|
|
69
69
|
|
|
70
70
|
<p align="center">
|
|
71
|
-
<img src="docs/images/0.4.
|
|
72
|
-
<img src="docs/images/0.4.
|
|
73
|
-
<img src="docs/images/0.4.
|
|
74
|
-
<img src="docs/images/0.4.
|
|
71
|
+
<img src="docs/images/0.4.17/agents.png" width="49%" alt="The Agents tab: the demo workspace's sessions on the left, and one open in a terminal pane on the right, running demo-api's test and then the service">
|
|
72
|
+
<img src="docs/images/0.4.17/workspaces.png" width="49%" alt="The Workspaces page: the demo workspace's repos with their health, mode and Start, Stop, Restart and Logs actions">
|
|
73
|
+
<img src="docs/images/0.4.17/status-drawer.png" width="49%" alt="The status drawer: nothing needs attention, and every service in the demo workspace, by application, with its latency">
|
|
74
|
+
<img src="docs/images/0.4.17/edge.png" width="49%" alt="The Edge page: the edge container up, its ports and TLS, and every route it serves, Den's own and each stack's, with its upstream">
|
|
75
75
|
</p>
|
|
76
76
|
|
|
77
77
|
## Get started
|
package/docs/distributions.md
CHANGED
|
@@ -355,6 +355,19 @@ is never mistaken for another repo's service. An alias that names more than
|
|
|
355
355
|
one service is an error that lists them (`"worker" is ambiguous in workspace
|
|
356
356
|
"default": one/worker, two/worker`); name one of them.
|
|
357
357
|
|
|
358
|
+
## Opening a service
|
|
359
|
+
|
|
360
|
+
A service with a URL gets **Visit ↗**, which opens it in a new tab. It
|
|
361
|
+
shows on Workspaces (beside Start, Stop, Restart and Logs, and as a link
|
|
362
|
+
on the service's name), on Services and on Graph cards. Den picks the
|
|
363
|
+
URL in this order: the service's `urls.<env>`; for `local`, its edge
|
|
364
|
+
route when the edge serves one; else the origin of its HTTP health
|
|
365
|
+
check. A service with none of these has no Visit.
|
|
366
|
+
|
|
367
|
+
The ☆ star on Workspaces rows, Services and Graph cards marks a
|
|
368
|
+
favourite. It is one list, saved in `shell.pins`, so a star set on one
|
|
369
|
+
page shows on all of them.
|
|
370
|
+
|
|
358
371
|
## Health checks
|
|
359
372
|
|
|
360
373
|
A service declares its health per environment under `health`, in one of
|
|
@@ -432,6 +445,36 @@ that a container existed, running or not).
|
|
|
432
445
|
`service`, with both forms at once, or with a key of another type (`cmd`,
|
|
433
446
|
`url`), and says which.
|
|
434
447
|
|
|
448
|
+
### The Environments page
|
|
449
|
+
|
|
450
|
+
The Environments page lists every service's row at once, each cell pulsing
|
|
451
|
+
"checking" until its result arrives. The results stream in one at a time
|
|
452
|
+
(`GET /api/health/stream`, one event per check), so a slow host holds up
|
|
453
|
+
its own cell only. The server runs at most 12 checks at once, and no check
|
|
454
|
+
takes longer than 10 s. A check that runs out of time says "timed out after
|
|
455
|
+
10 s" in its row. Re-check and the page's other controls work while the
|
|
456
|
+
checks run.
|
|
457
|
+
|
|
458
|
+
The page has no org picker of its own. It follows the top bar's View (all
|
|
459
|
+
active workspaces, or one), asks for that workspace only, and says so:
|
|
460
|
+
"Shows workspace south, as the top bar's View. Change it there." The
|
|
461
|
+
Release page follows the same rule with the same note. A page shows its own
|
|
462
|
+
org picker only when it filters by org.
|
|
463
|
+
|
|
464
|
+
### Last test run
|
|
465
|
+
|
|
466
|
+
The Workspaces page's "Last test run" column shows each repo's latest test
|
|
467
|
+
run: its result (✓ or ✗), how long ago, and the pillar and environment on
|
|
468
|
+
hover. It reads the server's run history, so it shows runs started from any
|
|
469
|
+
browser or tab, and it survives a restart. The service's own state ("up 2h")
|
|
470
|
+
is shown apart from it, in the Health cell.
|
|
471
|
+
|
|
472
|
+
The history is `<state>/run-history.jsonl` (mode 0600): one line when a run
|
|
473
|
+
starts and one when it ends. Past 2000 lines it keeps the newest 1000.
|
|
474
|
+
`GET /api/runs/last` (read only) gives `{ repos: { <repo>: { id, pillar,
|
|
475
|
+
env, repos, startedAt, endedAt, exitCode, running } } }`. A run a restart
|
|
476
|
+
cut off has no end and is not shown.
|
|
477
|
+
|
|
435
478
|
## Placement
|
|
436
479
|
|
|
437
480
|
Each service says where it runs, per environment: `local` (on this machine,
|
|
@@ -2552,6 +2595,18 @@ text frame `{"type":"resize","cols":c,"rows":r}` resizes, and the server
|
|
|
2552
2595
|
sends `{"type":"session",…}` first and `{"type":"exit","exitCode":n}` when
|
|
2553
2596
|
the session ends.
|
|
2554
2597
|
|
|
2598
|
+
The terminal route checks the person and the agent, not whether the
|
|
2599
|
+
agent's workspace is active: an agent in a workspace you've switched away
|
|
2600
|
+
from still opens. A `GET` of the io path (not a WebSocket) says why one
|
|
2601
|
+
would be refused: `401` when you're signed out (the pane then says "Sign
|
|
2602
|
+
in"), `404` with the reason when there is no such session here, and `426`
|
|
2603
|
+
when the upgrade would be allowed. The pane shows that reason, not "Sign
|
|
2604
|
+
in", for any refusal but the first.
|
|
2605
|
+
|
|
2606
|
+
The pane's header and the workspace's heading in the list link to the
|
|
2607
|
+
Workspaces page focused on that workspace (`/local-dev?ws=<ws>`), and ⌘K
|
|
2608
|
+
has "Go to workspace <ws>" for each workspace with agents.
|
|
2609
|
+
|
|
2555
2610
|
### Stopped agents: Resume, Start fresh, Remove
|
|
2556
2611
|
|
|
2557
2612
|
An agent that stops stays in the list, below the running ones (AG-14, the
|
package/package.json
CHANGED
package/src/ui/cli/agent-ops.ts
CHANGED
|
@@ -2212,6 +2212,13 @@ export interface PersonaChanged extends Resumed {
|
|
|
2212
2212
|
from: string;
|
|
2213
2213
|
}
|
|
2214
2214
|
|
|
2215
|
+
/**
|
|
2216
|
+
* What a resume with nothing to continue yet says (AG-14, AG-18): the
|
|
2217
|
+
* runtime started fresh under the same conversation id.
|
|
2218
|
+
*/
|
|
2219
|
+
export const FRESH_NOTE =
|
|
2220
|
+
"nothing to resume yet; started fresh (same conversation id, folder and scope)";
|
|
2221
|
+
|
|
2215
2222
|
/** How often, and how long, "after this turn" checks its turn (AG-18). */
|
|
2216
2223
|
export const TURN_POLL_MS = 1_000;
|
|
2217
2224
|
export const TURN_WAIT_MS = 30 * 60_000;
|
|
@@ -95,6 +95,7 @@ import {
|
|
|
95
95
|
type AgentsDeps,
|
|
96
96
|
type AgentsResolved,
|
|
97
97
|
type EarlyExit,
|
|
98
|
+
FRESH_NOTE,
|
|
98
99
|
type RuntimeChoice,
|
|
99
100
|
type Started,
|
|
100
101
|
} from "./agent-ops.js";
|
|
@@ -420,7 +421,9 @@ async function persona(args: string[], d: AgentsResolved): Promise<number> {
|
|
|
420
421
|
return report(
|
|
421
422
|
d,
|
|
422
423
|
s,
|
|
423
|
-
|
|
424
|
+
s.fresh
|
|
425
|
+
? `agent ${s.rec.id} is now ${s.rec.persona} (was ${s.from}); ${FRESH_NOTE}, as ${s.rec.sessionName} in ${s.rec.cwd}`
|
|
426
|
+
: `agent ${s.rec.id} is now ${s.rec.persona} (was ${s.from}); same conversation, as ${s.rec.sessionName} in ${s.rec.cwd}`,
|
|
424
427
|
);
|
|
425
428
|
}
|
|
426
429
|
|
|
@@ -468,10 +471,7 @@ async function resume(args: string[], d: AgentsResolved): Promise<number> {
|
|
|
468
471
|
if (args.includes("--all-interrupted")) return resumeAll(args, d);
|
|
469
472
|
const { id, ws } = idAndWorkspace("agents resume", args);
|
|
470
473
|
const s = await resumeAgent(d, id, ws);
|
|
471
|
-
if (s.fresh)
|
|
472
|
-
d.out(
|
|
473
|
-
`agent ${s.rec.id}: nothing to resume yet; started fresh (same conversation id, folder and scope)`,
|
|
474
|
-
);
|
|
474
|
+
if (s.fresh) d.out(`agent ${s.rec.id}: ${FRESH_NOTE}`);
|
|
475
475
|
return report(
|
|
476
476
|
d,
|
|
477
477
|
s,
|
|
@@ -140,6 +140,7 @@ import {
|
|
|
140
140
|
type AgentsDeps,
|
|
141
141
|
type AgentsResolved,
|
|
142
142
|
type Started,
|
|
143
|
+
FRESH_NOTE,
|
|
143
144
|
} from "../cli/agent-ops.js";
|
|
144
145
|
import { agentSessionName } from "../../shared/agents/session-name.js";
|
|
145
146
|
import { recordAction } from "./audit.js";
|
|
@@ -714,7 +715,9 @@ export async function handleAgentRoutes(
|
|
|
714
715
|
}
|
|
715
716
|
const s = await change();
|
|
716
717
|
audit(what, s.early ? "exited at once" : undefined);
|
|
717
|
-
|
|
718
|
+
// Never prompted: nothing was resumed, so it says so (not "same
|
|
719
|
+
// conversation").
|
|
720
|
+
sendStarted(res, s, d, s.fresh ? { note: FRESH_NOTE } : {});
|
|
718
721
|
} else if (verb === "remove") {
|
|
719
722
|
const removed = await removeAgent(d, id!, ws);
|
|
720
723
|
audit(what);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How health checks run (0.4.17): at most CHECK_CONCURRENCY at once, each
|
|
3
|
+
* given at most CHECK_TIMEOUT_MS. A check that runs over its time gets the
|
|
4
|
+
* caller's stand-in result ("timed out after 10 s") rather than holding up
|
|
5
|
+
* the run, and the run never waits on it again.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The most checks in flight at once. */
|
|
9
|
+
export const CHECK_CONCURRENCY = 12;
|
|
10
|
+
/** The longest one check may take, whatever its kind. */
|
|
11
|
+
export const CHECK_TIMEOUT_MS = 10_000;
|
|
12
|
+
|
|
13
|
+
/** What a check that ran out of time says. */
|
|
14
|
+
export function timedOutText(ms: number): string {
|
|
15
|
+
return `timed out after ${Math.round(ms / 1000)} s`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `p`, or `late()` once `ms` passes first. A late `p` that later settles
|
|
20
|
+
* is ignored, and so is its rejection.
|
|
21
|
+
*/
|
|
22
|
+
export function withTimeout<T>(
|
|
23
|
+
p: Promise<T>,
|
|
24
|
+
ms: number,
|
|
25
|
+
late: () => T,
|
|
26
|
+
): Promise<T> {
|
|
27
|
+
return new Promise<T>((resolve, reject) => {
|
|
28
|
+
let done = false;
|
|
29
|
+
const timer = setTimeout(() => {
|
|
30
|
+
if (done) return;
|
|
31
|
+
done = true;
|
|
32
|
+
resolve(late());
|
|
33
|
+
}, ms);
|
|
34
|
+
timer.unref?.();
|
|
35
|
+
p.then(
|
|
36
|
+
(v) => {
|
|
37
|
+
if (done) return;
|
|
38
|
+
done = true;
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
resolve(v);
|
|
41
|
+
},
|
|
42
|
+
(e: unknown) => {
|
|
43
|
+
if (done) return;
|
|
44
|
+
done = true;
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
reject(e);
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `fn` over `items`, at most `limit` at a time, the results in the items'
|
|
54
|
+
* order. Each starts as soon as a slot frees up.
|
|
55
|
+
*/
|
|
56
|
+
export async function runCapped<T, R>(
|
|
57
|
+
items: readonly T[],
|
|
58
|
+
limit: number,
|
|
59
|
+
fn: (item: T, index: number) => Promise<R>,
|
|
60
|
+
): Promise<R[]> {
|
|
61
|
+
const out = new Array<R>(items.length);
|
|
62
|
+
let next = 0;
|
|
63
|
+
const worker = async (): Promise<void> => {
|
|
64
|
+
while (next < items.length) {
|
|
65
|
+
const i = next++;
|
|
66
|
+
out[i] = await fn(items[i], i);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const n = Math.max(1, Math.min(limit, items.length));
|
|
70
|
+
await Promise.all(Array.from({ length: n }, worker));
|
|
71
|
+
return out;
|
|
72
|
+
}
|
package/src/ui/server/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
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,
|
|
@@ -326,6 +329,18 @@ function rotationWhy(rot) {
|
|
|
326
329
|
);
|
|
327
330
|
}
|
|
328
331
|
|
|
332
|
+
/** The Workspaces page, focused on workspace `ws` (0.4.17). */
|
|
333
|
+
function wsHref(ws) {
|
|
334
|
+
const nav = window.denShell?.nav?.() ?? [];
|
|
335
|
+
const base = nav.find((n) => n.id === "local-dev")?.href ?? "/local-dev";
|
|
336
|
+
return `${base}?ws=${encodeURIComponent(ws)}`;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Workspace `ws` as a link to it on Workspaces. */
|
|
340
|
+
function wsLinkHtml(ws) {
|
|
341
|
+
return `<a class="wslink" href="${esc(wsHref(ws))}" title="${esc(t("ag.goWs", { ws }))}">${esc(ws)}</a>`;
|
|
342
|
+
}
|
|
343
|
+
|
|
329
344
|
/** Its rotation, when one is due: "ready" or "due" (den#4). */
|
|
330
345
|
function rotationOf(r) {
|
|
331
346
|
if (!r.rotation?.due) return "";
|
|
@@ -480,7 +495,7 @@ function paintList() {
|
|
|
480
495
|
? groups
|
|
481
496
|
.map(
|
|
482
497
|
(g) =>
|
|
483
|
-
`<div class="wsg" data-ws="${esc(g.ws)}"><div class="wsh"><span>${
|
|
498
|
+
`<div class="wsg" data-ws="${esc(g.ws)}"><div class="wsh"><span>${wsLinkHtml(g.ws)}</span><span>${esc(g.rows.length)}</span></div>` +
|
|
484
499
|
g.rows.map(rowHtml).join("") +
|
|
485
500
|
`</div>`,
|
|
486
501
|
)
|
|
@@ -616,6 +631,11 @@ function followAgents() {
|
|
|
616
631
|
}
|
|
617
632
|
}
|
|
618
633
|
|
|
634
|
+
/** The pane's line for a resume that had nothing to continue (AG-14). */
|
|
635
|
+
function freshLine(p) {
|
|
636
|
+
p.term?.write(`\x1b[2m${t("ag.freshLine")}\x1b[0m\r\n`);
|
|
637
|
+
}
|
|
638
|
+
|
|
619
639
|
/** Move pane `p` to session row `row`, where it was. */
|
|
620
640
|
function retarget(p, row) {
|
|
621
641
|
const old = p.id;
|
|
@@ -639,6 +659,7 @@ function retarget(p, row) {
|
|
|
639
659
|
p.term?.write(
|
|
640
660
|
`\r\n\x1b[2m${t("ag.switched", { p: row.persona ?? row.agent })}\x1b[0m\r\n`,
|
|
641
661
|
);
|
|
662
|
+
if (st.fresh.delete(`${p.agentWs}\u0000${p.agent}`)) freshLine(p);
|
|
642
663
|
paintBar(p);
|
|
643
664
|
paintLayout();
|
|
644
665
|
writeUrl();
|
|
@@ -769,6 +790,7 @@ function paintLayout() {
|
|
|
769
790
|
if (n === 0) {
|
|
770
791
|
box.className = "agempty";
|
|
771
792
|
delete box.dataset.layout;
|
|
793
|
+
delete box.dataset.ws;
|
|
772
794
|
box.innerHTML = `<div>${esc(t("ag.empty"))}</div>`;
|
|
773
795
|
return;
|
|
774
796
|
}
|
|
@@ -776,6 +798,10 @@ function paintLayout() {
|
|
|
776
798
|
const shown = visibleIds();
|
|
777
799
|
box.className = `agp n${Math.max(1, shown.length)}${layout === "tabs" ? " tabs" : ""}`;
|
|
778
800
|
box.dataset.layout = layout;
|
|
801
|
+
// The front agent's workspace: ⌘K lists it first (0.4.17).
|
|
802
|
+
const front = rowOf(st.cur)?.workspace;
|
|
803
|
+
if (front) box.dataset.ws = front;
|
|
804
|
+
else delete box.dataset.ws;
|
|
779
805
|
const empty = box.querySelector(":scope > div:not(.pane):not(.agtabs)");
|
|
780
806
|
if (empty) empty.remove();
|
|
781
807
|
let strip = box.querySelector(":scope > .agtabs");
|
|
@@ -919,10 +945,14 @@ function paintBar(p) {
|
|
|
919
945
|
const r = rowOf(p.id);
|
|
920
946
|
const bar = p.el.querySelector(".bar2");
|
|
921
947
|
const name = r ? paneName(r) : p.label || p.id;
|
|
922
|
-
// AG-4b: the session's name last, when it says more.
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
948
|
+
// AG-4b: the session's name last, when it says more. The workspace is a
|
|
949
|
+
// link to it on Workspaces (0.4.17).
|
|
950
|
+
const ws = r ? r.workspace : p.workspace || "";
|
|
951
|
+
const rest = r
|
|
952
|
+
? [r.runtime, sessionExtra(r)].filter(Boolean).join(" · ")
|
|
953
|
+
: "";
|
|
954
|
+
const where =
|
|
955
|
+
(ws ? wsLinkHtml(ws) : "") + (ws && rest ? " · " : "") + esc(rest);
|
|
926
956
|
const tone = r ? stateTone(r) : p.exited ? "off" : "pending";
|
|
927
957
|
const stop =
|
|
928
958
|
r?.kind === "agent" && (r.state === "running" || r.state === "stopping")
|
|
@@ -943,7 +973,7 @@ function paintBar(p) {
|
|
|
943
973
|
: "";
|
|
944
974
|
bar.innerHTML =
|
|
945
975
|
`<span class="den-dot ${tone}" aria-hidden="true"></span>` +
|
|
946
|
-
`<b>${esc(name)}</b><span class="where mono">${
|
|
976
|
+
`<b>${esc(name)}</b><span class="where mono">${where}</span>` +
|
|
947
977
|
(r?.tokenRevoked
|
|
948
978
|
? `<span class="tagx warn" title="${esc(t("ag.revokedTip"))}">${esc(t("ag.revoked"))}</span>`
|
|
949
979
|
: "") +
|
|
@@ -1124,21 +1154,41 @@ async function connect(p) {
|
|
|
1124
1154
|
}
|
|
1125
1155
|
p.term?.write(new Uint8Array(e.data));
|
|
1126
1156
|
};
|
|
1127
|
-
sock.onclose = (e) => {
|
|
1157
|
+
sock.onclose = async (e) => {
|
|
1128
1158
|
if (p.sock !== sock) return;
|
|
1129
1159
|
p.sock = null;
|
|
1130
1160
|
if (p.closed) return;
|
|
1131
|
-
// Refused before it opened:
|
|
1132
|
-
//
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1161
|
+
// Refused before it opened: a browser sees only the close, so ask the
|
|
1162
|
+
// route why with a plain GET (0.4.17). "Sign in" only when it's 401.
|
|
1163
|
+
let why = e.reason || String(e.code);
|
|
1164
|
+
if (!opened) {
|
|
1165
|
+
void refresh();
|
|
1166
|
+
why = await refusalReason(p.id);
|
|
1167
|
+
if (p.closed || p.sock) return;
|
|
1168
|
+
}
|
|
1136
1169
|
p.term?.write(`\r\n${t("ag.lost", { why })}\r\n`);
|
|
1137
1170
|
p.lost = true;
|
|
1138
1171
|
paintBar(p);
|
|
1139
1172
|
};
|
|
1140
1173
|
}
|
|
1141
1174
|
|
|
1175
|
+
/** Why session `id`'s io upgrade was refused, in words for its pane. */
|
|
1176
|
+
async function refusalReason(id) {
|
|
1177
|
+
try {
|
|
1178
|
+
const r = await fetch(`/api/sessions/${encodeURIComponent(id)}/io`, {
|
|
1179
|
+
headers: { accept: "application/json" },
|
|
1180
|
+
credentials: "same-origin",
|
|
1181
|
+
});
|
|
1182
|
+
if (r.status === 401) return t("ag.signin", { cli: CLI });
|
|
1183
|
+
const b = await r.json().catch(() => null);
|
|
1184
|
+
if (r.status === 426) return t("ag.refusedUnknown");
|
|
1185
|
+
if (typeof b?.error === "string" && b.error) return b.error;
|
|
1186
|
+
return t("ag.refusedStatus", { status: r.status });
|
|
1187
|
+
} catch {
|
|
1188
|
+
return t("ag.refusedUnknown");
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1142
1192
|
// ── links and keys in a pane (AG-4b) ───────────────────────────────────
|
|
1143
1193
|
|
|
1144
1194
|
/** What Shift+Enter sends to a pane's session (a plain session: Enter). */
|
|
@@ -2306,11 +2356,20 @@ async function submitPersonaChange(when) {
|
|
|
2306
2356
|
body,
|
|
2307
2357
|
);
|
|
2308
2358
|
window.denShell?.toast?.(
|
|
2309
|
-
t(
|
|
2310
|
-
|
|
2311
|
-
p: personaName(p),
|
|
2312
|
-
|
|
2359
|
+
t(
|
|
2360
|
+
r?.pending ? "ag.pc.pending" : r?.fresh ? "ag.pc.fresh" : "ag.pc.done",
|
|
2361
|
+
{ id: ch.name, p: personaName(p) },
|
|
2362
|
+
),
|
|
2313
2363
|
);
|
|
2364
|
+
// Never prompted: nothing was resumed. Its pane says so, now if it
|
|
2365
|
+
// has already moved to the new session, else when it does.
|
|
2366
|
+
if (r?.fresh) {
|
|
2367
|
+
const pane = [...st.panes.values()].find(
|
|
2368
|
+
(q) => q.agent === ch.id && q.agentWs === ch.workspace,
|
|
2369
|
+
);
|
|
2370
|
+
if (pane && pane.id === r.agent?.sessionId) freshLine(pane);
|
|
2371
|
+
else st.fresh.add(`${ch.workspace}\u0000${ch.id}`);
|
|
2372
|
+
}
|
|
2314
2373
|
for (const w of r?.warnings ?? []) window.denShell?.toast?.(w);
|
|
2315
2374
|
dlg.close?.();
|
|
2316
2375
|
await refresh();
|