@aiwg/cockpit 2026.6.11 → 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/README.md +8 -5
- package/bridge/src/public/index.html +8 -1
- package/bridge/src/server.mjs +69 -21
- package/package.json +2 -1
- package/web/src/App.test.tsx +45 -0
- package/web/src/App.tsx +18 -2
- package/web/src/components/Inventory.tsx +13 -3
- 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/README.md
CHANGED
|
@@ -382,11 +382,14 @@ Known state as of the 2026-06-19 host live run:
|
|
|
382
382
|
roctinam/agentic-sandbox#499 for upstream regression tracking, but it was not
|
|
383
383
|
reproduced by this isolated host proof.
|
|
384
384
|
- Docker/container secure bootstrap is fixed from the agentic-sandbox side in
|
|
385
|
-
`v2026.6.24`; roctinam/agentic-sandbox#497 is closed.
|
|
386
|
-
|
|
387
|
-
- VM bootstrap/readiness is fixed from the agentic-sandbox side
|
|
388
|
-
`v2026.6.
|
|
389
|
-
|
|
385
|
+
`v2026.6.24`; roctinam/agentic-sandbox#497 is closed. Re-validated Cockpit-side
|
|
386
|
+
against `v2026.6.34`: container matrix PASS.
|
|
387
|
+
- VM bootstrap/readiness is fixed from the agentic-sandbox side via the vsock
|
|
388
|
+
transport line (`v2026.6.31`–`v2026.6.34`); roctinam/agentic-sandbox#498 and
|
|
389
|
+
the #561 transport regression are closed. Re-validated Cockpit-side against
|
|
390
|
+
`v2026.6.34`: VM matrix PASS — provision → vsock enroll → boot-ready → provider
|
|
391
|
+
workload → clean destroy. Evidence:
|
|
392
|
+
`.aiwg/testing/cockpit-vm-vsock-2026-06-27.md/.json`.
|
|
390
393
|
- Remaining upstream follow-ups are Claude auth-state propagation
|
|
391
394
|
(roctinam/agentic-sandbox#499) and agent-scoped PTY sessions not appearing in
|
|
392
395
|
the formal/global session registry (roctinam/agentic-sandbox#500).
|
|
@@ -139,7 +139,14 @@
|
|
|
139
139
|
|
|
140
140
|
// shared: POST/DELETE a control-plane call, then re-run a loader
|
|
141
141
|
async function control(path, method, reload) {
|
|
142
|
-
try {
|
|
142
|
+
try {
|
|
143
|
+
const res = await api(path, { method });
|
|
144
|
+
const body = await res.json().catch(() => ({}));
|
|
145
|
+
if (!res.ok) throw new Error(`${path} → ${res.status}`);
|
|
146
|
+
if (body.already_gone) {
|
|
147
|
+
meta.textContent = body.message || 'Instance already removed; inventory refreshed.';
|
|
148
|
+
}
|
|
149
|
+
} catch (e) { alert(e.message); }
|
|
143
150
|
if (reload) reload();
|
|
144
151
|
}
|
|
145
152
|
|
package/bridge/src/server.mjs
CHANGED
|
@@ -369,7 +369,29 @@ async function destroyInstance(upstreamUrl, instanceId) {
|
|
|
369
369
|
}
|
|
370
370
|
return result;
|
|
371
371
|
}
|
|
372
|
-
} catch {
|
|
372
|
+
} catch (err) {
|
|
373
|
+
const message = String(err?.message ?? err);
|
|
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)) {
|
|
382
|
+
return {
|
|
383
|
+
target: `${upstreamUrl}/api/v2/admin/instances/${encodeURIComponent(instanceId)}/destroy`,
|
|
384
|
+
status: 200,
|
|
385
|
+
body: {
|
|
386
|
+
id: instanceId,
|
|
387
|
+
destroyed: instanceId,
|
|
388
|
+
state: 'destroyed',
|
|
389
|
+
result: { state: 'destroyed' },
|
|
390
|
+
already_gone: true,
|
|
391
|
+
message: `Instance ${instanceId} was already removed; inventory refreshed.`,
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|
|
373
395
|
// Current sandbox builds can list Docker rows in admin-v2 inventory while
|
|
374
396
|
// lifecycle verbs return instance.not_found. Fall through to a dev cleanup.
|
|
375
397
|
}
|
|
@@ -382,7 +404,18 @@ async function destroyInstance(upstreamUrl, instanceId) {
|
|
|
382
404
|
};
|
|
383
405
|
}
|
|
384
406
|
|
|
385
|
-
|
|
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
|
+
}
|
|
386
419
|
return {
|
|
387
420
|
target: `docker rm -f ${dockerName}`,
|
|
388
421
|
status: 200,
|
|
@@ -392,6 +425,8 @@ async function destroyInstance(upstreamUrl, instanceId) {
|
|
|
392
425
|
runtime,
|
|
393
426
|
state: 'destroyed',
|
|
394
427
|
result: { state: 'destroyed' },
|
|
428
|
+
already_gone: alreadyGone,
|
|
429
|
+
message: alreadyGone ? `Container ${dockerName} was already removed; inventory refreshed.` : undefined,
|
|
395
430
|
fallback: 'docker-cli-after-admin-v2-instance-not-found',
|
|
396
431
|
},
|
|
397
432
|
};
|
|
@@ -457,7 +492,7 @@ function normalizeRuntimePosture(kind) {
|
|
|
457
492
|
label: 'Container / shared kernel',
|
|
458
493
|
warning: 'Container isolation shares the host kernel.',
|
|
459
494
|
};
|
|
460
|
-
if (runtime === 'vm') return { kind:
|
|
495
|
+
if (runtime === 'vm' || runtime === 'qemu' || runtime === 'kvm') return { kind: 'vm', isolation: 'strong', label: 'VM / hardware boundary' };
|
|
461
496
|
if (runtime === 'unknown') return { kind: runtime, isolation: 'unknown', label: 'Unknown runtime', warning: 'Runtime metadata was not reported by the sandbox.' };
|
|
462
497
|
return {
|
|
463
498
|
kind: runtime,
|
|
@@ -510,7 +545,7 @@ function normalizeSessionBackends(backends, runtimeKind, state = 'unknown', agen
|
|
|
510
545
|
if (!list.length && runtimeKind === 'host') {
|
|
511
546
|
return [{ mode: 'managed', backend: 'tmux', observe: true, drive: true, replay: false, keyframe: false, available: true, reason: 'agentic-sandbox v1 host session API default' }];
|
|
512
547
|
}
|
|
513
|
-
if (!list.length && ['docker', 'container', 'vm'].includes(runtimeKind) && String(state).toLowerCase() === 'running') {
|
|
548
|
+
if (!list.length && ['docker', 'container', 'vm', 'qemu', 'kvm'].includes(runtimeKind) && String(state).toLowerCase() === 'running') {
|
|
514
549
|
return [{
|
|
515
550
|
mode: 'managed',
|
|
516
551
|
backend: 'tmux',
|
|
@@ -938,22 +973,34 @@ async function getSessions(executorUrl, instanceId) {
|
|
|
938
973
|
return u.toString();
|
|
939
974
|
} catch { /* fall through to legacy shape */ }
|
|
940
975
|
}
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
...s,
|
|
949
|
-
id: sessionId,
|
|
950
|
-
instance_id: s.instance_id ?? s.instanceId ?? instanceId,
|
|
951
|
-
agent_id: s.agent_id ?? s.agentId ?? sessionAgentId,
|
|
952
|
-
role_policy: s.role_policy ?? s.rolePolicy ?? (s.default_role === 'observer' ? 'observe-default' : s.default_role) ?? 'observe-default',
|
|
953
|
-
attach_url: normalizeAttachUrl(s, sessionId),
|
|
954
|
-
};
|
|
955
|
-
}),
|
|
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`;
|
|
956
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 };
|
|
957
1004
|
}
|
|
958
1005
|
|
|
959
1006
|
async function endSession(executorUrl, instanceId, sessionId) {
|
|
@@ -1166,7 +1213,6 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
|
|
|
1166
1213
|
session_class: mode || 'managed',
|
|
1167
1214
|
command: 'bash',
|
|
1168
1215
|
args: ['-l'],
|
|
1169
|
-
working_dir: '/root',
|
|
1170
1216
|
}),
|
|
1171
1217
|
},
|
|
1172
1218
|
{
|
|
@@ -1198,7 +1244,9 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
|
|
|
1198
1244
|
attachUrl = u.toString();
|
|
1199
1245
|
} catch { /* fall through to legacy shape */ }
|
|
1200
1246
|
}
|
|
1201
|
-
|
|
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` });
|
|
1202
1250
|
}
|
|
1203
1251
|
|
|
1204
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",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"scripts": {
|
|
40
40
|
"start:bridge": "node bridge/src/server.mjs",
|
|
41
41
|
"dev": "bash scripts/cockpit-dev.sh",
|
|
42
|
+
"up": "bash scripts/cockpit-up.sh",
|
|
42
43
|
"build:web": "npm --prefix web install --no-audit --no-fund && npm --prefix web run build",
|
|
43
44
|
"pack:dry": "npm pack --dry-run",
|
|
44
45
|
"publish:dry": "npm publish --dry-run --access public",
|
package/web/src/App.test.tsx
CHANGED
|
@@ -195,6 +195,51 @@ describe('App shell (rendered DOM)', () => {
|
|
|
195
195
|
expect(screen.getByText('Destination')).toBeTruthy();
|
|
196
196
|
});
|
|
197
197
|
|
|
198
|
+
it('treats stale destroy 404 responses as already removed in Inventory (#1660)', async () => {
|
|
199
|
+
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
|
200
|
+
const inventory = { instances: [instance('ghost-vm-1', 'vm', 'QEMU Codex')], count: 1, fetched_at: new Date().toISOString() };
|
|
201
|
+
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
202
|
+
const url = String(input);
|
|
203
|
+
if (url.includes('/api/health')) return jsonResponse({ executor_url: 'http://127.0.0.1:8122' });
|
|
204
|
+
if (url.includes('/api/inventory')) return jsonResponse(inventory);
|
|
205
|
+
if (url.includes('/api/running')) return jsonResponse({ count: 0, running: [] });
|
|
206
|
+
if (url.includes('/api/approvals')) return jsonResponse({ approvals: [] });
|
|
207
|
+
if (url.includes('/api/cost')) return jsonResponse({ total: { input_tokens: 0, output_tokens: 0, usd: 0 }, per_instance: [] });
|
|
208
|
+
if (url.includes('/api/instances/ghost-vm-1') && init?.method === 'DELETE') {
|
|
209
|
+
return jsonResponse({ destroyed: 'ghost-vm-1', already_gone: true, message: 'Instance ghost-vm-1 was already removed; inventory refreshed.' });
|
|
210
|
+
}
|
|
211
|
+
return jsonResponse({});
|
|
212
|
+
}) as typeof fetch;
|
|
213
|
+
|
|
214
|
+
render(<App />);
|
|
215
|
+
fireEvent.click(screen.getByRole('tab', { name: 'Inventory' }));
|
|
216
|
+
fireEvent.click(await screen.findByRole('button', { name: /destroy instance ghost-vm-1/i }));
|
|
217
|
+
|
|
218
|
+
expect((await screen.findByRole('status')).textContent).toMatch(/already removed; inventory refreshed/i);
|
|
219
|
+
expect(screen.queryByText(/action failed/i)).toBeNull();
|
|
220
|
+
});
|
|
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
|
+
|
|
198
243
|
it('each tab has a matching labelled tabpanel (controls/labelledby pairing)', () => {
|
|
199
244
|
render(<App />);
|
|
200
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) {
|
|
@@ -9,6 +9,7 @@ export function Inventory({ onStartSession, onLaunchInstance }: { onStartSession
|
|
|
9
9
|
const [data, setData] = useState<Inv | null>(null);
|
|
10
10
|
const [err, setErr] = useState('');
|
|
11
11
|
const [actionErr, setActionErr] = useState('');
|
|
12
|
+
const [actionMsg, setActionMsg] = useState('');
|
|
12
13
|
|
|
13
14
|
const load = useCallback(() => {
|
|
14
15
|
api<Inv>('/api/inventory').then((d) => { setData(d); setErr(''); }).catch((e) => setErr((e as Error).message));
|
|
@@ -16,7 +17,16 @@ export function Inventory({ onStartSession, onLaunchInstance }: { onStartSession
|
|
|
16
17
|
useEffect(() => { load(); }, [load]);
|
|
17
18
|
|
|
18
19
|
const control = (path: string, method: string) =>
|
|
19
|
-
api
|
|
20
|
+
api<{ already_gone?: boolean; message?: string }>(path, { method })
|
|
21
|
+
.then((result) => {
|
|
22
|
+
setActionErr('');
|
|
23
|
+
setActionMsg(result.already_gone ? (result.message ?? 'Instance already removed; inventory refreshed.') : '');
|
|
24
|
+
load();
|
|
25
|
+
})
|
|
26
|
+
.catch((e) => {
|
|
27
|
+
setActionMsg('');
|
|
28
|
+
setActionErr((e as Error).message);
|
|
29
|
+
});
|
|
20
30
|
|
|
21
31
|
if (err) return <p className="err">Could not load inventory: {err}</p>;
|
|
22
32
|
if (!data) return <p className="empty">Loading…</p>;
|
|
@@ -40,6 +50,7 @@ export function Inventory({ onStartSession, onLaunchInstance }: { onStartSession
|
|
|
40
50
|
{onLaunchInstance && <button className="cta" onClick={onLaunchInstance}>+ New instance + session</button>}
|
|
41
51
|
</div>
|
|
42
52
|
{actionErr && <p className="err">Action failed: {actionErr}</p>}
|
|
53
|
+
{actionMsg && <p className="hint" role="status">{actionMsg}</p>}
|
|
43
54
|
<table>
|
|
44
55
|
<caption>Available instance deployments</caption>
|
|
45
56
|
<thead>
|
|
@@ -105,8 +116,7 @@ export function Inventory({ onStartSession, onLaunchInstance }: { onStartSession
|
|
|
105
116
|
: <button aria-label={`Start instance ${fmtId(i.id)}`} onClick={() => control(`/api/instances/${encodeURIComponent(i.id)}/start`, 'POST')}>Start Instance</button>}{' '}
|
|
106
117
|
<button
|
|
107
118
|
aria-label={`Destroy instance ${fmtId(i.id)}`}
|
|
108
|
-
|
|
109
|
-
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}
|
|
110
120
|
onClick={() => { if (confirm(`Destroy ${fmtId(i.id)}? This cannot be undone.`)) control(`/api/instances/${encodeURIComponent(i.id)}`, 'DELETE'); }}
|
|
111
121
|
>
|
|
112
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();
|