@aiwg/cockpit 2026.6.12 → 2026.6.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/bridge/src/server.mjs +52 -18
- package/package.json +1 -1
- package/web/src/App.test.tsx +21 -0
- package/web/src/App.tsx +18 -2
- package/web/src/components/Inventory.tsx +1 -2
- package/web/src/components/LaunchInstanceModal.test.tsx +15 -0
- package/web/src/components/LaunchInstanceModal.tsx +14 -1
- package/web/src/components/Sessions.test.tsx +78 -5
- package/web/src/components/Sessions.tsx +146 -75
- package/web/src/styles.css +35 -0
- package/web/src/useSession.test.tsx +80 -0
- package/web/src/useSession.ts +98 -33
package/bridge/src/server.mjs
CHANGED
|
@@ -371,7 +371,14 @@ async function destroyInstance(upstreamUrl, instanceId) {
|
|
|
371
371
|
}
|
|
372
372
|
} catch (err) {
|
|
373
373
|
const message = String(err?.message ?? err);
|
|
374
|
-
|
|
374
|
+
// A docker/container row with a resolvable name is still physically
|
|
375
|
+
// removable even when admin-v2 has no instance record (404): fall through
|
|
376
|
+
// to the `docker rm -f` cleanup below so the stopped container is actually
|
|
377
|
+
// removed. Returning already_gone here would claim success while the
|
|
378
|
+
// container persists and re-appears on the next inventory poll — the
|
|
379
|
+
// "stale stopped Docker row can't be destroyed" failure.
|
|
380
|
+
const dockerCleanable = ['docker', 'container'].includes(runtime) && dockerName;
|
|
381
|
+
if (inst && !dockerCleanable && / -> 404(?:;|$)/.test(message)) {
|
|
375
382
|
return {
|
|
376
383
|
target: `${upstreamUrl}/api/v2/admin/instances/${encodeURIComponent(instanceId)}/destroy`,
|
|
377
384
|
status: 200,
|
|
@@ -397,7 +404,18 @@ async function destroyInstance(upstreamUrl, instanceId) {
|
|
|
397
404
|
};
|
|
398
405
|
}
|
|
399
406
|
|
|
400
|
-
|
|
407
|
+
let alreadyGone = false;
|
|
408
|
+
try {
|
|
409
|
+
await spawnCollect('docker', ['rm', '-f', dockerName]);
|
|
410
|
+
} catch (err) {
|
|
411
|
+
// `docker rm -f` errors only because the container is already gone (e.g.
|
|
412
|
+
// removed out-of-band). That is success for a destroy request, not a failure.
|
|
413
|
+
if (/No such container|is not running|not found/i.test(String(err?.message ?? err))) {
|
|
414
|
+
alreadyGone = true;
|
|
415
|
+
} else {
|
|
416
|
+
throw err;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
401
419
|
return {
|
|
402
420
|
target: `docker rm -f ${dockerName}`,
|
|
403
421
|
status: 200,
|
|
@@ -407,6 +425,8 @@ async function destroyInstance(upstreamUrl, instanceId) {
|
|
|
407
425
|
runtime,
|
|
408
426
|
state: 'destroyed',
|
|
409
427
|
result: { state: 'destroyed' },
|
|
428
|
+
already_gone: alreadyGone,
|
|
429
|
+
message: alreadyGone ? `Container ${dockerName} was already removed; inventory refreshed.` : undefined,
|
|
410
430
|
fallback: 'docker-cli-after-admin-v2-instance-not-found',
|
|
411
431
|
},
|
|
412
432
|
};
|
|
@@ -953,22 +973,34 @@ async function getSessions(executorUrl, instanceId) {
|
|
|
953
973
|
return u.toString();
|
|
954
974
|
} catch { /* fall through to legacy shape */ }
|
|
955
975
|
}
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
...s,
|
|
964
|
-
id: sessionId,
|
|
965
|
-
instance_id: s.instance_id ?? s.instanceId ?? instanceId,
|
|
966
|
-
agent_id: s.agent_id ?? s.agentId ?? sessionAgentId,
|
|
967
|
-
role_policy: s.role_policy ?? s.rolePolicy ?? (s.default_role === 'observer' ? 'observe-default' : s.default_role) ?? 'observe-default',
|
|
968
|
-
attach_url: normalizeAttachUrl(s, sessionId),
|
|
969
|
-
};
|
|
970
|
-
}),
|
|
976
|
+
// Fallback URL construction (session entry carried no attach_url/pty_ws_url).
|
|
977
|
+
// The executor's pty-ws route keys the agent segment by the INSTANCE id, not
|
|
978
|
+
// the registered agent name — resolveSessionAgentId returns the name for some
|
|
979
|
+
// agents (e.g. VMs after a Bridge restart), which the route won't accept and
|
|
980
|
+
// the data-plane socket never connects (#1671). Use the instance id here; the
|
|
981
|
+
// agent name is only needed for the session-list FETCH, not the attach path.
|
|
982
|
+
return `${wsBase}/agents/${encodeURIComponent(instanceId)}/sessions/${encodeURIComponent(sessionId)}/attach`;
|
|
971
983
|
};
|
|
984
|
+
// Dedup by session id: the executor can register/return the same session more
|
|
985
|
+
// than once (e.g. a session registered twice in its registry), which surfaced
|
|
986
|
+
// as duplicate rows that are impossible to tell apart in the Sessions picker.
|
|
987
|
+
// Keep the first occurrence of each id (and drop id-less entries).
|
|
988
|
+
const seen = new Set();
|
|
989
|
+
const deduped = [];
|
|
990
|
+
for (const s of sessions) {
|
|
991
|
+
const sessionId = s.id ?? s.session_id ?? s.sessionId;
|
|
992
|
+
if (!sessionId || seen.has(sessionId)) continue;
|
|
993
|
+
seen.add(sessionId);
|
|
994
|
+
deduped.push({
|
|
995
|
+
...s,
|
|
996
|
+
id: sessionId,
|
|
997
|
+
instance_id: s.instance_id ?? s.instanceId ?? instanceId,
|
|
998
|
+
agent_id: s.agent_id ?? s.agentId ?? sessionAgentId,
|
|
999
|
+
role_policy: s.role_policy ?? s.rolePolicy ?? (s.default_role === 'observer' ? 'observe-default' : s.default_role) ?? 'observe-default',
|
|
1000
|
+
attach_url: normalizeAttachUrl(s, sessionId),
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
return { instance_id: instanceId, sessions: deduped };
|
|
972
1004
|
}
|
|
973
1005
|
|
|
974
1006
|
async function endSession(executorUrl, instanceId, sessionId) {
|
|
@@ -1212,7 +1244,9 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
|
|
|
1212
1244
|
attachUrl = u.toString();
|
|
1213
1245
|
} catch { /* fall through to legacy shape */ }
|
|
1214
1246
|
}
|
|
1215
|
-
|
|
1247
|
+
// Same as the list path (#1671): the attach segment must be the instance
|
|
1248
|
+
// id the executor's pty-ws route accepts, not the resolved agent name.
|
|
1249
|
+
return json(res, status, { ...body, id: sessionId, attach_url: attachUrl ?? `${wsBase}/agents/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionId)}/attach` });
|
|
1216
1250
|
}
|
|
1217
1251
|
|
|
1218
1252
|
// --- management surface (UC-012): lifecycle + task cancel ---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiwg/cockpit",
|
|
3
|
-
"version": "2026.6.
|
|
3
|
+
"version": "2026.6.13",
|
|
4
4
|
"description": "AIWG Cockpit — UX-first control plane over AIWG + multi-stack agentic sessions. Opt-in, separately published; NOT shipped in the base aiwg npm package (guarded by test/smoke/cockpit-base-footprint.test.js).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/web/src/App.test.tsx
CHANGED
|
@@ -219,6 +219,27 @@ describe('App shell (rendered DOM)', () => {
|
|
|
219
219
|
expect(screen.queryByText(/action failed/i)).toBeNull();
|
|
220
220
|
});
|
|
221
221
|
|
|
222
|
+
it('keeps Destroy enabled for a stopped Docker row so it can be cleaned up', async () => {
|
|
223
|
+
const stale = { ...instance('stale-dkr-1', 'docker', 'full-suite'), state: 'stopped' };
|
|
224
|
+
const inventory = { instances: [stale], count: 1, fetched_at: new Date().toISOString() };
|
|
225
|
+
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
226
|
+
const url = String(input);
|
|
227
|
+
if (url.includes('/api/health')) return jsonResponse({ executor_url: 'http://127.0.0.1:8122' });
|
|
228
|
+
if (url.includes('/api/inventory')) return jsonResponse(inventory);
|
|
229
|
+
if (url.includes('/api/running')) return jsonResponse({ count: 0, running: [] });
|
|
230
|
+
if (url.includes('/api/approvals')) return jsonResponse({ approvals: [] });
|
|
231
|
+
if (url.includes('/api/cost')) return jsonResponse({ total: { input_tokens: 0, output_tokens: 0, usd: 0 }, per_instance: [] });
|
|
232
|
+
return jsonResponse({});
|
|
233
|
+
}) as typeof fetch;
|
|
234
|
+
|
|
235
|
+
render(<App />);
|
|
236
|
+
fireEvent.click(screen.getByRole('tab', { name: 'Inventory' }));
|
|
237
|
+
const destroy = await screen.findByRole('button', { name: /destroy instance stale-dkr-1/i });
|
|
238
|
+
// Previously hard-disabled for stopped Docker rows, which trapped stale
|
|
239
|
+
// containers in inventory with no in-UI way to remove them.
|
|
240
|
+
expect((destroy as HTMLButtonElement).disabled).toBe(false);
|
|
241
|
+
});
|
|
242
|
+
|
|
222
243
|
it('each tab has a matching labelled tabpanel (controls/labelledby pairing)', () => {
|
|
223
244
|
render(<App />);
|
|
224
245
|
for (const tab of screen.getAllByRole('tab')) {
|
package/web/src/App.tsx
CHANGED
|
@@ -202,10 +202,21 @@ interface OperationStatus {
|
|
|
202
202
|
error?: { message?: string; detail?: string; code?: string } | string;
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
// How long the picker waits for a newly-launched instance's agent to register
|
|
206
|
+
// before handing the user back to Inventory. Heavy loadouts (e.g. full-suite —
|
|
207
|
+
// all 9 providers × 6 frameworks) legitimately take well over a minute to
|
|
208
|
+
// install and enroll their agent inside a fresh container/VM, so a short wait
|
|
209
|
+
// produced false "no session-ready agent" failures for instances that were in
|
|
210
|
+
// fact up and still installing. Overridable via window.AIWG_COCKPIT_SESSION_WAIT_S.
|
|
211
|
+
const SESSION_READY_TIMEOUT_S = (() => {
|
|
212
|
+
const raw = Number((window as unknown as { AIWG_COCKPIT_SESSION_WAIT_S?: unknown }).AIWG_COCKPIT_SESSION_WAIT_S);
|
|
213
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 150;
|
|
214
|
+
})();
|
|
215
|
+
|
|
205
216
|
async function waitForSessionReady(instanceId?: string, operationId?: string) {
|
|
206
217
|
let last = '';
|
|
207
218
|
let operationDetail = '';
|
|
208
|
-
for (let i = 0; i <
|
|
219
|
+
for (let i = 0; i < SESSION_READY_TIMEOUT_S; i += 1) {
|
|
209
220
|
if (operationId) {
|
|
210
221
|
const op = await api<OperationStatus>(`/api/operations/${encodeURIComponent(operationId)}`);
|
|
211
222
|
const state = String(op.state ?? '').toLowerCase();
|
|
@@ -234,7 +245,12 @@ async function waitForSessionReady(instanceId?: string, operationId?: string) {
|
|
|
234
245
|
}
|
|
235
246
|
await sleep(1_000);
|
|
236
247
|
}
|
|
237
|
-
|
|
248
|
+
const where = instanceId ? `Instance ${instanceId}` : 'The instance';
|
|
249
|
+
throw new Error(
|
|
250
|
+
`${where} launched and is still installing its loadout — its agent had not registered after ${SESSION_READY_TIMEOUT_S}s `
|
|
251
|
+
+ `(${last}). This is not a failure: heavy loadouts (e.g. full-suite) can take longer. `
|
|
252
|
+
+ `It will appear under Inventory once its agent enrolls; open a session from there when it shows as running.`,
|
|
253
|
+
);
|
|
238
254
|
}
|
|
239
255
|
|
|
240
256
|
function operationFailure(op: OperationStatus) {
|
|
@@ -116,8 +116,7 @@ export function Inventory({ onStartSession, onLaunchInstance }: { onStartSession
|
|
|
116
116
|
: <button aria-label={`Start instance ${fmtId(i.id)}`} onClick={() => control(`/api/instances/${encodeURIComponent(i.id)}/start`, 'POST')}>Start Instance</button>}{' '}
|
|
117
117
|
<button
|
|
118
118
|
aria-label={`Destroy instance ${fmtId(i.id)}`}
|
|
119
|
-
|
|
120
|
-
title={i.state !== 'running' && i.runtime === 'docker' ? 'Sandbox reports this stopped Docker row but admin-v2 no longer has a destroyable instance record.' : undefined}
|
|
119
|
+
title={i.state !== 'running' && i.runtime === 'docker' ? 'Stopped Docker row — Destroy removes the container directly (admin-v2 has no instance record).' : undefined}
|
|
121
120
|
onClick={() => { if (confirm(`Destroy ${fmtId(i.id)}? This cannot be undone.`)) control(`/api/instances/${encodeURIComponent(i.id)}`, 'DELETE'); }}
|
|
122
121
|
>
|
|
123
122
|
Destroy
|
|
@@ -77,6 +77,21 @@ describe('LaunchInstanceModal', () => {
|
|
|
77
77
|
});
|
|
78
78
|
});
|
|
79
79
|
|
|
80
|
+
it('regenerates a fresh instance name on each open so back-to-back launches do not collide', async () => {
|
|
81
|
+
globalThis.fetch = mockFetch();
|
|
82
|
+
const { rerender } = render(<LaunchInstanceModal open onClose={() => {}} onLaunched={() => {}} />);
|
|
83
|
+
fireEvent.change(await screen.findByLabelText('Runtime'), { target: { value: 'docker' } });
|
|
84
|
+
const name1 = (await screen.findByLabelText('Name') as HTMLInputElement).value;
|
|
85
|
+
expect(name1).toMatch(/^cockpit-/);
|
|
86
|
+
// Close then reopen — the second launch (e.g. a VM after a Docker) must get a
|
|
87
|
+
// different name, or both register the same name-keyed agent and shadow each other.
|
|
88
|
+
rerender(<LaunchInstanceModal open={false} onClose={() => {}} onLaunched={() => {}} />);
|
|
89
|
+
rerender(<LaunchInstanceModal open onClose={() => {}} onLaunched={() => {}} />);
|
|
90
|
+
const name2 = (await screen.findByLabelText('Name') as HTMLInputElement).value;
|
|
91
|
+
expect(name2).toMatch(/^cockpit-/);
|
|
92
|
+
expect(name2).not.toBe(name1);
|
|
93
|
+
});
|
|
94
|
+
|
|
80
95
|
it('passes a VM SSH public key path when launching QEMU', async () => {
|
|
81
96
|
globalThis.fetch = mockFetch();
|
|
82
97
|
const onLaunched = vi.fn();
|
|
@@ -22,6 +22,15 @@ const FALLBACK_LOADOUTS: Loadout[] = [
|
|
|
22
22
|
{ id: 'full-suite', label: 'full-suite', description: 'Multi-provider tool suite', runtimes: ['qemu', 'vm'] },
|
|
23
23
|
];
|
|
24
24
|
|
|
25
|
+
// A fresh, collision-resistant instance name per launch. The executor keys
|
|
26
|
+
// agents by instance name, so two instances sharing a name (e.g. a Docker
|
|
27
|
+
// container and a VM both named `cockpit-<ts>`) register the same agent id and
|
|
28
|
+
// shadow each other — the second launch silently knocks the first offline.
|
|
29
|
+
// Date.now() alone collides within a page session (the name was generated once
|
|
30
|
+
// at mount and reused); add random entropy and regenerate on every open/launch.
|
|
31
|
+
const genInstanceName = () =>
|
|
32
|
+
`cockpit-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`.slice(0, 32);
|
|
33
|
+
|
|
25
34
|
export function LaunchInstanceModal({
|
|
26
35
|
open,
|
|
27
36
|
onClose,
|
|
@@ -32,7 +41,7 @@ export function LaunchInstanceModal({
|
|
|
32
41
|
onLaunched: (instanceId?: string, openSession?: boolean, operationId?: string) => Promise<void> | void;
|
|
33
42
|
}) {
|
|
34
43
|
const [runtime, setRuntime] = useState<Runtime>('host');
|
|
35
|
-
const [name, setName] = useState(
|
|
44
|
+
const [name, setName] = useState(genInstanceName);
|
|
36
45
|
const [loadout, setLoadout] = useState('host-tools');
|
|
37
46
|
const [loadouts, setLoadouts] = useState<Loadout[]>([]);
|
|
38
47
|
const [instances, setInstances] = useState<Instance[]>([]);
|
|
@@ -49,6 +58,9 @@ export function LaunchInstanceModal({
|
|
|
49
58
|
|
|
50
59
|
useEffect(() => {
|
|
51
60
|
if (!open) return;
|
|
61
|
+
// Fresh name every time the picker opens, so back-to-back launches (e.g.
|
|
62
|
+
// Docker then VM) never collide on the executor's name-keyed agent registry.
|
|
63
|
+
setName(genInstanceName());
|
|
52
64
|
let cancelled = false;
|
|
53
65
|
Promise.all([
|
|
54
66
|
api<{ loadouts: Loadout[] }>('/api/loadouts').catch(() => ({ loadouts: [] as Loadout[] })),
|
|
@@ -116,6 +128,7 @@ export function LaunchInstanceModal({
|
|
|
116
128
|
: `Launch accepted: ${instanceId ?? operationId ?? 'operation pending'}`);
|
|
117
129
|
await onLaunched(instanceId, openSession, operationId);
|
|
118
130
|
if (openSession) onClose();
|
|
131
|
+
else setName(genInstanceName()); // modal stays open — next launch gets a fresh, non-colliding name
|
|
119
132
|
} catch (e) {
|
|
120
133
|
setErr((e as Error).message);
|
|
121
134
|
} finally {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react';
|
|
2
|
+
import { render, screen, waitFor, fireEvent, cleanup, within } from '@testing-library/react';
|
|
3
3
|
import { Sessions } from './Sessions';
|
|
4
4
|
import type { SessionApi } from '../useSession';
|
|
5
5
|
|
|
@@ -110,10 +110,83 @@ describe('Sessions', () => {
|
|
|
110
110
|
|
|
111
111
|
render(<Sessions session={session} composer="" setComposer={() => {}} onRequestStart={() => {}} refreshMs={10} />);
|
|
112
112
|
|
|
113
|
-
|
|
114
|
-
expect(await screen.
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
// The nav lists instances as buttons (not <select> options) now (#1670).
|
|
114
|
+
expect(await screen.findByText('docker-one')).toBeTruthy();
|
|
115
|
+
// Inventory refresh swaps inst-1 → inst-2; the dead instance drops out and
|
|
116
|
+
// selection follows to the live one, whose session is listed underneath.
|
|
117
|
+
expect(await screen.findByText('docker-two')).toBeTruthy();
|
|
118
|
+
await waitFor(() => expect(screen.queryByText('docker-one')).toBeNull());
|
|
119
|
+
// Scope to the nav (the active-session indicator also carries the id by title).
|
|
120
|
+
const nav = screen.getByLabelText('Instances and sessions');
|
|
121
|
+
expect(await within(nav).findByTitle('sess-new')).toBeTruthy();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('auto-attaches in observe when a different session is selected (#1670)', async () => {
|
|
125
|
+
const session = stubSession(); // currently attached to .../sessions/sess-1/attach as controller
|
|
126
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
127
|
+
const url = String(input);
|
|
128
|
+
if (url.includes('/api/inventory')) return jsonResponse({ instances: [INSTANCE] });
|
|
129
|
+
if (url.includes('/api/sessions?instance=')) return jsonResponse({
|
|
130
|
+
sessions: [{ id: 'sess-2', session_name: 'terminal-other', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-2/attach', mode: 'managed', backend: 'tmux' }],
|
|
131
|
+
});
|
|
132
|
+
return new Response('{}', { status: 404 });
|
|
133
|
+
});
|
|
134
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
135
|
+
render(<Sessions session={session} composer="" setComposer={() => {}} onRequestStart={() => {}} />);
|
|
136
|
+
|
|
137
|
+
const nav = screen.getByLabelText('Instances and sessions');
|
|
138
|
+
const sessBtn = await within(nav).findByTitle('sess-2');
|
|
139
|
+
fireEvent.click(sessBtn);
|
|
140
|
+
// Selecting a not-yet-attached session attaches it read-only; the operator clicks Drive to take over.
|
|
141
|
+
expect(session.attach).toHaveBeenCalledWith('ws://x/agents/inst-1/sessions/sess-2/attach', false, 'observer');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('does not re-attach (downgrade) when re-selecting the session already attached', async () => {
|
|
145
|
+
const session = stubSession(); // state.url === .../sessions/sess-1/attach, role controller
|
|
146
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
147
|
+
const url = String(input);
|
|
148
|
+
if (url.includes('/api/inventory')) return jsonResponse({ instances: [INSTANCE] });
|
|
149
|
+
if (url.includes('/api/sessions?instance=')) return jsonResponse({
|
|
150
|
+
sessions: [{ id: 'sess-1', session_name: 'terminal-main', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-1/attach', mode: 'managed', backend: 'tmux' }],
|
|
151
|
+
});
|
|
152
|
+
return new Response('{}', { status: 404 });
|
|
153
|
+
});
|
|
154
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
155
|
+
render(<Sessions session={session} composer="" setComposer={() => {}} onRequestStart={() => {}} />);
|
|
156
|
+
|
|
157
|
+
const nav = screen.getByLabelText('Instances and sessions');
|
|
158
|
+
fireEvent.click(await within(nav).findByTitle('sess-1'));
|
|
159
|
+
// Clicking the session we already drive must not downgrade us back to observer.
|
|
160
|
+
expect(session.attach).not.toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('distinguishes sessions by name + backend + viewer count in the nav (#1670)', async () => {
|
|
164
|
+
const session = stubSession();
|
|
165
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
166
|
+
const url = String(input);
|
|
167
|
+
if (url.includes('/api/inventory')) return jsonResponse({ instances: [INSTANCE] });
|
|
168
|
+
if (url.includes('/api/sessions?instance=')) return jsonResponse({
|
|
169
|
+
sessions: [
|
|
170
|
+
{ id: 'sess-a', session_name: 'terminal-alpha', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-a/attach', mode: 'managed', backend: 'tmux', controllers: 1, observers: 1 },
|
|
171
|
+
{ id: 'sess-b', session_name: 'terminal-beta', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-b/attach', mode: 'direct', backend: 'native', members: 0 },
|
|
172
|
+
],
|
|
173
|
+
});
|
|
174
|
+
return new Response('{}', { status: 404 });
|
|
175
|
+
});
|
|
176
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
177
|
+
|
|
178
|
+
render(<Sessions session={session} composer="" setComposer={() => {}} onRequestStart={() => {}} />);
|
|
179
|
+
|
|
180
|
+
// Two distinct sessions, each shown by name with its own backend/viewer meta.
|
|
181
|
+
// Scope to the nav — the controls bar's active-session indicator echoes the label.
|
|
182
|
+
await screen.findByText('terminal-beta');
|
|
183
|
+
const nav = screen.getByLabelText('Instances and sessions');
|
|
184
|
+
expect(within(nav).getByText('terminal-alpha')).toBeTruthy();
|
|
185
|
+
expect(within(nav).getByText('terminal-beta')).toBeTruthy();
|
|
186
|
+
expect(within(nav).getByText('managed/tmux · 2 viewers')).toBeTruthy();
|
|
187
|
+
expect(within(nav).getByText('direct/native · 0 viewers')).toBeTruthy();
|
|
188
|
+
// sess-a has a controller connected → it carries the ctrl badge; sess-b does not.
|
|
189
|
+
expect(within(nav).getByTitle('A controller is connected')).toBeTruthy();
|
|
117
190
|
});
|
|
118
191
|
});
|
|
119
192
|
|
|
@@ -60,7 +60,15 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
|
|
|
60
60
|
})
|
|
61
61
|
.catch((e) => { setSessions([]); setAttachUrl(''); setSessionErr((e as Error).message); });
|
|
62
62
|
}, []);
|
|
63
|
-
|
|
63
|
+
// Reload the selected instance's sessions on selection change AND on an interval,
|
|
64
|
+
// so a session created elsewhere (the Start modal, the Running board, another
|
|
65
|
+
// operator) shows up in the nav without re-selecting the instance.
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!instId) return;
|
|
68
|
+
loadSessions(instId);
|
|
69
|
+
const timer = window.setInterval(() => loadSessions(instId), refreshMs);
|
|
70
|
+
return () => window.clearInterval(timer);
|
|
71
|
+
}, [instId, loadSessions, refreshMs]);
|
|
64
72
|
|
|
65
73
|
const send = () => { if (session.sendInput(composer)) setComposer(''); };
|
|
66
74
|
const attached = session.state.attached;
|
|
@@ -85,15 +93,16 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
|
|
|
85
93
|
// Starting now routes through the shared picker (#1640/#1641) so this tab and the
|
|
86
94
|
// dashboard verb share one params/clobber/error path. The selects below remain for
|
|
87
95
|
// attaching to / observing / driving sessions that already exist.
|
|
88
|
-
const endSelectedSession = async () => {
|
|
89
|
-
|
|
90
|
-
|
|
96
|
+
const endSelectedSession = async (target?: SessionInfo) => {
|
|
97
|
+
const s = target ?? selectedSession;
|
|
98
|
+
if (!current || !s) return;
|
|
99
|
+
const label = s.session_name ?? s.id;
|
|
91
100
|
if (!confirm(`End session ${label}? This closes the PTY and detaches connected views.`)) return;
|
|
92
|
-
setEndingSession(
|
|
101
|
+
setEndingSession(s.id);
|
|
93
102
|
setSessionErr('');
|
|
94
103
|
try {
|
|
95
|
-
await api(`/api/instances/${encodeURIComponent(current.id)}/sessions/${encodeURIComponent(
|
|
96
|
-
if (session.state.url ===
|
|
104
|
+
await api(`/api/instances/${encodeURIComponent(current.id)}/sessions/${encodeURIComponent(s.id)}`, { method: 'DELETE' });
|
|
105
|
+
if (session.state.url === s.attach_url) session.detach();
|
|
97
106
|
await loadSessions(current.id);
|
|
98
107
|
} catch (e) {
|
|
99
108
|
setSessionErr((e as Error).message);
|
|
@@ -105,80 +114,142 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
|
|
|
105
114
|
return (
|
|
106
115
|
<>
|
|
107
116
|
<p className="hint">
|
|
108
|
-
<strong>Workspace.</strong>
|
|
109
|
-
new session
|
|
117
|
+
<strong>Workspace.</strong> Pick an instance on the left, then a session under it to observe or drive.
|
|
118
|
+
Start a new session per instance, or open a live task from the <strong>Running</strong> fleet board.
|
|
110
119
|
</p>
|
|
111
|
-
<div className="
|
|
112
|
-
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
120
|
+
<div className="session-workspace">
|
|
121
|
+
{/* Persistent instances → sessions navigation (agentic-sandbox-style control screen, #1670). */}
|
|
122
|
+
<aside className="session-nav" aria-label="Instances and sessions">
|
|
123
|
+
<div className="session-nav-head">
|
|
124
|
+
<h2>Instances</h2>
|
|
125
|
+
<span className="hint">{instances.length}</span>
|
|
126
|
+
</div>
|
|
127
|
+
{!instances.length && <p className="empty">No session-capable running instances.</p>}
|
|
128
|
+
<ul className="nav-list">
|
|
129
|
+
{instances.map((i) => {
|
|
130
|
+
const isSel = i.id === instId;
|
|
131
|
+
const name = i.launch_context?.name ?? fmtId(i.id);
|
|
132
|
+
return (
|
|
133
|
+
<li key={i.id} className={`nav-instance${isSel ? ' selected' : ''}`}>
|
|
134
|
+
<button className="nav-instance-row" aria-expanded={isSel} onClick={() => setInstId(i.id)} title={i.id}>
|
|
135
|
+
<span className={`badge isolation-${i.runtime_posture.isolation}`}>{i.runtime}</span>
|
|
136
|
+
<span className="nav-instance-name">{name}</span>
|
|
137
|
+
<span className={`state ${i.state}`}><span className="dot" aria-hidden="true" />{i.state}</span>
|
|
138
|
+
</button>
|
|
139
|
+
{isSel && (
|
|
140
|
+
<div className="nav-sessions">
|
|
141
|
+
{sessions.length === 0 && <p className="empty nav-empty">No sessions yet.</p>}
|
|
142
|
+
<ul>
|
|
143
|
+
{sessions.map((s) => {
|
|
144
|
+
const selS = s.attach_url === attachUrl;
|
|
145
|
+
const live = session.state.url === s.attach_url && attached;
|
|
146
|
+
return (
|
|
147
|
+
<li key={s.id}>
|
|
148
|
+
<button
|
|
149
|
+
className={`nav-session${selS ? ' selected' : ''}${live ? ' live' : ''}`}
|
|
150
|
+
// Selecting a session auto-attaches in observe (read-only) so the
|
|
151
|
+
// operator immediately sees it; they click Drive to take control.
|
|
152
|
+
// Re-selecting the session already attached here is a no-op (don't
|
|
153
|
+
// downgrade an active controller back to observer).
|
|
154
|
+
onClick={() => {
|
|
155
|
+
setAttachUrl(s.attach_url);
|
|
156
|
+
if (s.attach_url !== session.state.url) session.attach(s.attach_url, false, 'observer');
|
|
157
|
+
}}
|
|
158
|
+
title={s.id}
|
|
159
|
+
>
|
|
160
|
+
<span className="nav-session-name">{sessionLabel(s)}</span>
|
|
161
|
+
<span className="nav-session-meta">{sessionMeta(s)}</span>
|
|
162
|
+
{sessionHoldsController(s) && <span className="badge controller" title="A controller is connected">ctrl</span>}
|
|
163
|
+
{live && <span className="badge live-dot" title="Attached here">●</span>}
|
|
164
|
+
</button>
|
|
165
|
+
<button
|
|
166
|
+
className="nav-session-end"
|
|
167
|
+
aria-label={`End session ${sessionLabel(s)}`}
|
|
168
|
+
title="End this PTY session"
|
|
169
|
+
disabled={endingSession === s.id}
|
|
170
|
+
onClick={() => endSelectedSession(s)}
|
|
171
|
+
>
|
|
172
|
+
{endingSession === s.id ? '…' : '×'}
|
|
173
|
+
</button>
|
|
174
|
+
</li>
|
|
175
|
+
);
|
|
176
|
+
})}
|
|
177
|
+
</ul>
|
|
178
|
+
<button className="cta-sm nav-new-session" disabled={backends.length > 0 && !selectedBackend?.available} onClick={() => onRequestStart(i.id)}>+ New session</button>
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</li>
|
|
182
|
+
);
|
|
183
|
+
})}
|
|
184
|
+
</ul>
|
|
185
|
+
</aside>
|
|
186
|
+
|
|
187
|
+
<section className="session-main">
|
|
188
|
+
<div className="controls">
|
|
189
|
+
{backends.length > 1 && (
|
|
190
|
+
<>
|
|
191
|
+
<label htmlFor="sel-backend">Mode</label>
|
|
192
|
+
<select id="sel-backend" value={backendKey} onChange={(e) => setBackendKey(e.target.value)}>
|
|
193
|
+
{backends.map((b) => <option key={`${b.mode}:${b.backend}`} value={`${b.mode}:${b.backend}`} disabled={!b.available}>{b.mode} · {b.backend}{b.available ? '' : ` — ${b.reason ?? 'unsupported'}`}</option>)}
|
|
194
|
+
</select>
|
|
195
|
+
</>
|
|
196
|
+
)}
|
|
197
|
+
<span className="controls-active" title={selectedSession?.id}>{selectedSession ? sessionLabel(selectedSession) : '— no session selected —'}</span>
|
|
198
|
+
<button disabled={!attachUrl || (attached && session.state.role === 'observer')} onClick={() => session.attach(attachUrl, false, 'observer')}>Observe</button>
|
|
199
|
+
<button disabled={!attachUrl || selectedBackend?.drive === false || (attached && session.state.role === 'controller')} onClick={() => session.attach(attachUrl, false, 'controller')}>
|
|
200
|
+
{attached && session.state.role === 'observer' ? 'Take Control' : 'Drive'}
|
|
201
|
+
</button>
|
|
202
|
+
<button disabled={!attached || selectedBackend?.keyframe === false} onClick={session.requestKeyframe}>Keyframe</button>
|
|
203
|
+
<button disabled={!attached} onClick={() => session.replay(attachUrl, requestedReplayRole)}>Reattach + replay</button>
|
|
204
|
+
<button disabled={!attached} onClick={session.detach}>Detach</button>
|
|
205
|
+
{session.state.role && <span className={`badge ${session.state.role}`}>{session.state.role}</span>}
|
|
206
|
+
</div>
|
|
207
|
+
{sessionErr && <p className="err">Session action failed: {sessionErr}</p>}
|
|
208
|
+
{current && (
|
|
209
|
+
<p className="hint">
|
|
210
|
+
{current.runtime_posture.label} · {current.transport.label} ({current.transport.mode}) · attach starts as observe unless control is explicitly granted.
|
|
211
|
+
{attached && session.state.role === 'observer' ? ' Click Take Control to re-attach with write access.' : ''}
|
|
212
|
+
{selectedBackend && !selectedBackend.available ? ` ${selectedBackend.reason ?? 'Selected backend is unavailable.'}` : ''}
|
|
213
|
+
</p>
|
|
214
|
+
)}
|
|
215
|
+
<div className="terminal" ref={session.openTerminal} role="log" aria-label="Session output" />
|
|
216
|
+
{showPicker && (
|
|
217
|
+
<div className="picker">
|
|
218
|
+
<p className="hint" style={{ marginTop: 0 }}>Pick a capability to insert into the command — then Send to inject it. (Lookup is UI; the agent runs it.)</p>
|
|
219
|
+
<CapabilitySearch compact autoFocus onPick={insertCap} />
|
|
220
|
+
</div>
|
|
221
|
+
)}
|
|
222
|
+
<div className="inputrow">
|
|
223
|
+
<button aria-label="Insert a capability" title="Insert a capability (search)" onClick={() => setShowPicker((v) => !v)}>+</button>
|
|
224
|
+
<input
|
|
225
|
+
ref={inputRef}
|
|
226
|
+
value={composer}
|
|
227
|
+
onChange={(e) => setComposer(e.target.value)}
|
|
228
|
+
onKeyDown={(e) => { if (e.key === 'Enter') send(); }}
|
|
229
|
+
placeholder={session.isController ? 'Type to drive the session…' : 'Observing — input is read-only'}
|
|
230
|
+
disabled={!session.isController}
|
|
231
|
+
aria-label="Session input"
|
|
232
|
+
/>
|
|
233
|
+
<button disabled={!session.isController} onClick={send}>Send</button>
|
|
234
|
+
</div>
|
|
235
|
+
</section>
|
|
173
236
|
</div>
|
|
174
|
-
<p className="hint">
|
|
175
|
-
Pick or start a session, then Attach. Cockpit requests observe-first access; drive/control is explicit and denial reasons stay visible through the session stream.
|
|
176
|
-
Actions inject their command right here.
|
|
177
|
-
</p>
|
|
178
237
|
</>
|
|
179
238
|
);
|
|
180
239
|
}
|
|
181
240
|
|
|
241
|
+
function sessionLabel(s: SessionInfo): string {
|
|
242
|
+
return s.session_name ?? s.sessionName ?? fmtId(s.id);
|
|
243
|
+
}
|
|
244
|
+
function sessionMeta(s: SessionInfo): string {
|
|
245
|
+
const backend = `${s.mode ?? s.session_class ?? 'managed'}/${s.backend ?? s.session_backend ?? 'tmux'}`;
|
|
246
|
+
const viewers = s.members ?? ((s.controllers ?? 0) + (s.observers ?? 0));
|
|
247
|
+
return `${backend} · ${viewers} viewer${viewers === 1 ? '' : 's'}`;
|
|
248
|
+
}
|
|
249
|
+
function sessionHoldsController(s: SessionInfo): boolean {
|
|
250
|
+
return s.has_controller === true || (s.controllers ?? 0) > 0;
|
|
251
|
+
}
|
|
252
|
+
|
|
182
253
|
function dedupeInstances(instances: Instance[]) {
|
|
183
254
|
const seen = new Set<string>();
|
|
184
255
|
return instances.filter((instance) => {
|
package/web/src/styles.css
CHANGED
|
@@ -252,3 +252,38 @@ code { font:13px ui-monospace, monospace; color:var(--muted); }
|
|
|
252
252
|
@media (prefers-reduced-motion: reduce) {
|
|
253
253
|
*, *::before, *::after { scroll-behavior:auto !important; transition:none !important; animation:none !important; }
|
|
254
254
|
}
|
|
255
|
+
|
|
256
|
+
/* Session workspace — persistent instances→sessions nav (agentic-sandbox-style control screen, #1670) */
|
|
257
|
+
.session-workspace { display:grid; grid-template-columns:minmax(240px, 300px) minmax(0, 1fr); gap:16px; align-items:start; }
|
|
258
|
+
@media (max-width: 820px) { .session-workspace { grid-template-columns:1fr; } }
|
|
259
|
+
.session-nav { border:1px solid var(--line); border-radius:10px; background:var(--panel); padding:10px; max-height:calc(100vh - 250px); overflow-y:auto; }
|
|
260
|
+
.session-nav-head { display:flex; align-items:center; justify-content:space-between; margin:2px 4px 8px; }
|
|
261
|
+
.session-nav-head h2 { margin:0; font-size:15px; }
|
|
262
|
+
.session-nav-head .hint { margin:0; }
|
|
263
|
+
.nav-list { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:4px; }
|
|
264
|
+
.nav-instance { border-radius:8px; border:1px solid transparent; }
|
|
265
|
+
.nav-instance.selected { background:rgba(94,234,212,.06); border-color:var(--line); }
|
|
266
|
+
.nav-instance-row { width:100%; display:flex; align-items:center; gap:8px; background:none; border:0; padding:8px; cursor:pointer; text-align:left; border-radius:8px; }
|
|
267
|
+
.nav-instance-row:hover { border-color:transparent; background:rgba(148,163,184,.08); }
|
|
268
|
+
.nav-instance-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:600; font-size:13px; }
|
|
269
|
+
.nav-instance .state { font-size:11px; color:var(--muted); }
|
|
270
|
+
.nav-instance .badge { font-size:10px; padding:1px 7px; text-transform:lowercase; }
|
|
271
|
+
.nav-sessions { padding:0 6px 8px 10px; display:flex; flex-direction:column; gap:4px; }
|
|
272
|
+
.nav-sessions ul { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:3px; }
|
|
273
|
+
.nav-sessions li { display:flex; align-items:stretch; gap:4px; }
|
|
274
|
+
.nav-session { flex:1; min-width:0; display:flex; flex-wrap:wrap; align-items:center; gap:4px 6px; background:none; border:1px solid transparent; border-left:2px solid var(--line); border-radius:6px; padding:6px 8px; cursor:pointer; text-align:left; }
|
|
275
|
+
.nav-session:hover { background:rgba(148,163,184,.08); }
|
|
276
|
+
.nav-session.selected { border-color:var(--accent); border-left-color:var(--accent); background:rgba(94,234,212,.08); }
|
|
277
|
+
.nav-session.live { border-left-color:var(--ok); }
|
|
278
|
+
.nav-session-name { flex-basis:100%; font-size:12px; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
279
|
+
.nav-session-meta { font-size:11px; color:var(--muted); }
|
|
280
|
+
.nav-session .badge { padding:0 6px; font-size:10px; }
|
|
281
|
+
.nav-session .live-dot { border-color:var(--ok); color:var(--ok); }
|
|
282
|
+
.nav-session-end { align-self:center; padding:4px 9px; color:var(--muted); border-color:transparent; background:none; line-height:1; }
|
|
283
|
+
.nav-session-end:hover:not(:disabled) { border-color:var(--err); color:var(--err); }
|
|
284
|
+
.nav-new-session { margin-top:6px; align-self:flex-start; }
|
|
285
|
+
.cta-sm { font-size:12px; padding:5px 10px; border-color:var(--accent); color:var(--accent); }
|
|
286
|
+
.nav-empty { margin:4px 0; font-size:12px; }
|
|
287
|
+
.session-main { min-width:0; }
|
|
288
|
+
.controls-active { color:var(--muted); font-size:13px; padding:5px 10px; border:1px dashed var(--line); border-radius:7px; max-width:240px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
289
|
+
.badge.live-dot { color:var(--ok); border-color:var(--ok); }
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { renderHook, act } from '@testing-library/react';
|
|
3
|
+
import { useSession } from './useSession';
|
|
4
|
+
|
|
5
|
+
// Minimal WebSocket double: records every constructed socket and lets the test
|
|
6
|
+
// drive open/close/message. Mirrors the readiness-race timing (#1669).
|
|
7
|
+
class MockWS {
|
|
8
|
+
static instances: MockWS[] = [];
|
|
9
|
+
url: string;
|
|
10
|
+
sent: string[] = [];
|
|
11
|
+
private listeners: Record<string, ((e: unknown) => void)[]> = {};
|
|
12
|
+
constructor(url: string) { this.url = url; MockWS.instances.push(this); }
|
|
13
|
+
addEventListener(type: string, fn: (e: unknown) => void) { (this.listeners[type] ||= []).push(fn); }
|
|
14
|
+
send(data: string) { this.sent.push(data); }
|
|
15
|
+
close() { /* no-op; the test drives 'close' explicitly */ }
|
|
16
|
+
emit(type: string, e: unknown = {}) { (this.listeners[type] || []).forEach((fn) => fn(e)); }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
MockWS.instances = [];
|
|
21
|
+
(globalThis as unknown as { WebSocket: unknown }).WebSocket = MockWS as unknown;
|
|
22
|
+
vi.useFakeTimers();
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); });
|
|
25
|
+
|
|
26
|
+
describe('useSession — retry through the PTY-readiness window (#1669)', () => {
|
|
27
|
+
it('reconnects on an early empty close instead of giving up', () => {
|
|
28
|
+
const { result } = renderHook(() => useSession());
|
|
29
|
+
act(() => { result.current.attach('ws://x/attach', false, 'controller'); });
|
|
30
|
+
expect(MockWS.instances).toHaveLength(1);
|
|
31
|
+
|
|
32
|
+
// First socket opens, then closes with zero frames (agent PTY not ready yet).
|
|
33
|
+
act(() => { MockWS.instances[0].emit('open'); MockWS.instances[0].emit('close'); });
|
|
34
|
+
// No reconnect yet (waits the backoff)...
|
|
35
|
+
expect(MockWS.instances).toHaveLength(1);
|
|
36
|
+
// ...then a fresh socket is opened to retry.
|
|
37
|
+
act(() => { vi.advanceTimersByTime(1300); });
|
|
38
|
+
expect(MockWS.instances).toHaveLength(2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('counts a failing socket once even though it fires both error and close', () => {
|
|
42
|
+
const { result } = renderHook(() => useSession());
|
|
43
|
+
act(() => { result.current.attach('ws://x/attach', false, 'controller'); });
|
|
44
|
+
// A real failing WebSocket dispatches BOTH 'error' and 'close'; that must
|
|
45
|
+
// burn only one retry slot, not two (otherwise the budget halves silently).
|
|
46
|
+
act(() => { MockWS.instances[0].emit('open'); MockWS.instances[0].emit('error'); MockWS.instances[0].emit('close'); });
|
|
47
|
+
act(() => { vi.advanceTimersByTime(1300); });
|
|
48
|
+
expect(MockWS.instances).toHaveLength(2);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('stops retrying once the first frame arrives (real stream established)', () => {
|
|
52
|
+
const { result } = renderHook(() => useSession());
|
|
53
|
+
act(() => { result.current.attach('ws://x/attach', false, 'controller'); });
|
|
54
|
+
|
|
55
|
+
// Reach the second attempt, then this socket actually streams a frame.
|
|
56
|
+
act(() => { MockWS.instances[0].emit('open'); MockWS.instances[0].emit('close'); });
|
|
57
|
+
act(() => { vi.advanceTimersByTime(1300); });
|
|
58
|
+
const live = MockWS.instances[1];
|
|
59
|
+
act(() => {
|
|
60
|
+
live.emit('open');
|
|
61
|
+
live.emit('message', { data: JSON.stringify({ op: 'binding_hello' }) });
|
|
62
|
+
live.emit('message', { data: JSON.stringify({ op: 'output', seq: 1, payload: { data: btoa('hi') } }) });
|
|
63
|
+
});
|
|
64
|
+
// A later close after streaming must NOT spawn another socket.
|
|
65
|
+
act(() => { live.emit('close'); vi.advanceTimersByTime(5000); });
|
|
66
|
+
expect(MockWS.instances).toHaveLength(2);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('gives up after the retry budget and does not reconnect forever', () => {
|
|
70
|
+
const { result } = renderHook(() => useSession());
|
|
71
|
+
act(() => { result.current.attach('ws://x/attach', false, 'controller'); });
|
|
72
|
+
// Every attempt closes empty; after the budget it stops creating sockets.
|
|
73
|
+
for (let i = 0; i < 10; i += 1) {
|
|
74
|
+
act(() => { MockWS.instances[MockWS.instances.length - 1].emit('open'); MockWS.instances[MockWS.instances.length - 1].emit('close'); });
|
|
75
|
+
act(() => { vi.advanceTimersByTime(1300); });
|
|
76
|
+
}
|
|
77
|
+
// 1 initial + 6 retries = 7 sockets, then it stops.
|
|
78
|
+
expect(MockWS.instances.length).toBeLessThanOrEqual(7);
|
|
79
|
+
});
|
|
80
|
+
});
|
package/web/src/useSession.ts
CHANGED
|
@@ -22,6 +22,12 @@ export interface ResponseNeededState { needed: boolean; prompt: string; since: s
|
|
|
22
22
|
const RESIZE_FLOOR_COLS = 20;
|
|
23
23
|
const RESIZE_FLOOR_ROWS = 5;
|
|
24
24
|
|
|
25
|
+
// Retry-through-readiness window (#1669). A freshly-launched instance can accept
|
|
26
|
+
// the attach but stream 0 frames and close within ~2s while its PTY/tmux comes
|
|
27
|
+
// up; ~7s of reconnects rides past that without a hard error.
|
|
28
|
+
const MAX_READY_RETRIES = 6;
|
|
29
|
+
const READY_RETRY_MS = 1200;
|
|
30
|
+
|
|
25
31
|
const textEnc = new TextEncoder();
|
|
26
32
|
const textDec = new TextDecoder();
|
|
27
33
|
|
|
@@ -76,6 +82,14 @@ export function useSession() {
|
|
|
76
82
|
const roRef = useRef<ResizeObserver | null>(null);
|
|
77
83
|
const roleRef = useRef<Role>(null); // current role, read by term.onData without re-subscribing
|
|
78
84
|
const outputTailRef = useRef('');
|
|
85
|
+
// Retry-through-readiness state (#1669): a freshly-launched VM/container can
|
|
86
|
+
// accept the pty-ws attach, send 0 frames, and close within ~2s because the
|
|
87
|
+
// agent's PTY/tmux isn't streamable yet. Rather than show a hard
|
|
88
|
+
// [connection error], reconnect a few times until the first frame arrives.
|
|
89
|
+
const gotFrameRef = useRef(false); // any output/keyframe seen on the current attach
|
|
90
|
+
const retryRef = useRef(0); // reconnect attempts since the last user-initiated attach
|
|
91
|
+
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
92
|
+
const closedByUserRef = useRef(false); // detach()/new attach — suppress reconnect
|
|
79
93
|
const [state, setState] = useState<SessionState>({ attached: false, role: null, url: null });
|
|
80
94
|
const [responseNeeded, setResponseNeeded] = useState<ResponseNeededState>({ needed: false, prompt: '', since: null, source: 'pty' });
|
|
81
95
|
|
|
@@ -111,6 +125,9 @@ export function useSession() {
|
|
|
111
125
|
convertEol: false, // the PTY/tmux emits its own CR/LF
|
|
112
126
|
scrollback: 2000,
|
|
113
127
|
cursorBlink: false,
|
|
128
|
+
// Read-only until control is granted: observe must not capture keystrokes
|
|
129
|
+
// at all (not just drop them on send). Flipped to false on controller.
|
|
130
|
+
disableStdin: true,
|
|
114
131
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
115
132
|
fontSize: 13,
|
|
116
133
|
theme: { background: '#0a0c10', foreground: '#cdd3de' },
|
|
@@ -153,53 +170,101 @@ export function useSession() {
|
|
|
153
170
|
useEffect(() => () => {
|
|
154
171
|
try { roRef.current?.disconnect(); } catch { /* */ }
|
|
155
172
|
try { termRef.current?.dispose(); } catch { /* */ }
|
|
173
|
+
closedByUserRef.current = true;
|
|
174
|
+
if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; }
|
|
156
175
|
wsRef.current?.close();
|
|
157
176
|
}, []);
|
|
158
177
|
|
|
159
178
|
const attach = useCallback((url: string, replay = false, requestedRole: Exclude<Role, null> = 'observer') => {
|
|
179
|
+
if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; }
|
|
180
|
+
closedByUserRef.current = false;
|
|
181
|
+
retryRef.current = 0;
|
|
182
|
+
gotFrameRef.current = false;
|
|
160
183
|
wsRef.current?.close();
|
|
161
184
|
if (!replay) lastSeq.current = 0;
|
|
162
185
|
roleRef.current = null;
|
|
186
|
+
if (termRef.current) termRef.current.options.disableStdin = true; // read-only until role_assigned grants control
|
|
163
187
|
clearResponseNeeded();
|
|
164
188
|
outputTailRef.current = '';
|
|
165
189
|
setState({ attached: false, role: null, url });
|
|
166
190
|
if (!replay) { try { termRef.current?.reset(); } catch { /* */ } }
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
191
|
+
|
|
192
|
+
// Open (or re-open, on a readiness retry) the data-plane socket.
|
|
193
|
+
const connect = () => {
|
|
194
|
+
const ws = new WebSocket(replay ? `${url}?replay_from=${lastSeq.current}` : url);
|
|
195
|
+
wsRef.current = ws;
|
|
196
|
+
let gone = false; // a failing socket fires BOTH 'error' and 'close' — handle once
|
|
197
|
+
ws.addEventListener('open', () => setState((s) => ({ ...s, attached: true, url })));
|
|
198
|
+
const onGone = (kind: 'close' | 'error') => {
|
|
199
|
+
if (gone) return;
|
|
200
|
+
gone = true;
|
|
201
|
+
roleRef.current = null;
|
|
202
|
+
setState((s) => ({ ...s, attached: false, role: null }));
|
|
203
|
+
// Clean detach, or we were already streaming → leave it (a real end/drop).
|
|
204
|
+
if (closedByUserRef.current || gotFrameRef.current) return;
|
|
205
|
+
// Early empty close/error before the first frame → the agent's PTY isn't
|
|
206
|
+
// streamable yet. Reconnect through the readiness window rather than error.
|
|
207
|
+
if (retryRef.current < MAX_READY_RETRIES) {
|
|
208
|
+
retryRef.current += 1;
|
|
209
|
+
if (retryRef.current === 1) write(textEnc.encode('\r\n[waiting for session…]\r\n'));
|
|
210
|
+
retryTimerRef.current = setTimeout(connect, READY_RETRY_MS);
|
|
211
|
+
return;
|
|
182
212
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
213
|
+
void kind;
|
|
214
|
+
write(textEnc.encode('\r\n[connection error — session did not become ready]\r\n'));
|
|
215
|
+
};
|
|
216
|
+
ws.addEventListener('close', () => onGone('close'));
|
|
217
|
+
ws.addEventListener('error', () => onGone('error'));
|
|
218
|
+
ws.addEventListener('message', (ev) => {
|
|
219
|
+
let m: WsMsg;
|
|
220
|
+
try { m = JSON.parse(ev.data as string); } catch { return; }
|
|
221
|
+
switch (m.op) {
|
|
222
|
+
case 'binding_hello': {
|
|
223
|
+
sendOp('pty.join_session', { role: requestedRole });
|
|
224
|
+
// Tell the PTY our current dimensions up front so the first tmux redraw fits.
|
|
225
|
+
const t = termRef.current;
|
|
226
|
+
if (t && t.cols >= RESIZE_FLOOR_COLS && t.rows >= RESIZE_FLOOR_ROWS) sendOp('pty.session_resize', { cols: t.cols, rows: t.rows });
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
case 'role_assigned': {
|
|
230
|
+
const role = m.payload?.role ?? null;
|
|
231
|
+
roleRef.current = role;
|
|
232
|
+
setState((s) => ({ ...s, role }));
|
|
233
|
+
// Only a controller may type into the terminal; observers are read-only.
|
|
234
|
+
if (termRef.current) termRef.current.options.disableStdin = role !== 'controller';
|
|
235
|
+
// Re-attaching to an established session whose shell is idle gets no
|
|
236
|
+
// live output and the join replay carries no keyframe — request one so
|
|
237
|
+
// the current screen paints immediately instead of staying blank.
|
|
238
|
+
if (!gotFrameRef.current) sendOp('pty.request_keyframe');
|
|
239
|
+
requestAnimationFrame(() => fit());
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
case 'output':
|
|
243
|
+
gotFrameRef.current = true; retryRef.current = 0; // first frame → readiness reached
|
|
244
|
+
if (m.seq) lastSeq.current = Math.max(lastSeq.current, m.seq);
|
|
245
|
+
write(b64ToBytes(m.payload?.data ?? ''));
|
|
246
|
+
break;
|
|
247
|
+
case 'keyframe':
|
|
248
|
+
gotFrameRef.current = true; retryRef.current = 0;
|
|
249
|
+
for (const f of m.payload?.frames ?? []) { if (f.seq) lastSeq.current = Math.max(lastSeq.current, f.seq); write(b64ToBytes(f.payload.data)); }
|
|
250
|
+
break;
|
|
251
|
+
case 'error':
|
|
252
|
+
write(textEnc.encode(`\r\n[${m.payload?.code ?? 'error'}]\r\n`));
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
};
|
|
257
|
+
connect();
|
|
200
258
|
}, []);
|
|
201
259
|
|
|
202
|
-
const detach = useCallback(() => {
|
|
260
|
+
const detach = useCallback(() => {
|
|
261
|
+
closedByUserRef.current = true;
|
|
262
|
+
roleRef.current = null;
|
|
263
|
+
if (termRef.current) termRef.current.options.disableStdin = true; // detached → read-only
|
|
264
|
+
if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; }
|
|
265
|
+
wsRef.current?.close();
|
|
266
|
+
wsRef.current = null;
|
|
267
|
+
}, []);
|
|
203
268
|
const replay = useCallback((url: string, requestedRole?: Exclude<Role, null>) => {
|
|
204
269
|
const role = requestedRole ?? roleRef.current ?? 'observer';
|
|
205
270
|
detach();
|