@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 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, so restarting it signs the browser out
78
- and the page says so rather than rendering an empty roster that looks like a
79
- fleet that vanished. Run `stratus dashboard` again. If the socket drops
80
- without the daemon dying, the page reconnects on its own with a backoff and
81
- re-reads everything it missed.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stratusagent/dashboard",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
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 daemon restarted — its cookie sessions live in memory —
91
- * so the page says exactly that instead of rendering an empty roster that
92
- * looks like a fleet that vanished.
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 api.approvals().then((listed) => {
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, so restarting it signs you out.'),
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;
@@ -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
+ };
@@ -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
- el('div', { class: 'title' }, `${agent?.name ?? request.agentId} wants to run ${request.call.toolName}`),
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
- el('button', { class: 'small', onClick: () => decide(request.requestId, 'always') }, 'Always allow'),
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
  );