@aiwg/cockpit 2026.7.11 → 2026.7.13
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 +80 -12
- package/bridge/src/server.mjs +301 -21
- package/bridge/src/smoke.mjs +3 -3
- package/package.json +2 -2
- package/runtime-docs/README.md +3 -2
- package/shell-core/keychain.mjs +35 -5
- package/vscode/extension.js +13 -26
- package/vscode/smoke.mjs +47 -0
- package/web/src/App.test.tsx +119 -1
- package/web/src/App.tsx +58 -27
- package/web/src/components/Actions.tsx +1 -1
- package/web/src/components/Inventory.tsx +61 -14
- package/web/src/components/Library.tsx +1 -1
- package/web/src/components/Sessions.test.tsx +248 -10
- package/web/src/components/Sessions.tsx +236 -65
- package/web/src/components/StartSessionModal.test.tsx +1 -1
- package/web/src/components/StartSessionModal.tsx +7 -1
- package/web/src/components/Welcome.tsx +11 -10
- package/web/src/sessionMonitor.test.tsx +85 -0
- package/web/src/sessionMonitor.ts +72 -0
- package/web/src/sessionRegistry.test.ts +112 -0
- package/web/src/sessionRegistry.ts +192 -0
- package/web/src/styles.css +20 -2
- package/web/src/types.ts +23 -1
- package/web/src/useSession.test.tsx +207 -7
- package/web/src/useSession.ts +353 -193
- package/web/src/util.ts +8 -1
|
@@ -4,17 +4,34 @@ import { fmtId, capRef } from '../util';
|
|
|
4
4
|
import { CapabilitySearch } from './CapabilitySearch';
|
|
5
5
|
import type { Instance, SessionInfo, CapabilityResult } from '../types';
|
|
6
6
|
import type { SessionApi } from '../useSession';
|
|
7
|
+
import { markRegistrySessionViewed, sessionRegistryKeyFor, setRegistryActiveSession, upsertRegistrySessions, useSessionRegistry } from '../sessionRegistry';
|
|
8
|
+
import { useSessionSnapshotMonitor } from '../sessionMonitor';
|
|
7
9
|
|
|
8
10
|
export function Sessions({ session, composer, setComposer, onRequestStart, refreshMs = 5_000 }: { session: SessionApi; composer: string; setComposer: (v: string) => void; onRequestStart: (instanceId?: string) => void; refreshMs?: number }) {
|
|
9
11
|
const [instances, setInstances] = useState<Instance[]>([]);
|
|
10
12
|
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
|
11
13
|
const [instId, setInstId] = useState('');
|
|
12
|
-
const [
|
|
14
|
+
const [selectedSessionKey, setSelectedSessionKey] = useState('');
|
|
13
15
|
const [backendKey, setBackendKey] = useState('');
|
|
14
16
|
const [showPicker, setShowPicker] = useState(false);
|
|
15
17
|
const [endingSession, setEndingSession] = useState('');
|
|
18
|
+
const [reconnectingInstance, setReconnectingInstance] = useState('');
|
|
16
19
|
const [sessionErr, setSessionErr] = useState('');
|
|
20
|
+
const [attachedInstanceId, setAttachedInstanceId] = useState('');
|
|
21
|
+
const [attachedSessionId, setAttachedSessionId] = useState('');
|
|
22
|
+
const instIdRef = useRef('');
|
|
23
|
+
const attachedRef = useRef(false);
|
|
24
|
+
const attachedOwnerRef = useRef('');
|
|
25
|
+
const inventorySeqRef = useRef(0);
|
|
26
|
+
const sessionsSeqRef = useRef(0);
|
|
27
|
+
const missingAttachedPollsRef = useRef(0);
|
|
17
28
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
29
|
+
const sessionRegistry = useSessionRegistry();
|
|
30
|
+
|
|
31
|
+
useSessionSnapshotMonitor();
|
|
32
|
+
|
|
33
|
+
useEffect(() => { instIdRef.current = instId; }, [instId]);
|
|
34
|
+
useEffect(() => { attachedRef.current = session.state.attached; }, [session.state.attached]);
|
|
18
35
|
|
|
19
36
|
const insertCap = (r: CapabilityResult) => {
|
|
20
37
|
const sep = composer && !composer.endsWith(' ') ? ' ' : '';
|
|
@@ -23,66 +40,152 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
|
|
|
23
40
|
inputRef.current?.focus();
|
|
24
41
|
};
|
|
25
42
|
|
|
26
|
-
const refreshInventory = useCallback(() => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
.
|
|
43
|
+
const refreshInventory = useCallback(async () => {
|
|
44
|
+
const seq = inventorySeqRef.current + 1;
|
|
45
|
+
inventorySeqRef.current = seq;
|
|
46
|
+
const d = await api<{ instances: Instance[] }>('/api/inventory');
|
|
47
|
+
if (seq !== inventorySeqRef.current) return;
|
|
48
|
+
const sessionable = dedupeInstances(d.instances).filter((i) => i.state === 'running' && i.session_backends?.length);
|
|
49
|
+
setInstances(sessionable);
|
|
50
|
+
const currentId = instIdRef.current;
|
|
51
|
+
let nextId = sessionable[0]?.id ?? '';
|
|
52
|
+
if (currentId && sessionable.some((i) => i.id === currentId)) nextId = currentId;
|
|
53
|
+
else if (attachedRef.current && currentId) nextId = currentId;
|
|
54
|
+
instIdRef.current = nextId;
|
|
55
|
+
setInstId(nextId);
|
|
56
|
+
setBackendKey((currentBackend) => {
|
|
57
|
+
const selectedInstance = sessionable.find((i) => i.id === nextId) ?? sessionable[0];
|
|
58
|
+
if (currentBackend && selectedInstance?.session_backends.some((b) => `${b.mode}:${b.backend}` === currentBackend && b.available !== false)) return currentBackend;
|
|
59
|
+
if (attachedRef.current && currentBackend) return currentBackend;
|
|
60
|
+
const firstBackend = selectedInstance?.session_backends.find((b) => b.available !== false) ?? selectedInstance?.session_backends[0];
|
|
61
|
+
return firstBackend ? `${firstBackend.mode}:${firstBackend.backend}` : '';
|
|
62
|
+
});
|
|
42
63
|
}, []);
|
|
43
64
|
|
|
44
65
|
useEffect(() => {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return () => window.clearInterval(timer);
|
|
48
|
-
}, [refreshInventory, refreshMs]);
|
|
66
|
+
attachedOwnerRef.current = attachedInstanceId || instanceIdFromAttachUrl(session.state.url);
|
|
67
|
+
}, [attachedInstanceId, session.state.url]);
|
|
49
68
|
|
|
50
|
-
const loadSessions = useCallback((id: string) => {
|
|
69
|
+
const loadSessions = useCallback(async (id: string) => {
|
|
51
70
|
if (!id) return;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
.
|
|
71
|
+
const seq = sessionsSeqRef.current + 1;
|
|
72
|
+
sessionsSeqRef.current = seq;
|
|
73
|
+
const d = await api<{ sessions: SessionInfo[] }>(`/api/sessions?instance=${encodeURIComponent(id)}`);
|
|
74
|
+
if (seq !== sessionsSeqRef.current || id !== instIdRef.current) return;
|
|
75
|
+
const nextSessions = d.sessions ?? [];
|
|
76
|
+
upsertRegistrySessions(nextSessions);
|
|
77
|
+
setSessions(nextSessions);
|
|
78
|
+
setSessionErr('');
|
|
79
|
+
setSelectedSessionKey((currentKey) => {
|
|
80
|
+
if (currentKey && nextSessions.some((s) => sessionKey(s) === currentKey)) return currentKey;
|
|
81
|
+
if (attachedRef.current && attachedOwnerRef.current === id && currentKey) return currentKey;
|
|
82
|
+
return nextSessions[0] ? sessionKey(nextSessions[0]) : '';
|
|
83
|
+
});
|
|
63
84
|
}, []);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
let cancelled = false;
|
|
88
|
+
let timer: number | undefined;
|
|
89
|
+
let delay = refreshMs;
|
|
90
|
+
const schedule = (ms: number) => {
|
|
91
|
+
timer = window.setTimeout(tick, ms);
|
|
92
|
+
};
|
|
93
|
+
const tick = async () => {
|
|
94
|
+
if (cancelled) return;
|
|
95
|
+
if (endingSession) {
|
|
96
|
+
schedule(refreshMs);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const idBeforeInventory = instIdRef.current;
|
|
101
|
+
await refreshInventory();
|
|
102
|
+
if (idBeforeInventory && idBeforeInventory === instIdRef.current) await loadSessions(instIdRef.current);
|
|
103
|
+
delay = refreshMs;
|
|
104
|
+
} catch (e) {
|
|
105
|
+
if (!cancelled) setSessionErr((e as Error).message);
|
|
106
|
+
delay = Math.min(Math.max(refreshMs, delay * 2), 30_000);
|
|
107
|
+
}
|
|
108
|
+
if (!cancelled) schedule(delay);
|
|
109
|
+
};
|
|
110
|
+
tick();
|
|
111
|
+
return () => {
|
|
112
|
+
cancelled = true;
|
|
113
|
+
if (timer !== undefined) window.clearTimeout(timer);
|
|
114
|
+
};
|
|
115
|
+
}, [endingSession, loadSessions, refreshInventory, refreshMs]);
|
|
116
|
+
|
|
67
117
|
useEffect(() => {
|
|
68
118
|
if (!instId) return;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
119
|
+
// Clear the previous instance's rows immediately so a slow load never shows
|
|
120
|
+
// the wrong instance's sessions in the nav during the switch.
|
|
121
|
+
setSessions([]);
|
|
122
|
+
setSessionErr('');
|
|
123
|
+
loadSessions(instId).catch((e) => setSessionErr((e as Error).message));
|
|
124
|
+
}, [instId, loadSessions]);
|
|
73
125
|
|
|
74
|
-
const send = () => { if (session.sendInput(composer)) setComposer(''); };
|
|
75
126
|
const attached = session.state.attached;
|
|
76
127
|
const requestedReplayRole = session.state.role === 'controller' ? 'controller' : 'observer';
|
|
77
128
|
const current = instances.find((i) => i.id === instId);
|
|
78
129
|
const backends = current?.session_backends ?? [];
|
|
79
|
-
const selectedBackend = backends.find((b) => `${b.mode}:${b.backend}` === backendKey) ?? backends.find((b) => b.available) ?? backends[0];
|
|
80
|
-
const
|
|
130
|
+
const selectedBackend = backends.find((b) => `${b.mode}:${b.backend}` === backendKey) ?? backends.find((b) => b.available !== false) ?? backends[0];
|
|
131
|
+
const currentUnavailableReason = backends.find((b) => b.available === false)?.reason;
|
|
132
|
+
const currentReconnectable = current ? isReconnectable(current) : false;
|
|
133
|
+
const selectedSession = sessions.find((s) => sessionKey(s) === selectedSessionKey);
|
|
134
|
+
const attachedOwner = attachedInstanceId || instanceIdFromAttachUrl(session.state.url);
|
|
135
|
+
const attachedKey = attachedOwner && attachedSessionId ? `${attachedOwner}:${attachedSessionId}` : sessionKeyFromAttachUrl(session.state.url);
|
|
136
|
+
const activeTarget = session.state.target ?? (attachedOwner && attachedSessionId ? { instanceId: attachedOwner, sessionId: attachedSessionId } : null);
|
|
137
|
+
// Merge the currently-attached session into the nav even when the executor's
|
|
138
|
+
// session-list API omits it. Host-runtime PTY sessions are not returned by
|
|
139
|
+
// list_sessions (agentic-sandbox #500 follow-up), so a live, attached session
|
|
140
|
+
// would otherwise render as "No sessions yet". The synthetic row reuses the
|
|
141
|
+
// attach URL Cockpit already holds so selecting it re-attaches/replays.
|
|
142
|
+
const displaySessions: SessionInfo[] = (attached && attachedOwner === instId && attachedSessionId
|
|
143
|
+
&& !sessions.some((s) => sessionKey(s) === attachedKey))
|
|
144
|
+
? [...sessions, {
|
|
145
|
+
id: attachedSessionId,
|
|
146
|
+
instance_id: instId,
|
|
147
|
+
attach_url: session.state.url ?? '',
|
|
148
|
+
session_name: 'attached session',
|
|
149
|
+
session_backend: selectedBackend?.backend,
|
|
150
|
+
session_class: selectedBackend?.mode,
|
|
151
|
+
}]
|
|
152
|
+
: sessions;
|
|
153
|
+
const send = () => { if (session.sendInput(composer, activeTarget)) setComposer(''); };
|
|
154
|
+
const attachToSession = (s: SessionInfo, role: 'controller' | 'observer') => {
|
|
155
|
+
setAttachedInstanceId(s.instance_id || instId);
|
|
156
|
+
setAttachedSessionId(String(s.id));
|
|
157
|
+
setRegistryActiveSession(s.instance_id || instId, String(s.id));
|
|
158
|
+
session.attach(s.attach_url, false, role, { instanceId: s.instance_id || instId, sessionId: String(s.id) });
|
|
159
|
+
};
|
|
160
|
+
const replaySession = (s: SessionInfo, role: 'controller' | 'observer') => {
|
|
161
|
+
setAttachedInstanceId(s.instance_id || instId);
|
|
162
|
+
setAttachedSessionId(String(s.id));
|
|
163
|
+
setRegistryActiveSession(s.instance_id || instId, String(s.id));
|
|
164
|
+
session.replay(s.attach_url, role, { instanceId: s.instance_id || instId, sessionId: String(s.id) });
|
|
165
|
+
};
|
|
166
|
+
const detachSession = () => {
|
|
167
|
+
setAttachedInstanceId('');
|
|
168
|
+
setAttachedSessionId('');
|
|
169
|
+
setRegistryActiveSession(null, null);
|
|
170
|
+
session.detach();
|
|
171
|
+
};
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
const selected = sessions.find((s) => sessionKey(s) === selectedSessionKey);
|
|
174
|
+
if (selected) markRegistrySessionViewed(selected.instance_id, String(selected.id));
|
|
175
|
+
}, [selectedSessionKey, sessions]);
|
|
81
176
|
useEffect(() => {
|
|
82
177
|
if (!session.state.url) return;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
178
|
+
if (!attachedOwner || attachedOwner !== instId) return;
|
|
179
|
+
const sessionStillListed = sessions.some((s) => sessionKey(s) === attachedKey);
|
|
180
|
+
if (sessionStillListed) {
|
|
181
|
+
missingAttachedPollsRef.current = 0;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (sessions.length) {
|
|
185
|
+
missingAttachedPollsRef.current += 1;
|
|
186
|
+
if (missingAttachedPollsRef.current >= 2) detachSession();
|
|
187
|
+
}
|
|
188
|
+
}, [attachedKey, attachedOwner, instId, session.state.url, sessions]);
|
|
86
189
|
useEffect(() => {
|
|
87
190
|
if (!current) return;
|
|
88
191
|
const valid = current.session_backends.some((b) => `${b.mode}:${b.backend}` === backendKey);
|
|
@@ -103,7 +206,7 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
|
|
|
103
206
|
setSessionErr('');
|
|
104
207
|
try {
|
|
105
208
|
await api(`/api/instances/${encodeURIComponent(current.id)}/sessions/${encodeURIComponent(s.id)}`, { method: 'DELETE' });
|
|
106
|
-
if (
|
|
209
|
+
if (sessionKey(s) === attachedKey) detachSession();
|
|
107
210
|
await loadSessions(current.id);
|
|
108
211
|
} catch (e) {
|
|
109
212
|
setSessionErr((e as Error).message);
|
|
@@ -111,6 +214,20 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
|
|
|
111
214
|
setEndingSession('');
|
|
112
215
|
}
|
|
113
216
|
};
|
|
217
|
+
const reconnectCurrent = async () => {
|
|
218
|
+
if (!current) return;
|
|
219
|
+
setReconnectingInstance(current.id);
|
|
220
|
+
setSessionErr('');
|
|
221
|
+
try {
|
|
222
|
+
await api(`/api/instances/${encodeURIComponent(current.id)}/reconnect`, { method: 'POST' });
|
|
223
|
+
await refreshInventory();
|
|
224
|
+
await loadSessions(current.id);
|
|
225
|
+
} catch (e) {
|
|
226
|
+
setSessionErr((e as Error).message);
|
|
227
|
+
} finally {
|
|
228
|
+
setReconnectingInstance('');
|
|
229
|
+
}
|
|
230
|
+
};
|
|
114
231
|
|
|
115
232
|
return (
|
|
116
233
|
<>
|
|
@@ -125,7 +242,7 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
|
|
|
125
242
|
<h2>Instances</h2>
|
|
126
243
|
<span className="hint">{instances.length}</span>
|
|
127
244
|
</div>
|
|
128
|
-
{!instances.length && <p className="empty">No
|
|
245
|
+
{!instances.length && <p className="empty">No running instances with session metadata.</p>}
|
|
129
246
|
<ul className="nav-list">
|
|
130
247
|
{instances.map((i) => {
|
|
131
248
|
const isSel = i.id === instId;
|
|
@@ -139,11 +256,17 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
|
|
|
139
256
|
</button>
|
|
140
257
|
{isSel && (
|
|
141
258
|
<div className="nav-sessions">
|
|
142
|
-
{
|
|
259
|
+
{displaySessions.length === 0 && (
|
|
260
|
+
<p className="empty nav-empty">
|
|
261
|
+
{isReconnectable(i) ? 'Agent unreachable; reconnect to recover existing sessions.' : 'No sessions yet.'}
|
|
262
|
+
</p>
|
|
263
|
+
)}
|
|
143
264
|
<ul>
|
|
144
|
-
{
|
|
145
|
-
const
|
|
146
|
-
const
|
|
265
|
+
{displaySessions.map((s) => {
|
|
266
|
+
const key = sessionKey(s);
|
|
267
|
+
const selS = key === selectedSessionKey;
|
|
268
|
+
const live = key === attachedKey && attached;
|
|
269
|
+
const registryEntry = sessionRegistry.entries[sessionRegistryKeyFor(s)];
|
|
147
270
|
return (
|
|
148
271
|
<li key={s.id}>
|
|
149
272
|
<button
|
|
@@ -154,11 +277,11 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
|
|
|
154
277
|
// Docker/tmux streams repaint and control is reasserted.
|
|
155
278
|
onClick={() => {
|
|
156
279
|
const role = session.state.role === 'controller' && selectedBackend?.drive !== false ? 'controller' : 'observer';
|
|
157
|
-
|
|
158
|
-
if (
|
|
159
|
-
|
|
280
|
+
setSelectedSessionKey(key);
|
|
281
|
+
if (key === attachedKey && attached) {
|
|
282
|
+
replaySession(s, role);
|
|
160
283
|
} else {
|
|
161
|
-
|
|
284
|
+
attachToSession(s, role);
|
|
162
285
|
}
|
|
163
286
|
}}
|
|
164
287
|
title={s.id}
|
|
@@ -166,6 +289,8 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
|
|
|
166
289
|
<span className="nav-session-name">{sessionLabel(s)}</span>
|
|
167
290
|
<span className="nav-session-meta">{sessionMeta(s)}</span>
|
|
168
291
|
{sessionHoldsController(s) && <span className="badge controller" title="A controller is connected">ctrl</span>}
|
|
292
|
+
{registryEntry?.unread && <span className="badge unread" title="Unread output">unread</span>}
|
|
293
|
+
{registryEntry?.responseNeeded.needed && <span className="badge response" title="Response needed">response</span>}
|
|
169
294
|
{live && <span className="badge live-dot" title="Attached here">●</span>}
|
|
170
295
|
</button>
|
|
171
296
|
<button
|
|
@@ -181,7 +306,7 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
|
|
|
181
306
|
);
|
|
182
307
|
})}
|
|
183
308
|
</ul>
|
|
184
|
-
<button className="cta-sm nav-new-session" disabled={backends.length > 0 &&
|
|
309
|
+
<button className="cta-sm nav-new-session" disabled={backends.length > 0 && selectedBackend?.available === false} onClick={() => onRequestStart(i.id)}>+ New session</button>
|
|
185
310
|
</div>
|
|
186
311
|
)}
|
|
187
312
|
</li>
|
|
@@ -201,13 +326,18 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
|
|
|
201
326
|
</>
|
|
202
327
|
)}
|
|
203
328
|
<span className="controls-active" title={selectedSession?.id}>{selectedSession ? sessionLabel(selectedSession) : '— no session selected —'}</span>
|
|
204
|
-
<button disabled={!
|
|
205
|
-
<button disabled={!
|
|
329
|
+
<button disabled={!selectedSession || (attached && session.state.role === 'observer')} onClick={() => selectedSession && attachToSession(selectedSession, 'observer')}>Observe</button>
|
|
330
|
+
<button disabled={!selectedSession || selectedBackend?.drive === false || (attached && session.state.role === 'controller')} onClick={() => selectedSession && attachToSession(selectedSession, 'controller')}>
|
|
206
331
|
{attached && session.state.role === 'observer' ? 'Take Control' : 'Drive'}
|
|
207
332
|
</button>
|
|
208
333
|
<button disabled={!attached || selectedBackend?.keyframe === false} onClick={session.requestKeyframe}>Keyframe</button>
|
|
209
|
-
<button disabled={!attached} onClick={() =>
|
|
210
|
-
<button disabled={!attached} onClick={
|
|
334
|
+
<button disabled={!attached} onClick={() => selectedSession && replaySession(selectedSession, requestedReplayRole)}>Reattach + replay</button>
|
|
335
|
+
<button disabled={!attached} onClick={detachSession}>Detach</button>
|
|
336
|
+
{currentReconnectable && (
|
|
337
|
+
<button disabled={reconnectingInstance === current?.id} onClick={reconnectCurrent}>
|
|
338
|
+
{reconnectingInstance === current?.id ? 'Reconnecting…' : 'Reconnect'}
|
|
339
|
+
</button>
|
|
340
|
+
)}
|
|
211
341
|
{session.state.role && <span className={`badge ${session.state.role}`}>{session.state.role}</span>}
|
|
212
342
|
</div>
|
|
213
343
|
{sessionErr && <p className="err">Session action failed: {sessionErr}</p>}
|
|
@@ -216,6 +346,7 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
|
|
|
216
346
|
{current.runtime_posture.label} · {current.transport.label} ({current.transport.mode}) · attach starts as observe unless control is explicitly granted.
|
|
217
347
|
{attached && session.state.role === 'observer' ? ' Click Take Control to re-attach with write access.' : ''}
|
|
218
348
|
{selectedBackend && !selectedBackend.available ? ` ${selectedBackend.reason ?? 'Selected backend is unavailable.'}` : ''}
|
|
349
|
+
{currentReconnectable ? ` Agent is unreachable while the runtime is still running. ${currentUnavailableReason ?? 'Reconnect can re-register the agent without restarting the instance.'}` : ''}
|
|
219
350
|
</p>
|
|
220
351
|
)}
|
|
221
352
|
<div className="terminal" ref={session.openTerminal} role="log" aria-label="Session output" />
|
|
@@ -245,15 +376,44 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
|
|
|
245
376
|
}
|
|
246
377
|
|
|
247
378
|
function sessionLabel(s: SessionInfo): string {
|
|
248
|
-
return s.session_name ??
|
|
379
|
+
return s.session_name ?? fmtId(s.id);
|
|
249
380
|
}
|
|
250
381
|
function sessionMeta(s: SessionInfo): string {
|
|
251
|
-
const backend = `${s.
|
|
252
|
-
|
|
382
|
+
const backend = `${s.session_class ?? 'managed'}/${s.session_backend ?? 'tmux'}`;
|
|
383
|
+
// v2 membership is authoritative; omit the viewer fragment when the executor
|
|
384
|
+
// doesn't advertise membership rather than implying "0 viewers".
|
|
385
|
+
if (!s.membership) return backend;
|
|
386
|
+
const viewers = s.membership.attachment_count;
|
|
253
387
|
return `${backend} · ${viewers} viewer${viewers === 1 ? '' : 's'}`;
|
|
254
388
|
}
|
|
255
389
|
function sessionHoldsController(s: SessionInfo): boolean {
|
|
256
|
-
return
|
|
390
|
+
return (s.membership?.controllers.length ?? 0) > 0;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function sessionKey(s: SessionInfo): string {
|
|
394
|
+
return `${s.instance_id}:${s.id}`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function sessionKeyFromAttachUrl(url: string | null): string {
|
|
398
|
+
const parts = sessionPartsFromAttachUrl(url);
|
|
399
|
+
return parts ? `${parts.instanceId}:${parts.sessionId}` : '';
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function sessionPartsFromAttachUrl(url: string | null): { instanceId: string; sessionId: string } | null {
|
|
403
|
+
if (!url) return null;
|
|
404
|
+
const pattern = /\/agents\/([^/]+)\/sessions\/([^/]+)\/attach/;
|
|
405
|
+
try {
|
|
406
|
+
const parsed = new URL(url);
|
|
407
|
+
const match = parsed.pathname.match(pattern);
|
|
408
|
+
return match ? { instanceId: decodeURIComponent(match[1]), sessionId: decodeURIComponent(match[2]) } : null;
|
|
409
|
+
} catch {
|
|
410
|
+
const match = url.match(pattern);
|
|
411
|
+
return match ? { instanceId: decodeURIComponent(match[1]), sessionId: decodeURIComponent(match[2]) } : null;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function instanceIdFromAttachUrl(url: string | null): string {
|
|
416
|
+
return sessionPartsFromAttachUrl(url)?.instanceId ?? '';
|
|
257
417
|
}
|
|
258
418
|
|
|
259
419
|
function dedupeInstances(instances: Instance[]) {
|
|
@@ -264,3 +424,14 @@ function dedupeInstances(instances: Instance[]) {
|
|
|
264
424
|
return true;
|
|
265
425
|
});
|
|
266
426
|
}
|
|
427
|
+
|
|
428
|
+
// VM runtimes included per #1778 — the bridge signals the in-guest agent via
|
|
429
|
+
// qemu-guest-agent, the container/docker path via docker exec.
|
|
430
|
+
const RECONNECTABLE_RUNTIMES = ['docker', 'container', 'vm', 'qemu', 'kvm'];
|
|
431
|
+
|
|
432
|
+
function isReconnectable(i: Instance): boolean {
|
|
433
|
+
const runtime = String(i.runtime_posture?.kind ?? i.runtime).toLowerCase();
|
|
434
|
+
const running = String(i.state).toLowerCase() === 'running';
|
|
435
|
+
const agentMissing = i.agent_ready === false || i.session_backends?.some((b) => b.available === false);
|
|
436
|
+
return running && RECONNECTABLE_RUNTIMES.includes(runtime) && Boolean(agentMissing);
|
|
437
|
+
}
|
|
@@ -65,7 +65,7 @@ describe('StartSessionModal (#1640/#1641)', () => {
|
|
|
65
65
|
const startBtn = await screen.findByRole('button', { name: /start session/i });
|
|
66
66
|
await waitFor(() => expect((startBtn as HTMLButtonElement).disabled).toBe(false));
|
|
67
67
|
fireEvent.click(startBtn);
|
|
68
|
-
await waitFor(() => expect(session.attach).toHaveBeenCalledWith(expect.stringContaining('/attach'), false, 'observer'));
|
|
68
|
+
await waitFor(() => expect(session.attach).toHaveBeenCalledWith(expect.stringContaining('/attach'), false, 'observer', { instanceId: 'inst-aaaaaaaa-1111', sessionId: 'sess-x' }));
|
|
69
69
|
const postCall = (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mock.calls.find((c) => String(c[0]).includes('/sessions') && c[1]?.method === 'POST');
|
|
70
70
|
expect(String(postCall?.[0])).toMatch(/mode=managed&backend=tmux/);
|
|
71
71
|
expect(String(postCall?.[0])).not.toContain('loadout=');
|
|
@@ -34,6 +34,11 @@ export function StartSessionModal({ open, onClose, session, onStarted, initialIn
|
|
|
34
34
|
useEffect(() => {
|
|
35
35
|
if (!open) return;
|
|
36
36
|
setErr('');
|
|
37
|
+
// The modal stays mounted across open/close, so stale state persists. A
|
|
38
|
+
// successful start left busy=true (it was only cleared on failure), which
|
|
39
|
+
// re-rendered the next open with a permanently greyed-out "Starting…"
|
|
40
|
+
// button until a full page refresh. Reset on every open.
|
|
41
|
+
setBusy(false);
|
|
37
42
|
let cancelled = false;
|
|
38
43
|
(async () => {
|
|
39
44
|
try {
|
|
@@ -89,9 +94,10 @@ export function StartSessionModal({ open, onClose, session, onStarted, initialIn
|
|
|
89
94
|
{ method: 'POST', signal: controller.signal },
|
|
90
95
|
);
|
|
91
96
|
if (!s.attach_url) throw new Error('Session started but no attach URL was returned.');
|
|
92
|
-
session.attach(s.attach_url, false, posture);
|
|
97
|
+
session.attach(s.attach_url, false, posture, { instanceId: current.id, sessionId: s.id });
|
|
93
98
|
onStarted();
|
|
94
99
|
onClose();
|
|
100
|
+
setBusy(false);
|
|
95
101
|
} catch (e) {
|
|
96
102
|
const message = e instanceof DOMException && e.name === 'AbortError'
|
|
97
103
|
? 'Starting the session timed out. The instance may still be preparing PTY support; refresh sessions and try again.'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState, type CSSProperties } from 'react';
|
|
2
2
|
import { api } from '../api';
|
|
3
|
-
import { fmtId } from '../util';
|
|
3
|
+
import { fmtId, runtimeFamily as runtimeTargetFamily } from '../util';
|
|
4
4
|
import type { Instance, Approval, Cost, RunningTask } from '../types';
|
|
5
5
|
|
|
6
6
|
interface Status {
|
|
@@ -82,9 +82,9 @@ export function Welcome({ onStartSession, onLaunchInstance, goTo }: { onStartSes
|
|
|
82
82
|
|
|
83
83
|
const runningInstances = st?.instances.filter((i) => i.state === 'running') ?? [];
|
|
84
84
|
const runtimeCoverage = {
|
|
85
|
-
host: st?.instances.some((i) => i.runtime_posture
|
|
86
|
-
container: st?.instances.some((i) => i.runtime_posture
|
|
87
|
-
vm: st?.instances.some((i) => i.runtime_posture
|
|
85
|
+
host: st?.instances.some((i) => runtimeTargetFamily(i.runtime_posture?.kind ?? i.runtime) === 'host') ?? false,
|
|
86
|
+
container: st?.instances.some((i) => runtimeTargetFamily(i.runtime_posture?.kind ?? i.runtime) === 'container') ?? false,
|
|
87
|
+
vm: st?.instances.some((i) => runtimeTargetFamily(i.runtime_posture?.kind ?? i.runtime) === 'vm') ?? false,
|
|
88
88
|
};
|
|
89
89
|
const copyStartCommand = async () => {
|
|
90
90
|
await navigator.clipboard?.writeText('aiwg cockpit');
|
|
@@ -352,8 +352,9 @@ function inventoryWarning(inv: InventoryEnvelope) {
|
|
|
352
352
|
}
|
|
353
353
|
|
|
354
354
|
function runtimeFamily(instance: Instance): string {
|
|
355
|
-
|
|
356
|
-
if (
|
|
355
|
+
const family = runtimeTargetFamily(instance.runtime_posture?.kind ?? instance.runtime);
|
|
356
|
+
if (family === 'host') return 'terminal';
|
|
357
|
+
if (family === 'vm' || family === 'container') return 'cube';
|
|
357
358
|
return 'window';
|
|
358
359
|
}
|
|
359
360
|
|
|
@@ -374,12 +375,12 @@ function initialWallMode(): WallReviewMode {
|
|
|
374
375
|
|
|
375
376
|
function buildOrbitNodes(st: Status | null): OrbitNode[] {
|
|
376
377
|
const running = st?.instances.filter((i) => i.state === 'running') ?? [];
|
|
377
|
-
const host = st?.instances.find((i) => i.runtime_posture
|
|
378
|
-
const container = st?.instances.find((i) => i.runtime_posture
|
|
379
|
-
const vm = st?.instances.find((i) => i.runtime_posture
|
|
378
|
+
const host = st?.instances.find((i) => runtimeTargetFamily(i.runtime_posture?.kind ?? i.runtime) === 'host');
|
|
379
|
+
const container = st?.instances.find((i) => runtimeTargetFamily(i.runtime_posture?.kind ?? i.runtime) === 'container');
|
|
380
|
+
const vm = st?.instances.find((i) => runtimeTargetFamily(i.runtime_posture?.kind ?? i.runtime) === 'vm');
|
|
380
381
|
const drive = st?.instances.some((i) => i.session_backends.some((b) => b.available && b.drive)) ?? false;
|
|
381
382
|
const approvals = st?.approvals.length ?? 0;
|
|
382
|
-
const cost = st?.cost?.total
|
|
383
|
+
const cost = st?.cost?.total?.usd;
|
|
383
384
|
const base: Omit<OrbitNode, 'x' | 'y'>[] = [
|
|
384
385
|
{
|
|
385
386
|
key: 'host',
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { useSessionSnapshotMonitor } from './sessionMonitor';
|
|
4
|
+
import {
|
|
5
|
+
getSessionRegistrySnapshot,
|
|
6
|
+
resetSessionRegistryForTest,
|
|
7
|
+
setRegistryActiveSession,
|
|
8
|
+
upsertRegistrySessions,
|
|
9
|
+
} from './sessionRegistry';
|
|
10
|
+
import type { SessionInfo } from './types';
|
|
11
|
+
|
|
12
|
+
const BACKGROUND_SESSION: SessionInfo = {
|
|
13
|
+
id: 'sess-bg',
|
|
14
|
+
instance_id: 'inst-bg',
|
|
15
|
+
attach_url: 'ws://x/agents/inst-bg/sessions/sess-bg/attach',
|
|
16
|
+
session_name: 'background',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
resetSessionRegistryForTest();
|
|
21
|
+
Object.defineProperty(document, 'hidden', { configurable: true, value: false });
|
|
22
|
+
vi.useFakeTimers();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.useRealTimers();
|
|
27
|
+
vi.restoreAllMocks();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('useSessionSnapshotMonitor', () => {
|
|
31
|
+
it('polls non-attached session snapshots into the registry', async () => {
|
|
32
|
+
upsertRegistrySessions([BACKGROUND_SESSION]);
|
|
33
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
34
|
+
if (String(input).includes('/api/instances/inst-bg/sessions/sess-bg/screen')) {
|
|
35
|
+
return jsonResponse({ text: 'Background prompt? [y/N]\n', seq: 4 });
|
|
36
|
+
}
|
|
37
|
+
return new Response('{}', { status: 404 });
|
|
38
|
+
});
|
|
39
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
40
|
+
|
|
41
|
+
renderHook(() => useSessionSnapshotMonitor(10_000));
|
|
42
|
+
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
|
|
43
|
+
|
|
44
|
+
const entry = getSessionRegistrySnapshot().entries['inst-bg:sess-bg'];
|
|
45
|
+
expect(entry.snapshot?.text).toContain('Background prompt?');
|
|
46
|
+
expect(entry.responseNeeded.needed).toBe(true);
|
|
47
|
+
expect(entry.unread).toBe(true);
|
|
48
|
+
expect(MockFetchUrls(fetchMock)).toContain('/api/instances/inst-bg/sessions/sess-bg/screen');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('does not poll the actively attached session', async () => {
|
|
52
|
+
upsertRegistrySessions([BACKGROUND_SESSION]);
|
|
53
|
+
setRegistryActiveSession('inst-bg', 'sess-bg');
|
|
54
|
+
const fetchMock = vi.fn(async () => jsonResponse({ text: 'should not fetch' }));
|
|
55
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
56
|
+
|
|
57
|
+
renderHook(() => useSessionSnapshotMonitor(10_000));
|
|
58
|
+
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
|
|
59
|
+
|
|
60
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('pauses snapshot polling while the document is hidden', async () => {
|
|
64
|
+
Object.defineProperty(document, 'hidden', { configurable: true, value: true });
|
|
65
|
+
upsertRegistrySessions([BACKGROUND_SESSION]);
|
|
66
|
+
const fetchMock = vi.fn(async () => jsonResponse({ text: 'hidden' }));
|
|
67
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
68
|
+
|
|
69
|
+
renderHook(() => useSessionSnapshotMonitor(10_000));
|
|
70
|
+
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
|
|
71
|
+
|
|
72
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
function jsonResponse(body: unknown): Response {
|
|
77
|
+
return new Response(JSON.stringify(body), {
|
|
78
|
+
status: 200,
|
|
79
|
+
headers: { 'Content-Type': 'application/json' },
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function MockFetchUrls(fetchMock: ReturnType<typeof vi.fn>): string {
|
|
84
|
+
return fetchMock.mock.calls.map(([input]) => String(input)).join('\n');
|
|
85
|
+
}
|