@aiwg/cockpit 2026.7.7 → 2026.7.9
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 +16 -4
- package/bridge/src/server.mjs +372 -27
- package/bridge/src/smoke.mjs +10 -4
- package/contrib/aiwg-core.json +24 -2
- package/contrib/contribution.schema.json +28 -0
- package/package.json +1 -1
- package/shell-core/keychain.mjs +3 -0
- package/shell-core/runtime.mjs +3 -0
- package/web/src/App.test.tsx +146 -2
- package/web/src/App.tsx +9 -0
- package/web/src/components/Actions.tsx +80 -3
- package/web/src/components/Explore.tsx +89 -2
- package/web/src/components/Memory.tsx +145 -0
- package/web/src/components/Missions.tsx +135 -0
- package/web/src/components/Telemetry.tsx +85 -0
- package/web/src/styles.css +51 -1
- package/web/src/types.ts +67 -0
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"items": {
|
|
46
46
|
"type": "object",
|
|
47
47
|
"required": ["id", "title", "source"],
|
|
48
|
+
"additionalProperties": false,
|
|
48
49
|
"properties": {
|
|
49
50
|
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,63}$" },
|
|
50
51
|
"title": { "type": "string" },
|
|
@@ -52,11 +53,38 @@
|
|
|
52
53
|
}
|
|
53
54
|
}
|
|
54
55
|
},
|
|
56
|
+
"workflows": {
|
|
57
|
+
"type": "array",
|
|
58
|
+
"items": {
|
|
59
|
+
"type": "object",
|
|
60
|
+
"required": ["id", "title", "steps"],
|
|
61
|
+
"additionalProperties": false,
|
|
62
|
+
"properties": {
|
|
63
|
+
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,63}$" },
|
|
64
|
+
"title": { "type": "string" },
|
|
65
|
+
"description": { "type": "string" },
|
|
66
|
+
"steps": {
|
|
67
|
+
"type": "array",
|
|
68
|
+
"minItems": 1,
|
|
69
|
+
"items": {
|
|
70
|
+
"type": "object",
|
|
71
|
+
"required": ["action"],
|
|
72
|
+
"additionalProperties": false,
|
|
73
|
+
"properties": {
|
|
74
|
+
"action": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,63}$" },
|
|
75
|
+
"label": { "type": "string" }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
},
|
|
55
82
|
"hooks": {
|
|
56
83
|
"type": "array",
|
|
57
84
|
"items": {
|
|
58
85
|
"type": "object",
|
|
59
86
|
"required": ["on", "action"],
|
|
87
|
+
"additionalProperties": false,
|
|
60
88
|
"properties": {
|
|
61
89
|
"on": { "type": "string", "description": "event name, e.g. session.output, instance.state-changed" },
|
|
62
90
|
"action": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,63}$", "description": "id of a contributed action to run" }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiwg/cockpit",
|
|
3
|
-
"version": "2026.7.
|
|
3
|
+
"version": "2026.7.9",
|
|
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/shell-core/keychain.mjs
CHANGED
|
@@ -38,6 +38,9 @@ async function canRun(cmd) {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
export async function storeCockpitToken(token, account = `bridge-${process.pid}`) {
|
|
41
|
+
if (process.env.AIWG_COCKPIT_KEYCHAIN_DISABLED === '1') {
|
|
42
|
+
throw new Error('OS keychain disabled by AIWG_COCKPIT_KEYCHAIN_DISABLED');
|
|
43
|
+
}
|
|
41
44
|
const os = platform();
|
|
42
45
|
if (os === 'darwin' && await canRun('security')) {
|
|
43
46
|
await collect('security', ['add-generic-password', '-a', account, '-s', SERVICE, '-w', token, '-U']);
|
package/shell-core/runtime.mjs
CHANGED
|
@@ -14,6 +14,9 @@ export const RUNTIME_FILE = join(homedir(), '.aiwg', 'cockpit', 'runtime', 'brid
|
|
|
14
14
|
/** Read the per-launch Bridge connection (token, port, url). Throws if not launched. */
|
|
15
15
|
export async function readRuntime(file = RUNTIME_FILE) {
|
|
16
16
|
const r = JSON.parse(await readFile(file, 'utf8'));
|
|
17
|
+
if (process.env.AIWG_COCKPIT_KEYCHAIN_STRICT === '1' && !r.token_ref) {
|
|
18
|
+
throw new Error(`runtime file ${file} is not keychain-backed in strict mode`);
|
|
19
|
+
}
|
|
17
20
|
const token = r.token || await readCockpitToken(r.token_ref);
|
|
18
21
|
if (!token || !r.port) throw new Error(`runtime file ${file} missing token/port`);
|
|
19
22
|
return { ...r, token, url: `http://127.0.0.1:${r.port}` };
|
package/web/src/App.test.tsx
CHANGED
|
@@ -12,10 +12,10 @@ beforeEach(() => {
|
|
|
12
12
|
});
|
|
13
13
|
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
|
|
14
14
|
|
|
15
|
-
const TAB_LABELS = ['Home', 'Inventory', 'Running', 'Sessions', 'Approvals', 'Explore', 'Library', 'Actions'];
|
|
15
|
+
const TAB_LABELS = ['Home', 'Inventory', 'Running', 'Missions', 'Sessions', 'Approvals', 'Explore', 'Library', 'Telemetry', 'Memory', 'Actions'];
|
|
16
16
|
|
|
17
17
|
describe('App shell (rendered DOM)', () => {
|
|
18
|
-
it('renders an ARIA tablist with all
|
|
18
|
+
it('renders an ARIA tablist with all Cockpit tabs', () => {
|
|
19
19
|
render(<App />);
|
|
20
20
|
expect(screen.getByRole('tablist', { name: /cockpit views/i })).toBeTruthy();
|
|
21
21
|
expect(screen.getAllByRole('tab')).toHaveLength(TAB_LABELS.length);
|
|
@@ -45,6 +45,108 @@ describe('App shell (rendered DOM)', () => {
|
|
|
45
45
|
expect(screen.getByText(/start a session automatically/i)).toBeTruthy();
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
it('renders durable Missions projection from aiwg mc state and live executor work', async () => {
|
|
49
|
+
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
50
|
+
const url = String(input);
|
|
51
|
+
if (url.includes('/api/health')) return jsonResponse({ executor_url: 'http://127.0.0.1:8122' });
|
|
52
|
+
if (url.includes('/api/inventory')) return jsonResponse({ instances: [instance('host-1', 'host', 'Codex host')] });
|
|
53
|
+
if (url.includes('/api/running')) return jsonResponse({ count: 1, running: [{ instance_id: 'host-1', task_id: 'task-abc', state: 'working', tenant: 'local' }] });
|
|
54
|
+
if (url.includes('/api/approvals')) return jsonResponse({ approvals: [] });
|
|
55
|
+
if (url.includes('/api/cost')) return jsonResponse({ total: { input_tokens: 0, output_tokens: 0, usd: 0 }, per_instance: [] });
|
|
56
|
+
if (url.includes('/api/missions')) return jsonResponse({
|
|
57
|
+
source: 'aiwg-mc + agentic-sandbox',
|
|
58
|
+
fetched_at: new Date().toISOString(),
|
|
59
|
+
count: 2,
|
|
60
|
+
sessions: [{
|
|
61
|
+
id: 'mc-1',
|
|
62
|
+
name: 'Release hardening',
|
|
63
|
+
state: 'active',
|
|
64
|
+
source: 'aiwg-mc',
|
|
65
|
+
audit_count: 1,
|
|
66
|
+
audit_tail: [{ event: 'mission_dispatched', ts: '2026-07-04T12:00:00.000Z', missionId: 'm-1' }],
|
|
67
|
+
missions: [{ id: 'm-1', session_id: 'mc-1', source: 'aiwg-mc', title: 'Finish cockpit', status: 'running', loop: 1, max_iterations: 5, terminal: false }],
|
|
68
|
+
}, {
|
|
69
|
+
id: 'executor-live',
|
|
70
|
+
name: 'Executor live tasks',
|
|
71
|
+
state: 'active',
|
|
72
|
+
source: 'agentic-sandbox',
|
|
73
|
+
audit_count: 0,
|
|
74
|
+
audit_tail: [],
|
|
75
|
+
missions: [{ id: 'host-1::task-abc', session_id: 'executor-live', source: 'executor-task', title: 'Task task-abc', status: 'working', instance_id: 'host-1', task_id: 'task-abc', terminal: false }],
|
|
76
|
+
}],
|
|
77
|
+
missions: [
|
|
78
|
+
{ id: 'm-1', session_id: 'mc-1', source: 'aiwg-mc', title: 'Finish cockpit', status: 'running', terminal: false },
|
|
79
|
+
{ id: 'host-1::task-abc', session_id: 'executor-live', source: 'executor-task', title: 'Task task-abc', status: 'working', terminal: false },
|
|
80
|
+
],
|
|
81
|
+
});
|
|
82
|
+
return jsonResponse({});
|
|
83
|
+
}) as typeof fetch;
|
|
84
|
+
|
|
85
|
+
render(<App />);
|
|
86
|
+
fireEvent.click(screen.getByRole('tab', { name: 'Missions' }));
|
|
87
|
+
|
|
88
|
+
expect((await screen.findAllByText('Release hardening')).length).toBeGreaterThanOrEqual(1);
|
|
89
|
+
expect(screen.getByLabelText('Mission status summary').textContent).toContain('2 total');
|
|
90
|
+
expect(screen.getByText('Finish cockpit')).toBeTruthy();
|
|
91
|
+
expect(screen.getByText(/mission_dispatched/)).toBeTruthy();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('renders Bridge-backed Telemetry from unified events and cost', async () => {
|
|
95
|
+
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
96
|
+
const url = String(input);
|
|
97
|
+
if (url.includes('/api/health')) return jsonResponse({ executor_url: 'http://127.0.0.1:8122' });
|
|
98
|
+
if (url.includes('/api/inventory')) return jsonResponse({ instances: [instance('host-1', 'host', 'Codex host')] });
|
|
99
|
+
if (url.includes('/api/running')) return jsonResponse({ count: 1, running: [{ instance_id: 'host-1', task_id: 'task-abc', state: 'working', tenant: 'local' }] });
|
|
100
|
+
if (url.includes('/api/approvals')) return jsonResponse({ approvals: [] });
|
|
101
|
+
if (url.includes('/api/cost')) return jsonResponse({ total: { input_tokens: 1000, output_tokens: 2000, usd: 0.42 }, per_instance: [] });
|
|
102
|
+
if (url.includes('/api/missions')) return jsonResponse({ count: 1, sessions: [], missions: [{ id: 'm-1', session_id: 'mc-1', source: 'aiwg-mc', title: 'Mission', status: 'completed', terminal: true }] });
|
|
103
|
+
if (url.includes('/api/events/snapshot')) return jsonResponse({
|
|
104
|
+
source: 'cockpit.unified-event-model/v1',
|
|
105
|
+
fetched_at: new Date().toISOString(),
|
|
106
|
+
count: 2,
|
|
107
|
+
events: [
|
|
108
|
+
{ id: 'mission:m-1', type: 'mission.lifecycle', source: 'aiwg-mc', subject: 'm-1', state: 'completed', ts: new Date().toISOString() },
|
|
109
|
+
{ id: 'session:host-1:sess-1', type: 'session.lifecycle', source: 'pty-session', subject: 'sess-1', state: 'available', ts: new Date().toISOString() },
|
|
110
|
+
{ id: 'task:task-abc', type: 'task.lifecycle', source: 'a2a', subject: 'task-abc', state: 'working', ts: new Date().toISOString() },
|
|
111
|
+
],
|
|
112
|
+
});
|
|
113
|
+
return jsonResponse({});
|
|
114
|
+
}) as typeof fetch;
|
|
115
|
+
|
|
116
|
+
render(<App />);
|
|
117
|
+
fireEvent.click(screen.getByRole('tab', { name: 'Telemetry' }));
|
|
118
|
+
|
|
119
|
+
expect(await screen.findByText('cockpit.unified-event-model/v1')).toBeTruthy();
|
|
120
|
+
expect(screen.getByText('$0.42')).toBeTruthy();
|
|
121
|
+
expect(screen.getByText('mission.lifecycle')).toBeTruthy();
|
|
122
|
+
fireEvent.click(screen.getByRole('button', { name: 'session' }));
|
|
123
|
+
expect(screen.getByText('session.lifecycle')).toBeTruthy();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('renders local Memory and auto-notes terminal Missions', async () => {
|
|
127
|
+
localStorage.clear();
|
|
128
|
+
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
129
|
+
const url = String(input);
|
|
130
|
+
if (url.includes('/api/health')) return jsonResponse({ executor_url: 'http://127.0.0.1:8122' });
|
|
131
|
+
if (url.includes('/api/inventory')) return jsonResponse({ instances: [instance('host-1', 'host', 'Codex host')] });
|
|
132
|
+
if (url.includes('/api/running')) return jsonResponse({ count: 0, running: [] });
|
|
133
|
+
if (url.includes('/api/approvals')) return jsonResponse({ approvals: [] });
|
|
134
|
+
if (url.includes('/api/cost')) return jsonResponse({ total: { input_tokens: 0, output_tokens: 0, usd: 0 }, per_instance: [] });
|
|
135
|
+
if (url.includes('/api/missions')) return jsonResponse({
|
|
136
|
+
count: 1,
|
|
137
|
+
sessions: [],
|
|
138
|
+
missions: [{ id: 'm-done', session_id: 'mc-1', source: 'aiwg-mc', title: 'Closed release gate', status: 'completed', completed_at: '2026-07-04T12:00:00.000Z', terminal: true }],
|
|
139
|
+
});
|
|
140
|
+
return jsonResponse({});
|
|
141
|
+
}) as typeof fetch;
|
|
142
|
+
|
|
143
|
+
render(<App />);
|
|
144
|
+
fireEvent.click(screen.getByRole('tab', { name: 'Memory' }));
|
|
145
|
+
|
|
146
|
+
expect(await screen.findByText('Closed release gate')).toBeTruthy();
|
|
147
|
+
expect(screen.getByText(/1 saved/i)).toBeTruthy();
|
|
148
|
+
});
|
|
149
|
+
|
|
48
150
|
it('renders the welcome heading and its tabpanel (not blank)', () => {
|
|
49
151
|
render(<App />);
|
|
50
152
|
expect(screen.getByRole('heading', { name: /work alongside your agents/i })).toBeTruthy();
|
|
@@ -158,18 +260,60 @@ describe('App shell (rendered DOM)', () => {
|
|
|
158
260
|
source: 'test',
|
|
159
261
|
inject: { command: 'aiwg doctor' },
|
|
160
262
|
}],
|
|
263
|
+
screens: [{ id: 'index-live', title: 'Live Index', source: 'cockpit://index/live', contribution: 'test' }],
|
|
264
|
+
workflows: [{
|
|
265
|
+
id: 'maintenance-check',
|
|
266
|
+
title: 'Maintenance Check',
|
|
267
|
+
description: 'Run doctor through an agentic session.',
|
|
268
|
+
source: 'test',
|
|
269
|
+
steps: [{ action: 'doctor', label: 'Doctor' }],
|
|
270
|
+
}],
|
|
161
271
|
});
|
|
162
272
|
return jsonResponse({});
|
|
163
273
|
}) as typeof fetch;
|
|
164
274
|
|
|
165
275
|
render(<App />);
|
|
166
276
|
fireEvent.click(screen.getByRole('tab', { name: 'Actions' }));
|
|
277
|
+
expect(await screen.findByText('Live Index')).toBeTruthy();
|
|
278
|
+
expect(screen.getByText('Maintenance Check')).toBeTruthy();
|
|
167
279
|
fireEvent.click(await screen.findByRole('button', { name: /copy CLI command for run doctor/i }));
|
|
168
280
|
|
|
169
281
|
expect(writeText).toHaveBeenCalledWith('aiwg doctor');
|
|
170
282
|
await waitFor(() => expect(screen.getByText(/copied "aiwg doctor"/i)).toBeTruthy());
|
|
171
283
|
});
|
|
172
284
|
|
|
285
|
+
it('renders live index status and query results in Explore', async () => {
|
|
286
|
+
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
287
|
+
const url = String(input);
|
|
288
|
+
if (url.includes('/api/health')) return jsonResponse({ executor_url: 'http://127.0.0.1:8122' });
|
|
289
|
+
if (url.includes('/api/inventory')) return jsonResponse({ instances: [instance('host-1', 'host', 'Codex host')] });
|
|
290
|
+
if (url.includes('/api/running')) return jsonResponse({ count: 0, running: [] });
|
|
291
|
+
if (url.includes('/api/approvals')) return jsonResponse({ approvals: [] });
|
|
292
|
+
if (url.includes('/api/cost')) return jsonResponse({ total: { input_tokens: 0, output_tokens: 0, usd: 0 }, per_instance: [] });
|
|
293
|
+
if (url.includes('/api/index/status')) return jsonResponse({
|
|
294
|
+
graphs: [{ name: 'project', origin: 'builtin', shared: false, defaultBuild: true, location: '.aiwg/.index/project', built: true, builtAt: '2026-07-04T12:00:00.000Z', ageHours: 1, entries: 42, missing: false }],
|
|
295
|
+
orphanIndexDirs: [],
|
|
296
|
+
warnings: [],
|
|
297
|
+
summary: { total: 1, built: 1, missing: 0, orphans: 0, warnings: 0 },
|
|
298
|
+
});
|
|
299
|
+
if (url.includes('/api/index/query')) return jsonResponse({
|
|
300
|
+
results: [{ path: '.aiwg/requirements/UC-001.md', title: 'Mission requirements', type: 'use-case', phase: 'requirements', summary: 'Operator workflow coverage', score: 0.91 }],
|
|
301
|
+
total: 1,
|
|
302
|
+
});
|
|
303
|
+
return jsonResponse({});
|
|
304
|
+
}) as typeof fetch;
|
|
305
|
+
|
|
306
|
+
render(<App />);
|
|
307
|
+
fireEvent.click(screen.getByRole('tab', { name: 'Explore' }));
|
|
308
|
+
|
|
309
|
+
expect(await screen.findByText(/1\/1 graphs built/i)).toBeTruthy();
|
|
310
|
+
fireEvent.change(screen.getByLabelText(/index query/i), { target: { value: 'mission' } });
|
|
311
|
+
fireEvent.click(screen.getByRole('button', { name: /search index/i }));
|
|
312
|
+
|
|
313
|
+
expect(await screen.findByText('Mission requirements')).toBeTruthy();
|
|
314
|
+
expect(screen.getByText(/operator workflow coverage/i)).toBeTruthy();
|
|
315
|
+
});
|
|
316
|
+
|
|
173
317
|
it('can open the mission-handoff review layout from a shareable URL', async () => {
|
|
174
318
|
window.history.replaceState({}, '', '/?wall=handoff');
|
|
175
319
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
package/web/src/App.tsx
CHANGED
|
@@ -5,11 +5,14 @@ import type { Approval, Instance, ResponseNeeded } from './types';
|
|
|
5
5
|
import { Welcome } from './components/Welcome';
|
|
6
6
|
import { Inventory } from './components/Inventory';
|
|
7
7
|
import { Running } from './components/Running';
|
|
8
|
+
import { Missions } from './components/Missions';
|
|
8
9
|
import { Sessions } from './components/Sessions';
|
|
9
10
|
import { Approvals } from './components/Approvals';
|
|
10
11
|
import { Explore } from './components/Explore';
|
|
11
12
|
import { Library } from './components/Library';
|
|
12
13
|
import { Actions } from './components/Actions';
|
|
14
|
+
import { Telemetry } from './components/Telemetry';
|
|
15
|
+
import { Memory } from './components/Memory';
|
|
13
16
|
import { StartSessionModal } from './components/StartSessionModal';
|
|
14
17
|
import { LaunchInstanceModal } from './components/LaunchInstanceModal';
|
|
15
18
|
|
|
@@ -17,10 +20,13 @@ const TABS = [
|
|
|
17
20
|
{ id: 'welcome', label: 'Home' },
|
|
18
21
|
{ id: 'inventory', label: 'Inventory' },
|
|
19
22
|
{ id: 'running', label: 'Running' },
|
|
23
|
+
{ id: 'missions', label: 'Missions' },
|
|
20
24
|
{ id: 'sessions', label: 'Sessions' },
|
|
21
25
|
{ id: 'approvals', label: 'Approvals' },
|
|
22
26
|
{ id: 'explore', label: 'Explore' },
|
|
23
27
|
{ id: 'library', label: 'Library' },
|
|
28
|
+
{ id: 'telemetry', label: 'Telemetry' },
|
|
29
|
+
{ id: 'memory', label: 'Memory' },
|
|
24
30
|
{ id: 'actions', label: 'Actions' },
|
|
25
31
|
] as const;
|
|
26
32
|
type TabId = (typeof TABS)[number]['id'];
|
|
@@ -165,6 +171,7 @@ export function App() {
|
|
|
165
171
|
<Panel id="welcome" tab={tab}><Welcome onStartSession={() => requestStart()} onLaunchInstance={() => setLaunchOpen(true)} goTo={(t) => setTab(t as TabId)} /></Panel>
|
|
166
172
|
<Panel id="inventory" tab={tab}><Inventory onStartSession={requestStart} onLaunchInstance={() => setLaunchOpen(true)} /></Panel>
|
|
167
173
|
<Panel id="running" tab={tab}><Running refreshTick={refreshTick} /></Panel>
|
|
174
|
+
<Panel id="missions" tab={tab}><Missions refreshTick={refreshTick} /></Panel>
|
|
168
175
|
{/* Sessions stays mounted so the WebSocket survives tab switches */}
|
|
169
176
|
<section id="panel-sessions" role="tabpanel" aria-labelledby="tab-sessions" hidden={tab !== 'sessions'}>
|
|
170
177
|
<Sessions session={session} composer={composer} setComposer={setComposer} onRequestStart={requestStart} />
|
|
@@ -174,6 +181,8 @@ export function App() {
|
|
|
174
181
|
<Panel id="library" tab={tab}>
|
|
175
182
|
<Library session={session} setComposer={setComposer} goSessions={() => setTab('sessions')} />
|
|
176
183
|
</Panel>
|
|
184
|
+
<Panel id="telemetry" tab={tab}><Telemetry refreshTick={refreshTick} /></Panel>
|
|
185
|
+
<Panel id="memory" tab={tab}><Memory refreshTick={refreshTick} /></Panel>
|
|
177
186
|
<Panel id="actions" tab={tab}>
|
|
178
187
|
<Actions refreshTick={refreshTick} session={session} setComposer={setComposer} goSessions={() => setTab('sessions')} />
|
|
179
188
|
</Panel>
|
|
@@ -1,25 +1,45 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
2
|
import { api } from '../api';
|
|
3
|
-
import type { ContribAction } from '../types';
|
|
3
|
+
import type { ContribAction, ContribScreen, ContribWorkflow, ContributionsResponse } from '../types';
|
|
4
4
|
import type { SessionApi } from '../useSession';
|
|
5
5
|
|
|
6
6
|
// Actions INJECT a command into an agentic session — the Cockpit never runs the CLI.
|
|
7
7
|
export function Actions({ refreshTick = 0, session, setComposer, goSessions }: { refreshTick?: number; session: SessionApi; setComposer: (v: string) => void; goSessions: () => void }) {
|
|
8
8
|
const [actions, setActions] = useState<ContribAction[]>([]);
|
|
9
|
+
const [screens, setScreens] = useState<ContribScreen[]>([]);
|
|
10
|
+
const [workflows, setWorkflows] = useState<ContribWorkflow[]>([]);
|
|
9
11
|
const [err, setErr] = useState('');
|
|
10
12
|
const [note, setNote] = useState('');
|
|
11
13
|
|
|
12
14
|
useEffect(() => {
|
|
13
|
-
api<
|
|
15
|
+
api<ContributionsResponse>('/api/contributions').then((d) => {
|
|
16
|
+
setActions(d.actions ?? []);
|
|
17
|
+
setScreens(d.screens ?? []);
|
|
18
|
+
setWorkflows(d.workflows ?? []);
|
|
19
|
+
}).catch((e) => setErr((e as Error).message));
|
|
14
20
|
}, [refreshTick]);
|
|
15
21
|
|
|
16
|
-
const inject = (a: ContribAction) => {
|
|
22
|
+
const inject = async (a: ContribAction) => {
|
|
17
23
|
let command = a.inject.command;
|
|
18
24
|
if (a.inject.needs_args) {
|
|
19
25
|
const extra = prompt(`Arguments for ${a.title}${a.inject.args_hint ? ` (${a.inject.args_hint})` : ''}:`, '');
|
|
20
26
|
if (extra === null) return;
|
|
21
27
|
if (extra.trim()) command += ' ' + extra.trim();
|
|
22
28
|
}
|
|
29
|
+
await api('/api/audit/intent', {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: { 'content-type': 'application/json' },
|
|
32
|
+
body: JSON.stringify({
|
|
33
|
+
event: 'action.inject.requested',
|
|
34
|
+
detail: {
|
|
35
|
+
action_id: a.id,
|
|
36
|
+
source: a.source,
|
|
37
|
+
target: a.inject.target ?? 'focused',
|
|
38
|
+
command,
|
|
39
|
+
controller_attached: session.isController,
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
}).catch(() => undefined);
|
|
23
43
|
if (session.isController && session.sendInput(command)) {
|
|
24
44
|
setNote(`Injected "${command}" into the attached session — the agent runs it.`);
|
|
25
45
|
} else {
|
|
@@ -32,6 +52,20 @@ export function Actions({ refreshTick = 0, session, setComposer, goSessions }: {
|
|
|
32
52
|
await navigator.clipboard?.writeText(a.inject.command);
|
|
33
53
|
setNote(`Copied "${a.inject.command}" to the clipboard.`);
|
|
34
54
|
};
|
|
55
|
+
const actionById = new Map(actions.map((a) => [a.id, a]));
|
|
56
|
+
const openScreen = (screen: ContribScreen) => {
|
|
57
|
+
if (screen.source === 'cockpit://index/live') {
|
|
58
|
+
window.location.hash = '#explore';
|
|
59
|
+
setNote('Opened the contributed Live Index screen.');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (screen.source === 'cockpit://issues/workbench') {
|
|
63
|
+
window.location.hash = '#actions';
|
|
64
|
+
setNote('Issue Workbench is available here through the contributed issue actions and workflows.');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
setNote(`Unsupported contributed screen source: ${screen.source}`);
|
|
68
|
+
};
|
|
35
69
|
|
|
36
70
|
return (
|
|
37
71
|
<>
|
|
@@ -54,6 +88,49 @@ export function Actions({ refreshTick = 0, session, setComposer, goSessions }: {
|
|
|
54
88
|
))
|
|
55
89
|
: <p className="empty">No contributed actions.</p>}
|
|
56
90
|
</div>
|
|
91
|
+
<div className="contrib-layout">
|
|
92
|
+
<section aria-label="Contributed screens">
|
|
93
|
+
<div className="section-toolbar">
|
|
94
|
+
<h2>Screens</h2>
|
|
95
|
+
</div>
|
|
96
|
+
<div className="contrib-grid">
|
|
97
|
+
{screens.length
|
|
98
|
+
? screens.map((s) => (
|
|
99
|
+
<article className="contrib-item" key={s.id}>
|
|
100
|
+
<strong>{s.title}</strong>
|
|
101
|
+
<p>{s.source}</p>
|
|
102
|
+
<button onClick={() => openScreen(s)}>Open</button>
|
|
103
|
+
</article>
|
|
104
|
+
))
|
|
105
|
+
: <p className="empty">No contributed screens.</p>}
|
|
106
|
+
</div>
|
|
107
|
+
</section>
|
|
108
|
+
<section aria-label="Contributed workflows">
|
|
109
|
+
<div className="section-toolbar">
|
|
110
|
+
<h2>Workflows</h2>
|
|
111
|
+
</div>
|
|
112
|
+
<div className="contrib-grid">
|
|
113
|
+
{workflows.length
|
|
114
|
+
? workflows.map((w) => (
|
|
115
|
+
<article className="contrib-item" key={w.id}>
|
|
116
|
+
<strong>{w.title}</strong>
|
|
117
|
+
{w.description && <p>{w.description}</p>}
|
|
118
|
+
<div className="workflow-steps">
|
|
119
|
+
{w.steps.map((step, idx) => {
|
|
120
|
+
const action = actionById.get(step.action);
|
|
121
|
+
return (
|
|
122
|
+
<button key={`${w.id}-${idx}-${step.action}`} disabled={!action} onClick={() => action && inject(action)}>
|
|
123
|
+
{step.label || action?.title || step.action}
|
|
124
|
+
</button>
|
|
125
|
+
);
|
|
126
|
+
})}
|
|
127
|
+
</div>
|
|
128
|
+
</article>
|
|
129
|
+
))
|
|
130
|
+
: <p className="empty">No contributed workflows.</p>}
|
|
131
|
+
</div>
|
|
132
|
+
</section>
|
|
133
|
+
</div>
|
|
57
134
|
<p className="empty">{note || 'Output appears in the session terminal — actions drop a command into a session and the agent executes it.'}</p>
|
|
58
135
|
</>
|
|
59
136
|
);
|
|
@@ -1,12 +1,22 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
2
|
import { api } from '../api';
|
|
3
3
|
import { CapabilitySearch } from './CapabilitySearch';
|
|
4
|
-
import type { CapabilityResult } from '../types';
|
|
4
|
+
import type { CapabilityResult, IndexQueryResponse, IndexStatusResponse } from '../types';
|
|
5
5
|
|
|
6
6
|
export function Explore() {
|
|
7
7
|
const [body, setBody] = useState<{ type: string; name: string; body: string } | null>(null);
|
|
8
|
+
const [status, setStatus] = useState<IndexStatusResponse | null>(null);
|
|
9
|
+
const [query, setQuery] = useState('requirements');
|
|
10
|
+
const [queryResults, setQueryResults] = useState<IndexQueryResponse | null>(null);
|
|
11
|
+
const [busy, setBusy] = useState('');
|
|
8
12
|
const [err, setErr] = useState('');
|
|
9
13
|
|
|
14
|
+
const loadStatus = () => {
|
|
15
|
+
api<IndexStatusResponse>('/api/index/status').then(setStatus).catch((e) => setErr((e as Error).message));
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
useEffect(() => { loadStatus(); }, []);
|
|
19
|
+
|
|
10
20
|
const show = (r: CapabilityResult) => {
|
|
11
21
|
setBody(null); setErr('');
|
|
12
22
|
// Fetch by the discovered path — deterministic even when a name is shared by two
|
|
@@ -16,6 +26,39 @@ export function Explore() {
|
|
|
16
26
|
.then(setBody).catch((e) => setErr((e as Error).message));
|
|
17
27
|
};
|
|
18
28
|
|
|
29
|
+
const runIndexQuery = async () => {
|
|
30
|
+
const q = query.trim();
|
|
31
|
+
if (!q) return;
|
|
32
|
+
setBusy('query'); setErr('');
|
|
33
|
+
try {
|
|
34
|
+
setQueryResults(await api<IndexQueryResponse>(`/api/index/query?q=${encodeURIComponent(q)}&limit=8`));
|
|
35
|
+
} catch (e) {
|
|
36
|
+
setErr((e as Error).message);
|
|
37
|
+
} finally {
|
|
38
|
+
setBusy('');
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const rebuild = async () => {
|
|
43
|
+
setBusy('rebuild'); setErr('');
|
|
44
|
+
try {
|
|
45
|
+
const result = await api<{ status: IndexStatusResponse }>('/api/index/rebuild', {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: { 'content-type': 'application/json' },
|
|
48
|
+
body: JSON.stringify({ all: true }),
|
|
49
|
+
});
|
|
50
|
+
setStatus(result.status);
|
|
51
|
+
setQueryResults(null);
|
|
52
|
+
} catch (e) {
|
|
53
|
+
setErr((e as Error).message);
|
|
54
|
+
} finally {
|
|
55
|
+
setBusy('');
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const graphs = status?.graphs ?? [];
|
|
60
|
+
const queryItems = queryResults?.results ?? [];
|
|
61
|
+
|
|
19
62
|
return (
|
|
20
63
|
<>
|
|
21
64
|
<p className="hint">
|
|
@@ -23,6 +66,50 @@ export function Explore() {
|
|
|
23
66
|
session (Actions/Sessions). Search modeled on the fortemi-react patterns.
|
|
24
67
|
</p>
|
|
25
68
|
{err && <p className="err">{err}</p>}
|
|
69
|
+
<section className="index-live" aria-label="Live index status">
|
|
70
|
+
<div className="section-toolbar">
|
|
71
|
+
<div>
|
|
72
|
+
<h2>Live Index</h2>
|
|
73
|
+
<p className="hint">
|
|
74
|
+
{status
|
|
75
|
+
? `${status.summary.built}/${status.summary.total} graphs built, ${status.summary.missing} missing, ${status.summary.orphans} orphan dirs.`
|
|
76
|
+
: 'Loading index status.'}
|
|
77
|
+
</p>
|
|
78
|
+
</div>
|
|
79
|
+
<div className="manage-actions">
|
|
80
|
+
<button onClick={loadStatus} disabled={busy === 'rebuild'}>Refresh</button>
|
|
81
|
+
<button onClick={rebuild} disabled={busy === 'rebuild'}>{busy === 'rebuild' ? 'Rebuilding...' : 'Rebuild all'}</button>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
<div className="index-grid">
|
|
85
|
+
{graphs.map((g) => (
|
|
86
|
+
<article className={g.missing ? 'index-graph missing' : 'index-graph'} key={g.name}>
|
|
87
|
+
<div><strong>{g.name}</strong> <span className="badge">{g.origin}</span></div>
|
|
88
|
+
<p>{g.built ? `${g.entries ?? 0} entries` : 'not built'}{g.ageHours !== null ? ` · ${g.ageHours}h old` : ''}</p>
|
|
89
|
+
</article>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
<div className="index-query">
|
|
93
|
+
<label>
|
|
94
|
+
Index query
|
|
95
|
+
<input value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') void runIndexQuery(); }} />
|
|
96
|
+
</label>
|
|
97
|
+
<button onClick={runIndexQuery} disabled={busy === 'query'}>{busy === 'query' ? 'Searching...' : 'Search index'}</button>
|
|
98
|
+
</div>
|
|
99
|
+
{queryResults && (
|
|
100
|
+
<div className="index-results" aria-label="Index query results">
|
|
101
|
+
{queryItems.length
|
|
102
|
+
? queryItems.map((r) => (
|
|
103
|
+
<article key={r.path}>
|
|
104
|
+
<strong>{r.title || r.path}</strong>
|
|
105
|
+
<p>{r.summary || r.path}</p>
|
|
106
|
+
<small>{r.type || 'artifact'}{r.phase ? ` · ${r.phase}` : ''}{typeof r.score === 'number' ? ` · ${r.score.toFixed(2)}` : ''}</small>
|
|
107
|
+
</article>
|
|
108
|
+
))
|
|
109
|
+
: <p className="empty">No index hits.</p>}
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
</section>
|
|
26
113
|
<div className="grid2">
|
|
27
114
|
<CapabilitySearch onPick={show} autoFocus />
|
|
28
115
|
<div role="region" aria-label="Capability detail">
|