@aiwg/cockpit 2026.6.3
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 +337 -0
- package/bridge/package.json +13 -0
- package/bridge/src/public/index.html +395 -0
- package/bridge/src/server.mjs +1132 -0
- package/bridge/src/smoke.mjs +158 -0
- package/contrib/aiwg-core.json +15 -0
- package/contrib/contribution.schema.json +69 -0
- package/desktop/README.md +42 -0
- package/desktop/src-tauri/Cargo.toml +17 -0
- package/desktop/src-tauri/build.rs +3 -0
- package/desktop/src-tauri/frontend/index.html +11 -0
- package/desktop/src-tauri/src/main.rs +55 -0
- package/desktop/src-tauri/tauri.conf.json +20 -0
- package/package.json +49 -0
- package/runtime-docs/README.md +38 -0
- package/shell-core/runtime.mjs +45 -0
- package/shell-core/smoke.mjs +36 -0
- package/vscode/README.md +25 -0
- package/vscode/extension.js +59 -0
- package/vscode/package.json +29 -0
- package/web/dist/assets/index-B5anpdS1.js +67 -0
- package/web/dist/assets/index-CP3BF6uZ.css +32 -0
- package/web/dist/index.html +14 -0
- package/web/index.html +13 -0
- package/web/package.json +31 -0
- package/web/src/App.test.tsx +237 -0
- package/web/src/App.tsx +255 -0
- package/web/src/api.ts +20 -0
- package/web/src/components/Actions.tsx +60 -0
- package/web/src/components/Approvals.tsx +62 -0
- package/web/src/components/CapabilitySearch.tsx +73 -0
- package/web/src/components/Explore.tsx +41 -0
- package/web/src/components/Inventory.tsx +124 -0
- package/web/src/components/LaunchInstanceModal.test.tsx +97 -0
- package/web/src/components/LaunchInstanceModal.tsx +236 -0
- package/web/src/components/Library.tsx +76 -0
- package/web/src/components/Running.tsx +60 -0
- package/web/src/components/Sessions.test.tsx +125 -0
- package/web/src/components/Sessions.tsx +189 -0
- package/web/src/components/StartSessionModal.test.tsx +86 -0
- package/web/src/components/StartSessionModal.tsx +168 -0
- package/web/src/components/Welcome.tsx +474 -0
- package/web/src/main.tsx +11 -0
- package/web/src/styles.css +254 -0
- package/web/src/types.ts +47 -0
- package/web/src/useDebounce.ts +10 -0
- package/web/src/useSession.ts +220 -0
- package/web/src/util.test.ts +30 -0
- package/web/src/util.ts +17 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { api } from '../api';
|
|
3
|
+
import { fmtId } from '../util';
|
|
4
|
+
import type { Instance, Loadout } from '../types';
|
|
5
|
+
|
|
6
|
+
type Runtime = 'host' | 'docker' | 'qemu';
|
|
7
|
+
|
|
8
|
+
const DOCKER_IMAGE_OPTIONS = [
|
|
9
|
+
{ value: 'agentic/codex:latest', label: 'Codex' },
|
|
10
|
+
{ value: 'agentic/claude:latest', label: 'Claude' },
|
|
11
|
+
{ value: 'agentic/opencode:latest', label: 'OpenCode' },
|
|
12
|
+
{ value: 'agentic/automation-control:latest', label: 'Automation control' },
|
|
13
|
+
{ value: 'agentic/agent:dev', label: 'Agent dev base' },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const FALLBACK_LOADOUTS: Loadout[] = [
|
|
17
|
+
{ id: 'host-tools', label: 'host-tools', description: 'Host tools', runtimes: ['host'] },
|
|
18
|
+
{ id: 'agentic-dev', label: 'agentic-dev', description: 'Full development environment', runtimes: ['docker', 'container', 'qemu', 'vm'] },
|
|
19
|
+
{ id: 'claude-only', label: 'claude-only', description: 'Claude provider loadout', runtimes: ['docker', 'container', 'qemu', 'vm'] },
|
|
20
|
+
{ id: 'codex-only', label: 'codex-only', description: 'Codex provider loadout', runtimes: ['docker', 'container', 'qemu', 'vm'] },
|
|
21
|
+
{ id: 'opencode-only', label: 'opencode-only', description: 'OpenCode provider loadout', runtimes: ['docker', 'container', 'qemu', 'vm'] },
|
|
22
|
+
{ id: 'full-suite', label: 'full-suite', description: 'Multi-provider tool suite', runtimes: ['qemu', 'vm'] },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export function LaunchInstanceModal({
|
|
26
|
+
open,
|
|
27
|
+
onClose,
|
|
28
|
+
onLaunched,
|
|
29
|
+
}: {
|
|
30
|
+
open: boolean;
|
|
31
|
+
onClose: () => void;
|
|
32
|
+
onLaunched: (instanceId?: string, openSession?: boolean, operationId?: string) => Promise<void> | void;
|
|
33
|
+
}) {
|
|
34
|
+
const [runtime, setRuntime] = useState<Runtime>('host');
|
|
35
|
+
const [name, setName] = useState(() => `cockpit-${Date.now().toString(36)}`.slice(0, 32));
|
|
36
|
+
const [loadout, setLoadout] = useState('host-tools');
|
|
37
|
+
const [loadouts, setLoadouts] = useState<Loadout[]>([]);
|
|
38
|
+
const [instances, setInstances] = useState<Instance[]>([]);
|
|
39
|
+
const [hostId, setHostId] = useState('');
|
|
40
|
+
const [image, setImage] = useState('agentic/codex:latest');
|
|
41
|
+
const [customImage, setCustomImage] = useState('');
|
|
42
|
+
const [profile, setProfile] = useState('');
|
|
43
|
+
const [sshKey, setSshKey] = useState('');
|
|
44
|
+
const [mounts, setMounts] = useState('');
|
|
45
|
+
const [openSession, setOpenSession] = useState(true);
|
|
46
|
+
const [busy, setBusy] = useState(false);
|
|
47
|
+
const [err, setErr] = useState('');
|
|
48
|
+
const [result, setResult] = useState('');
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!open) return;
|
|
52
|
+
let cancelled = false;
|
|
53
|
+
Promise.all([
|
|
54
|
+
api<{ loadouts: Loadout[] }>('/api/loadouts').catch(() => ({ loadouts: [] as Loadout[] })),
|
|
55
|
+
api<{ instances: Instance[] }>('/api/inventory').catch(() => ({ instances: [] as Instance[] })),
|
|
56
|
+
])
|
|
57
|
+
.then(([lo, inv]) => {
|
|
58
|
+
if (cancelled) return;
|
|
59
|
+
setLoadouts(lo.loadouts ?? []);
|
|
60
|
+
setInstances(inv.instances ?? []);
|
|
61
|
+
const firstHost = (inv.instances ?? []).find(isUsableHost);
|
|
62
|
+
setHostId((current) => current || firstHost?.id || '');
|
|
63
|
+
});
|
|
64
|
+
return () => { cancelled = true; };
|
|
65
|
+
}, [open]);
|
|
66
|
+
|
|
67
|
+
if (!open) return null;
|
|
68
|
+
|
|
69
|
+
const chooseRuntime = (next: Runtime) => {
|
|
70
|
+
setRuntime(next);
|
|
71
|
+
if (next === 'host') {
|
|
72
|
+
setLoadout('host-tools');
|
|
73
|
+
} else if (next === 'docker') {
|
|
74
|
+
setLoadout('agentic-dev');
|
|
75
|
+
setImage((current) => current || 'agentic/codex:latest');
|
|
76
|
+
} else {
|
|
77
|
+
setLoadout('profiles/basic.yaml');
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const launch = async () => {
|
|
82
|
+
setBusy(true); setErr(''); setResult('');
|
|
83
|
+
try {
|
|
84
|
+
if (runtime === 'host') {
|
|
85
|
+
const host = hostTargets(instances).find((i) => i.id === hostId) ?? hostTargets(instances)[0];
|
|
86
|
+
if (!host) throw new Error('No registered host target is available. Start/register the host agent first, or choose Docker container.');
|
|
87
|
+
setResult(openSession
|
|
88
|
+
? `Using host target ${host.launch_context?.name ?? fmtId(host.id)}; starting session...`
|
|
89
|
+
: `Using host target ${host.launch_context?.name ?? fmtId(host.id)}`);
|
|
90
|
+
await onLaunched(host.id, openSession);
|
|
91
|
+
if (openSession) onClose();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const body: Record<string, unknown> = {
|
|
95
|
+
name: name.replace(/[^a-z0-9-]/g, '-').replace(/^-+/, 'a-').slice(0, 63),
|
|
96
|
+
runtime,
|
|
97
|
+
start: true,
|
|
98
|
+
};
|
|
99
|
+
if (loadout) body.loadout = loadout;
|
|
100
|
+
if (profile) body.profile = profile;
|
|
101
|
+
if (runtime === 'docker') {
|
|
102
|
+
body.image = image === '__custom__' ? customImage.trim() : image;
|
|
103
|
+
body.agentshare = true;
|
|
104
|
+
}
|
|
105
|
+
if (runtime === 'qemu' && sshKey.trim()) body.ssh_key = sshKey.trim();
|
|
106
|
+
if (runtime === 'docker' && mounts.trim()) body.mounts = mounts.split('\n').map((m) => m.trim()).filter(Boolean);
|
|
107
|
+
const op = await api<{ id?: string; instance_id?: string; instanceId?: string; operation?: { id?: string }; result?: { instance_id?: string; instanceId?: string } }>('/api/instances', {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: { 'content-type': 'application/json' },
|
|
110
|
+
body: JSON.stringify(body),
|
|
111
|
+
});
|
|
112
|
+
const instanceId = op.instance_id ?? op.instanceId ?? op.result?.instance_id ?? op.result?.instanceId;
|
|
113
|
+
const operationId = op.id ?? op.operation?.id;
|
|
114
|
+
setResult(openSession
|
|
115
|
+
? `Launch accepted: ${instanceId ?? operationId ?? 'operation pending'}; waiting for session...`
|
|
116
|
+
: `Launch accepted: ${instanceId ?? operationId ?? 'operation pending'}`);
|
|
117
|
+
await onLaunched(instanceId, openSession, operationId);
|
|
118
|
+
if (openSession) onClose();
|
|
119
|
+
} catch (e) {
|
|
120
|
+
setErr((e as Error).message);
|
|
121
|
+
} finally {
|
|
122
|
+
setBusy(false);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div className="modal-backdrop" role="presentation" onClick={onClose}>
|
|
128
|
+
<div className="modal" role="dialog" aria-modal="true" aria-labelledby="launch-instance-title" onClick={(e) => e.stopPropagation()}>
|
|
129
|
+
<h2 id="launch-instance-title">New instance</h2>
|
|
130
|
+
<p className="hint">Create a runtime target. Existing instances and sessions keep running.</p>
|
|
131
|
+
{err && <p className="err">{err}</p>}
|
|
132
|
+
{result && <p className="hint ok-text">{result}</p>}
|
|
133
|
+
<div className="form-grid">
|
|
134
|
+
<label htmlFor="li-runtime">Runtime</label>
|
|
135
|
+
<select id="li-runtime" value={runtime} onChange={(e) => chooseRuntime(e.target.value as Runtime)}>
|
|
136
|
+
<option value="host">Host</option>
|
|
137
|
+
<option value="docker">Docker container</option>
|
|
138
|
+
<option value="qemu">VM / QEMU</option>
|
|
139
|
+
</select>
|
|
140
|
+
{runtime === 'host' ? (
|
|
141
|
+
<>
|
|
142
|
+
<label htmlFor="li-host-target">Host target</label>
|
|
143
|
+
<select id="li-host-target" value={hostId} onChange={(e) => setHostId(e.target.value)}>
|
|
144
|
+
{hostTargets(instances).map((i) => <option key={i.id} value={i.id}>{i.launch_context?.name ?? fmtId(i.id)} - {i.loadout}</option>)}
|
|
145
|
+
{!hostTargets(instances).length && <option value="">No registered host available</option>}
|
|
146
|
+
</select>
|
|
147
|
+
</>
|
|
148
|
+
) : (
|
|
149
|
+
<>
|
|
150
|
+
<label htmlFor="li-name">Name</label>
|
|
151
|
+
<input id="li-name" value={name} onChange={(e) => setName(e.target.value)} />
|
|
152
|
+
</>
|
|
153
|
+
)}
|
|
154
|
+
<label htmlFor="li-loadout">Instance loadout</label>
|
|
155
|
+
{runtime === 'host' ? (
|
|
156
|
+
<span className="ro">host-tools</span>
|
|
157
|
+
) : (
|
|
158
|
+
<select id="li-loadout" value={loadout} onChange={(e) => setLoadout(e.target.value)}>
|
|
159
|
+
{loadoutOptions(loadouts, runtime).map((l) => <option key={l.id} value={l.id}>{l.label}{l.description ? ` - ${l.description}` : ''}</option>)}
|
|
160
|
+
</select>
|
|
161
|
+
)}
|
|
162
|
+
{runtime !== 'host' && (
|
|
163
|
+
<>
|
|
164
|
+
<label htmlFor="li-profile">Profile</label>
|
|
165
|
+
<input id="li-profile" value={profile} onChange={(e) => setProfile(e.target.value)} placeholder="optional" />
|
|
166
|
+
</>
|
|
167
|
+
)}
|
|
168
|
+
{runtime === 'qemu' && (
|
|
169
|
+
<>
|
|
170
|
+
<label htmlFor="li-ssh-key">SSH public key</label>
|
|
171
|
+
<input
|
|
172
|
+
id="li-ssh-key"
|
|
173
|
+
value={sshKey}
|
|
174
|
+
onChange={(e) => setSshKey(e.target.value)}
|
|
175
|
+
placeholder="auto-detect, or ~/.ssh/agentic_ed25519.pub"
|
|
176
|
+
/>
|
|
177
|
+
</>
|
|
178
|
+
)}
|
|
179
|
+
{runtime === 'docker' && (
|
|
180
|
+
<>
|
|
181
|
+
<label htmlFor="li-image">Container image</label>
|
|
182
|
+
<div className="field-stack">
|
|
183
|
+
<select id="li-image" value={image} onChange={(e) => setImage(e.target.value)}>
|
|
184
|
+
{DOCKER_IMAGE_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label} - {option.value}</option>)}
|
|
185
|
+
<option value="__custom__">Custom image...</option>
|
|
186
|
+
</select>
|
|
187
|
+
{image === '__custom__' && (
|
|
188
|
+
<input
|
|
189
|
+
aria-label="Custom container image"
|
|
190
|
+
value={customImage}
|
|
191
|
+
onChange={(e) => setCustomImage(e.target.value)}
|
|
192
|
+
placeholder="registry.example.com/team/agent:tag"
|
|
193
|
+
/>
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
<label htmlFor="li-mounts">Mounts</label>
|
|
197
|
+
<textarea id="li-mounts" value={mounts} onChange={(e) => setMounts(e.target.value)} placeholder="/host/path:/container/path" />
|
|
198
|
+
</>
|
|
199
|
+
)}
|
|
200
|
+
<label htmlFor="li-open-session">After launch</label>
|
|
201
|
+
<label className="check-row">
|
|
202
|
+
<input id="li-open-session" type="checkbox" checked={openSession} onChange={(e) => setOpenSession(e.target.checked)} />
|
|
203
|
+
Start a session automatically when the instance is ready
|
|
204
|
+
</label>
|
|
205
|
+
</div>
|
|
206
|
+
<div className="modal-actions">
|
|
207
|
+
<button onClick={onClose} disabled={busy}>Close</button>
|
|
208
|
+
<button className="cta" onClick={launch} disabled={busy || (runtime === 'host' && !hostId) || (runtime !== 'host' && !name.trim()) || (runtime === 'docker' && image === '__custom__' && !customImage.trim())}>
|
|
209
|
+
{busy ? 'Working...' : runtime === 'host' ? (openSession ? 'Start host session' : 'Use host') : openSession ? 'Create + start session' : 'Create instance'}
|
|
210
|
+
</button>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function loadoutOptions(loadouts: Loadout[], runtime: Runtime) {
|
|
218
|
+
if (runtime === 'host') return FALLBACK_LOADOUTS.filter((l) => l.id === 'host-tools');
|
|
219
|
+
const aliases = runtime === 'docker' ? ['docker', 'container'] : runtime === 'qemu' ? ['qemu', 'vm'] : [runtime];
|
|
220
|
+
const merged = [...loadouts, ...FALLBACK_LOADOUTS].reduce<Loadout[]>((acc, loadout) => {
|
|
221
|
+
if (!acc.some((existing) => existing.id === loadout.id)) acc.push(loadout);
|
|
222
|
+
return acc;
|
|
223
|
+
}, []);
|
|
224
|
+
const matching = merged.filter((l) => !l.runtimes?.length || l.runtimes.some((r) => aliases.includes(String(r).toLowerCase())));
|
|
225
|
+
return matching.length ? matching : merged;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function hostTargets(instances: Instance[]) {
|
|
229
|
+
return instances.filter(isUsableHost);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function isUsableHost(instance: Instance) {
|
|
233
|
+
return instance.state === 'running'
|
|
234
|
+
&& instance.runtime_posture?.kind === 'host'
|
|
235
|
+
&& instance.session_backends?.some((backend) => backend.available !== false);
|
|
236
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import { api } from '../api';
|
|
3
|
+
import { capRef } from '../util';
|
|
4
|
+
import { CapabilitySearch } from './CapabilitySearch';
|
|
5
|
+
import type { LibraryAsset, CapabilityResult } from '../types';
|
|
6
|
+
import type { SessionApi } from '../useSession';
|
|
7
|
+
|
|
8
|
+
// The user's OWN asset library — copy/clone/import. AIWG install files are never
|
|
9
|
+
// written. Deploying/using an asset is an agent action (Use → inject into a session).
|
|
10
|
+
export function Library({ session, setComposer, goSessions }: { session: SessionApi; setComposer: (v: string) => void; goSessions: () => void }) {
|
|
11
|
+
const [items, setItems] = useState<LibraryAsset[] | null>(null);
|
|
12
|
+
const [cloning, setCloning] = useState(false);
|
|
13
|
+
const [err, setErr] = useState('');
|
|
14
|
+
|
|
15
|
+
const load = useCallback(() => {
|
|
16
|
+
api<{ library: LibraryAsset[] }>('/api/library').then((d) => { setItems(d.library); setErr(''); }).catch((e) => setErr((e as Error).message));
|
|
17
|
+
}, []);
|
|
18
|
+
useEffect(() => { load(); }, [load]);
|
|
19
|
+
|
|
20
|
+
const clone = async (r: CapabilityResult) => {
|
|
21
|
+
try {
|
|
22
|
+
await api(`/api/library/clone?type=${encodeURIComponent(r.type)}&name=${encodeURIComponent(r.name)}&path=${encodeURIComponent(r.path)}`, { method: 'POST' });
|
|
23
|
+
setCloning(false); load();
|
|
24
|
+
} catch (e) { setErr((e as Error).message); }
|
|
25
|
+
};
|
|
26
|
+
const remove = (a: LibraryAsset) => {
|
|
27
|
+
if (!confirm(`Remove "${a.name}" from your library? (Your copy only — AIWG is untouched.)`)) return;
|
|
28
|
+
api(`/api/library/${encodeURIComponent(a.name)}`, { method: 'DELETE' }).then(load).catch((e) => alert((e as Error).message));
|
|
29
|
+
};
|
|
30
|
+
const use = (a: LibraryAsset) => {
|
|
31
|
+
const cmd = capRef(a.type, a.name.replace(/\.(md|markdown|ya?ml|json)$/i, ''));
|
|
32
|
+
if (!(session.isController && session.sendInput(cmd))) setComposer(cmd);
|
|
33
|
+
goSessions();
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<>
|
|
38
|
+
<p className="hint">
|
|
39
|
+
Your <strong>own</strong> assets — copied, cloned, or imported. Edit them freely; <strong>AIWG install files are never
|
|
40
|
+
touched</strong>. Stored on disk under <code>~/.aiwg/cockpit/library</code>. Deploying one into a project is an agent
|
|
41
|
+
action (Use → inject into a session).
|
|
42
|
+
</p>
|
|
43
|
+
{err && <p className="err">{err}</p>}
|
|
44
|
+
<div className="controls">
|
|
45
|
+
<button onClick={() => setCloning((v) => !v)}>{cloning ? 'Close' : '+ Clone from catalog'}</button>
|
|
46
|
+
</div>
|
|
47
|
+
{cloning && (
|
|
48
|
+
<div className="picker">
|
|
49
|
+
<p className="hint" style={{ marginTop: 0 }}>Search the AIWG catalog and clone a copy into your library.</p>
|
|
50
|
+
<CapabilitySearch compact autoFocus onPick={clone} />
|
|
51
|
+
</div>
|
|
52
|
+
)}
|
|
53
|
+
{!items ? <p className="empty">Loading…</p>
|
|
54
|
+
: !items.length ? <p className="empty">Your library is empty. Clone a capability from the catalog to get a copy you can modify.</p>
|
|
55
|
+
: (
|
|
56
|
+
<table>
|
|
57
|
+
<caption>{items.length} asset(s) in your library</caption>
|
|
58
|
+
<thead><tr><th scope="col">Name</th><th scope="col">Type</th><th scope="col">Origin</th><th scope="col">Actions</th></tr></thead>
|
|
59
|
+
<tbody>
|
|
60
|
+
{items.map((a) => (
|
|
61
|
+
<tr key={a.name}>
|
|
62
|
+
<td><strong>{a.name}</strong></td>
|
|
63
|
+
<td><span className="badge">{a.type}</span></td>
|
|
64
|
+
<td>{a.origin}</td>
|
|
65
|
+
<td style={{ whiteSpace: 'nowrap' }}>
|
|
66
|
+
<button onClick={() => use(a)}>Use</button>{' '}
|
|
67
|
+
<button onClick={() => remove(a)} aria-label={`Remove ${a.name}`}>Remove</button>
|
|
68
|
+
</td>
|
|
69
|
+
</tr>
|
|
70
|
+
))}
|
|
71
|
+
</tbody>
|
|
72
|
+
</table>
|
|
73
|
+
)}
|
|
74
|
+
</>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import { api } from '../api';
|
|
3
|
+
import { fmtId } from '../util';
|
|
4
|
+
import type { RunningTask, Cost } from '../types';
|
|
5
|
+
|
|
6
|
+
interface Run { count: number; running: RunningTask[] }
|
|
7
|
+
|
|
8
|
+
export function Running() {
|
|
9
|
+
const [run, setRun] = useState<Run | null>(null);
|
|
10
|
+
const [cost, setCost] = useState<Cost | null>(null);
|
|
11
|
+
const [err, setErr] = useState('');
|
|
12
|
+
|
|
13
|
+
const load = useCallback(() => {
|
|
14
|
+
api<Run>('/api/running').then((d) => { setRun(d); setErr(''); }).catch((e) => setErr((e as Error).message));
|
|
15
|
+
api<Cost>('/api/cost').then(setCost).catch(() => setCost(null));
|
|
16
|
+
}, []);
|
|
17
|
+
useEffect(() => { load(); }, [load]);
|
|
18
|
+
|
|
19
|
+
const stop = (t: RunningTask) =>
|
|
20
|
+
api(`/api/tasks/${encodeURIComponent(t.instance_id)}/${encodeURIComponent(t.task_id)}/cancel`, { method: 'POST' })
|
|
21
|
+
.then(load).catch((e) => alert((e as Error).message));
|
|
22
|
+
|
|
23
|
+
if (err) return <p className="err">Could not load running: {err}</p>;
|
|
24
|
+
if (!run) return <p className="empty">Loading…</p>;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<>
|
|
28
|
+
<p className="hint">
|
|
29
|
+
<strong>Fleet overview.</strong> Every task running across all stacks — a read-only board. To
|
|
30
|
+
attach to one and observe or drive it, open it in <strong>Sessions</strong> (the attached workspace).
|
|
31
|
+
</p>
|
|
32
|
+
{cost && (
|
|
33
|
+
<p className="hint">Spend across stacks: <strong>${cost.total.usd.toFixed(2)}</strong> · {(cost.total.input_tokens + cost.total.output_tokens).toLocaleString()} tokens</p>
|
|
34
|
+
)}
|
|
35
|
+
{!run.running.length
|
|
36
|
+
? <p className="empty">Nothing running yet — use “Start a session” (top right or Home) to put an agent to work.</p>
|
|
37
|
+
: (
|
|
38
|
+
<table>
|
|
39
|
+
<caption>Running across all stacks — {run.count} task(s)</caption>
|
|
40
|
+
<thead>
|
|
41
|
+
<tr><th scope="col">Instance</th><th scope="col">Runtime</th><th scope="col">Transport</th><th scope="col">Task</th><th scope="col">State</th><th scope="col">Tenant</th><th scope="col">Control</th></tr>
|
|
42
|
+
</thead>
|
|
43
|
+
<tbody>
|
|
44
|
+
{run.running.map((t) => (
|
|
45
|
+
<tr key={t.task_id}>
|
|
46
|
+
<td><code title={t.instance_id}>{fmtId(t.instance_id)}</code></td>
|
|
47
|
+
<td>{t.runtime_posture ? <span className={`badge isolation-${t.runtime_posture.isolation}`} title={t.runtime_posture.warning || t.runtime_posture.label}>{t.runtime_posture.label}</span> : <span className="badge">unknown</span>}</td>
|
|
48
|
+
<td>{t.transport ? <span className={`badge trust-${t.transport.trust}`}>{t.transport.label}</span> : <span className="badge">unknown</span>}</td>
|
|
49
|
+
<td><code title={t.task_id}>{fmtId(t.task_id)}</code></td>
|
|
50
|
+
<td><span className={`state ${t.state}`}><span className="dot" aria-hidden="true" />{t.state}</span></td>
|
|
51
|
+
<td>{t.tenant}</td>
|
|
52
|
+
<td><button aria-label={`Stop task ${fmtId(t.task_id)}`} onClick={() => stop(t)}>Stop</button></td>
|
|
53
|
+
</tr>
|
|
54
|
+
))}
|
|
55
|
+
</tbody>
|
|
56
|
+
</table>
|
|
57
|
+
)}
|
|
58
|
+
</>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react';
|
|
3
|
+
import { Sessions } from './Sessions';
|
|
4
|
+
import type { SessionApi } from '../useSession';
|
|
5
|
+
|
|
6
|
+
const INSTANCE = {
|
|
7
|
+
id: 'inst-1',
|
|
8
|
+
runtime: 'docker',
|
|
9
|
+
loadout: 'agentic-dev',
|
|
10
|
+
state: 'running',
|
|
11
|
+
tenant: 'default',
|
|
12
|
+
card_url: '',
|
|
13
|
+
runtime_posture: { kind: 'docker', isolation: 'shared-kernel', label: 'Container' },
|
|
14
|
+
host_daemon: { status: 'available' },
|
|
15
|
+
transport: { mode: 'mtls-agent-registration', trust: 'secure', label: 'Secure transport', source: 'test' },
|
|
16
|
+
launch_context: { name: 'docker-one', loadout: 'agentic-dev' },
|
|
17
|
+
session_backends: [{ mode: 'managed', backend: 'tmux', available: true, drive: true, keyframe: true }],
|
|
18
|
+
};
|
|
19
|
+
const INSTANCE_NEXT = {
|
|
20
|
+
...INSTANCE,
|
|
21
|
+
id: 'inst-2',
|
|
22
|
+
launch_context: { name: 'docker-two', loadout: 'agentic-dev' },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function stubSession(): SessionApi {
|
|
26
|
+
return {
|
|
27
|
+
state: { attached: true, role: 'controller', url: 'ws://x/agents/inst-1/sessions/sess-1/attach' },
|
|
28
|
+
responseNeeded: { needed: false, prompt: '', since: null, source: 'pty' },
|
|
29
|
+
attach: vi.fn(),
|
|
30
|
+
detach: vi.fn(),
|
|
31
|
+
replay: vi.fn(),
|
|
32
|
+
requestKeyframe: vi.fn(),
|
|
33
|
+
sendInput: vi.fn(),
|
|
34
|
+
openTerminal: vi.fn(),
|
|
35
|
+
isController: true,
|
|
36
|
+
} as unknown as SessionApi;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
(window as unknown as { __COCKPIT_TOKEN__: string }).__COCKPIT_TOKEN__ = 'test-token';
|
|
41
|
+
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
|
|
45
|
+
|
|
46
|
+
describe('Sessions', () => {
|
|
47
|
+
it('ends the selected session, detaches if attached, and refreshes the list', async () => {
|
|
48
|
+
const session = stubSession();
|
|
49
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
50
|
+
const url = String(input);
|
|
51
|
+
if (url.includes('/api/inventory')) return jsonResponse({ instances: [INSTANCE] });
|
|
52
|
+
if (url.includes('/api/sessions?instance=')) return jsonResponse({
|
|
53
|
+
sessions: [{
|
|
54
|
+
id: 'sess-1',
|
|
55
|
+
session_name: 'terminal-main',
|
|
56
|
+
instance_id: 'inst-1',
|
|
57
|
+
attach_url: 'ws://x/agents/inst-1/sessions/sess-1/attach',
|
|
58
|
+
session_class: 'managed',
|
|
59
|
+
session_backend: 'tmux',
|
|
60
|
+
}],
|
|
61
|
+
});
|
|
62
|
+
if (url.includes('/api/instances/inst-1/sessions/sess-1') && init?.method === 'DELETE') return jsonResponse({ ended: true });
|
|
63
|
+
return new Response('{}', { status: 404 });
|
|
64
|
+
});
|
|
65
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
66
|
+
|
|
67
|
+
render(<Sessions session={session} composer="" setComposer={() => {}} onRequestStart={() => {}} />);
|
|
68
|
+
|
|
69
|
+
const endButton = await screen.findByRole('button', { name: /end session/i });
|
|
70
|
+
await waitFor(() => expect((endButton as HTMLButtonElement).disabled).toBe(false));
|
|
71
|
+
fireEvent.click(endButton);
|
|
72
|
+
|
|
73
|
+
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
|
74
|
+
expect.stringContaining('/api/instances/inst-1/sessions/sess-1'),
|
|
75
|
+
expect.objectContaining({ method: 'DELETE' }),
|
|
76
|
+
));
|
|
77
|
+
expect(session.detach).toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('refreshes stale recovered inventory and stops offering dead session attach URLs', async () => {
|
|
81
|
+
const session = stubSession();
|
|
82
|
+
const inventories = [
|
|
83
|
+
{ instances: [INSTANCE] },
|
|
84
|
+
{ instances: [INSTANCE_NEXT] },
|
|
85
|
+
];
|
|
86
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
87
|
+
const url = String(input);
|
|
88
|
+
if (url.includes('/api/inventory')) return jsonResponse(inventories.shift() ?? { instances: [INSTANCE_NEXT] });
|
|
89
|
+
if (url.includes('instance=inst-1')) return jsonResponse({
|
|
90
|
+
sessions: [{
|
|
91
|
+
id: 'sess-old',
|
|
92
|
+
instance_id: 'inst-1',
|
|
93
|
+
attach_url: 'ws://x/agents/inst-1/sessions/sess-old/attach',
|
|
94
|
+
session_class: 'managed',
|
|
95
|
+
session_backend: 'tmux',
|
|
96
|
+
}],
|
|
97
|
+
});
|
|
98
|
+
if (url.includes('instance=inst-2')) return jsonResponse({
|
|
99
|
+
sessions: [{
|
|
100
|
+
id: 'sess-new',
|
|
101
|
+
instance_id: 'inst-2',
|
|
102
|
+
attach_url: 'ws://x/agents/inst-2/sessions/sess-new/attach',
|
|
103
|
+
session_class: 'managed',
|
|
104
|
+
session_backend: 'tmux',
|
|
105
|
+
}],
|
|
106
|
+
});
|
|
107
|
+
return new Response('{}', { status: 404 });
|
|
108
|
+
});
|
|
109
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
110
|
+
|
|
111
|
+
render(<Sessions session={session} composer="" setComposer={() => {}} onRequestStart={() => {}} refreshMs={10} />);
|
|
112
|
+
|
|
113
|
+
expect(await screen.findByRole('option', { name: /docker-one/i })).toBeTruthy();
|
|
114
|
+
expect(await screen.findByRole('option', { name: /docker-two/i })).toBeTruthy();
|
|
115
|
+
expect(screen.queryByRole('option', { name: /docker-one/i })).toBeNull();
|
|
116
|
+
expect(await screen.findByRole('option', { name: /sess-new/i })).toBeTruthy();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
function jsonResponse(body: unknown): Response {
|
|
121
|
+
return new Response(JSON.stringify(body), {
|
|
122
|
+
status: 200,
|
|
123
|
+
headers: { 'Content-Type': 'application/json' },
|
|
124
|
+
});
|
|
125
|
+
}
|