@stratusagent/dashboard 0.10.0 → 0.11.1
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/README.md +15 -5
- package/package.json +1 -1
- package/ui/app.js +30 -8
- package/ui/lib/latest.d.ts +10 -0
- package/ui/lib/latest.js +27 -0
- package/ui/views/dashboard.js +15 -2
package/README.md
CHANGED
|
@@ -74,8 +74,18 @@ Slack channel tokens.
|
|
|
74
74
|
|
|
75
75
|
## When the daemon restarts
|
|
76
76
|
|
|
77
|
-
Sessions live in the daemon's memory
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
Sessions live in the daemon's memory. An announced restart (`stratus
|
|
78
|
+
restart`) hands them to the replacement, so the page reconnects and stays
|
|
79
|
+
signed in; a crash or a plain stop-and-start signs the browser out, and the
|
|
80
|
+
page says so rather than rendering an empty roster that looks like a fleet
|
|
81
|
+
that vanished. Run `stratus dashboard` again. If the socket drops without
|
|
82
|
+
the daemon dying, the page reconnects on its own with a backoff and re-reads
|
|
83
|
+
everything it missed — and it re-reads the moment the socket drops too, so
|
|
84
|
+
an outage shows as the banner rather than as last-known numbers under a
|
|
85
|
+
"reconnecting" dot.
|
|
86
|
+
|
|
87
|
+
Refreshes that overlap settle latest-wins: one already in flight when a
|
|
88
|
+
newer one starts is discarded when it lands, so a burst of events cannot
|
|
89
|
+
leave the page holding an older answer. That is how an approval request
|
|
90
|
+
went missing once — the refresh a `session.created` started was sent before
|
|
91
|
+
the call was parked, and landed after the approval event's own refresh.
|
package/package.json
CHANGED
package/ui/app.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { api, connectEvents, ApiError } from './lib/api.js';
|
|
2
2
|
import { clear, el } from './lib/dom.js';
|
|
3
3
|
import { avatar } from './lib/avatar.js';
|
|
4
|
+
import { createLatest } from './lib/latest.js';
|
|
4
5
|
import { renderDashboard } from './views/dashboard.js';
|
|
5
6
|
import { renderAgent } from './views/agent.js';
|
|
6
7
|
import { renderSettings } from './views/settings.js';
|
|
@@ -87,22 +88,35 @@ const routeFromHash = () => {
|
|
|
87
88
|
/**
|
|
88
89
|
* Refresh the shared data every screen leans on.
|
|
89
90
|
*
|
|
90
|
-
* A 401 here means the
|
|
91
|
-
*
|
|
92
|
-
*
|
|
91
|
+
* A 401 here means the session is gone — the daemon crashed or was stopped
|
|
92
|
+
* and started again (an announced `stratus restart` hands its cookie
|
|
93
|
+
* sessions to the replacement; nothing else does, because they live in
|
|
94
|
+
* memory) — so the page says exactly that instead of rendering an empty
|
|
95
|
+
* roster that looks like a fleet that vanished.
|
|
93
96
|
*/
|
|
97
|
+
// Overlapping refreshes settle latest-wins — see lib/latest.js for the
|
|
98
|
+
// approval this lost.
|
|
99
|
+
const coreRefresh = createLatest();
|
|
100
|
+
|
|
94
101
|
export const refreshCore = async () => {
|
|
102
|
+
const ticket = coreRefresh.begin();
|
|
95
103
|
try {
|
|
96
104
|
const [health, agents, approvals] = await Promise.all([
|
|
97
105
|
api.health(),
|
|
98
106
|
api.agents(),
|
|
99
107
|
api.approvals(),
|
|
100
108
|
]);
|
|
109
|
+
if (!coreRefresh.isCurrent(ticket)) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
101
112
|
store.health = health;
|
|
102
113
|
store.agents = agents.agents ?? [];
|
|
103
114
|
store.approvals = approvals.approvals ?? [];
|
|
104
115
|
store.offlineSince = undefined;
|
|
105
116
|
} catch (error) {
|
|
117
|
+
if (!coreRefresh.isCurrent(ticket)) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
106
120
|
if (error instanceof ApiError && error.status === 401) {
|
|
107
121
|
store.route = { name: 'signed-out' };
|
|
108
122
|
} else {
|
|
@@ -199,12 +213,12 @@ const handleEnvelope = (envelope) => {
|
|
|
199
213
|
|
|
200
214
|
// Approvals and roster activity are shared state, so they refresh for
|
|
201
215
|
// everyone rather than only for whoever is looking at the right screen.
|
|
216
|
+
// The whole core, not the approvals list alone: the "awaiting approval"
|
|
217
|
+
// count comes from /health, and a list refreshed on its own left the
|
|
218
|
+
// counter at zero next to a card that said one was waiting.
|
|
202
219
|
const type = envelope.event.type;
|
|
203
220
|
if (type === 'tool.approval-requested' || type === 'tool.approval-resolved') {
|
|
204
|
-
void
|
|
205
|
-
store.approvals = listed.approvals ?? [];
|
|
206
|
-
render();
|
|
207
|
-
}).catch(() => {});
|
|
221
|
+
void refreshCore();
|
|
208
222
|
}
|
|
209
223
|
if (type === 'session.completed' || type === 'session.failed' || type === 'session.created') {
|
|
210
224
|
void refreshCore();
|
|
@@ -280,7 +294,7 @@ const sidebar = () => {
|
|
|
280
294
|
const signedOut = () => el('div', { class: 'main solo' },
|
|
281
295
|
el('div', { class: 'card' },
|
|
282
296
|
el('h2', {}, 'Not signed in'),
|
|
283
|
-
el('p', {}, 'This browser has no session with stratusd. Sessions live in the daemon’s memory,
|
|
297
|
+
el('p', {}, 'This browser has no session with stratusd. Sessions live in the daemon’s memory: stratus restart hands them to the new daemon, but a crash or a stop and start signs you out.'),
|
|
284
298
|
el('p', { class: 'field-note' }, 'Run stratus dashboard in a terminal to open a fresh one.'),
|
|
285
299
|
),
|
|
286
300
|
);
|
|
@@ -352,6 +366,14 @@ connectEvents({
|
|
|
352
366
|
onEnvelope: handleEnvelope,
|
|
353
367
|
onStatus: (status) => {
|
|
354
368
|
store.stream = status;
|
|
369
|
+
if (status === 'offline') {
|
|
370
|
+
// Ask now rather than wait for an event that will not come: with the
|
|
371
|
+
// daemon gone there are no events, so nothing else would notice, and
|
|
372
|
+
// the page kept its last numbers under a "reconnecting" dot through a
|
|
373
|
+
// whole outage. A failed refresh raises the offline banner; a 401
|
|
374
|
+
// after a restart signs the page out.
|
|
375
|
+
void refreshCore();
|
|
376
|
+
}
|
|
355
377
|
if (status === 'live') {
|
|
356
378
|
// Reconnected: whatever happened while the socket was down is only in
|
|
357
379
|
// the store, so re-read rather than leaving a stale page behind.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Types for the one UI module a test imports. The UI itself is plain ES
|
|
2
|
+
// modules with no build step; this declaration is what lets `pnpm typecheck`
|
|
3
|
+
// cover the test without pretending the module is TypeScript.
|
|
4
|
+
export interface Latest {
|
|
5
|
+
/** Issue a ticket for a refresh that is starting now. */
|
|
6
|
+
begin(): number;
|
|
7
|
+
/** Whether no newer ticket has been issued since this one. */
|
|
8
|
+
isCurrent(ticket: number): boolean;
|
|
9
|
+
}
|
|
10
|
+
export const createLatest: () => Latest;
|
package/ui/lib/latest.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Latest-wins for refreshes that overlap.
|
|
3
|
+
*
|
|
4
|
+
* Two refreshes of the same state in flight at once settle in whatever
|
|
5
|
+
* order the network chooses, and the one that started earlier can land
|
|
6
|
+
* last — carrying what was true before the later one was even sent. The
|
|
7
|
+
* dashboard lost an approval this way: `session.created` started a full
|
|
8
|
+
* refresh, `tool.approval-requested` arrived a moment later and fetched the
|
|
9
|
+
* approvals list (one entry), and then the first refresh's fetch — sent
|
|
10
|
+
* before the call was parked — landed with an empty list and overwrote it.
|
|
11
|
+
* The card never rendered and the counter said zero until someone clicked
|
|
12
|
+
* Refresh.
|
|
13
|
+
*
|
|
14
|
+
* `begin()` hands out a ticket; `isCurrent(ticket)` is false once a newer
|
|
15
|
+
* one has been issued, and a refresh whose ticket is stale discards what it
|
|
16
|
+
* fetched instead of writing it.
|
|
17
|
+
*/
|
|
18
|
+
export const createLatest = () => {
|
|
19
|
+
let issued = 0;
|
|
20
|
+
return {
|
|
21
|
+
begin: () => {
|
|
22
|
+
issued += 1;
|
|
23
|
+
return issued;
|
|
24
|
+
},
|
|
25
|
+
isCurrent: (ticket) => ticket === issued,
|
|
26
|
+
};
|
|
27
|
+
};
|
package/ui/views/dashboard.js
CHANGED
|
@@ -50,7 +50,11 @@ const approvalCard = () => {
|
|
|
50
50
|
el('div', { class: 'row' },
|
|
51
51
|
agent ? avatar(agent, 24) : null,
|
|
52
52
|
el('div', { class: 'grow' },
|
|
53
|
-
|
|
53
|
+
// The site, for a call judged by one. The input below is a CSS
|
|
54
|
+
// selector for `browser.act` and says nothing about where the
|
|
55
|
+
// click lands — which is exactly what **Always allow** widens.
|
|
56
|
+
el('div', { class: 'title' }, `${agent?.name ?? request.agentId} wants to run ${request.call.toolName}`
|
|
57
|
+
+ `${request.origin ? ` on ${request.origin}` : ''}`),
|
|
54
58
|
el('div', { class: 'sub' },
|
|
55
59
|
request.expiresAt ? `expires ${until(request.expiresAt)}` : 'no expiry',
|
|
56
60
|
` · parked ${ago(request.parkedAt)}`),
|
|
@@ -58,9 +62,18 @@ const approvalCard = () => {
|
|
|
58
62
|
el('span', { class: `pill risk-${request.risk}` }, request.risk),
|
|
59
63
|
),
|
|
60
64
|
el('div', { class: 'cmd' }, JSON.stringify(request.call.input)),
|
|
65
|
+
// No **Always allow** where the engine would not remember the
|
|
66
|
+
// answer — a `dangerous` tool, or a browser action with no page to
|
|
67
|
+
// grant. There it does exactly what **Allow once** does, under a
|
|
68
|
+
// label promising a standing grant nobody gets.
|
|
69
|
+
request.oneShot
|
|
70
|
+
? el('div', { class: 'sub' }, 'An approval covers this call only — nothing about it is remembered.')
|
|
71
|
+
: null,
|
|
61
72
|
el('div', { class: 'actions' },
|
|
62
73
|
el('button', { class: 'primary small', onClick: () => decide(request.requestId, 'once') }, 'Allow once'),
|
|
63
|
-
|
|
74
|
+
request.oneShot
|
|
75
|
+
? null
|
|
76
|
+
: el('button', { class: 'small', onClick: () => decide(request.requestId, 'always') }, 'Always allow'),
|
|
64
77
|
el('button', { class: 'small danger', onClick: () => decide(request.requestId, 'deny') }, 'Deny'),
|
|
65
78
|
),
|
|
66
79
|
);
|