cc-agent-ui 0.2.0

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/server.js ADDED
@@ -0,0 +1,263 @@
1
+ /**
2
+ * cc-agent-ui server — plugged into Redis directly.
3
+ *
4
+ * Data sources:
5
+ * cca:jobs:{namespace} → Redis SET of job IDs per namespace
6
+ * cca:job:{UUID} → Redis STRING (JSON) — full job metadata
7
+ * cca:job:{UUID}:output → Redis LIST — log lines (append-only)
8
+ * ~/.cc-agent/jobs/{UUID}.log → disk fallback for output
9
+ */
10
+ import http from 'http';
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import os from 'os';
14
+ import { fileURLToPath } from 'url';
15
+ import { WebSocketServer } from 'ws';
16
+ import { createClient } from 'redis';
17
+ import { exec } from 'child_process';
18
+
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+ const PORT = parseInt(process.env.PORT || '7701', 10);
21
+ const JOBS_DIR = path.join(os.homedir(), '.cc-agent', 'jobs');
22
+ const UI_FILE = path.join(__dirname, 'public', 'index.html');
23
+ const TAIL_LINES = 150;
24
+
25
+ // ── Redis ──────────────────────────────────────────────────────────────────
26
+ const redis = createClient({ url: 'redis://127.0.0.1:6379' });
27
+ redis.on('error', e => console.error('[redis]', e.message));
28
+ await redis.connect();
29
+ console.log('[redis] connected');
30
+
31
+ // ── State ──────────────────────────────────────────────────────────────────
32
+ const clients = new Set();
33
+ const jobCache = {}; // id → job object (latest known)
34
+ const outputLengths = {}; // id → last known Redis list length
35
+
36
+ // ── Helpers ────────────────────────────────────────────────────────────────
37
+ function broadcast(evt) {
38
+ const msg = JSON.stringify(evt);
39
+ for (const ws of clients) {
40
+ if (ws.readyState === 1) ws.send(msg);
41
+ }
42
+ }
43
+
44
+ /** Parse a job JSON string from Redis, return null on failure */
45
+ function parseJob(raw) {
46
+ try { return raw ? JSON.parse(raw) : null; } catch { return null; }
47
+ }
48
+
49
+ /** Get all namespace keys: cca:jobs:* */
50
+ async function getNamespaces() {
51
+ const keys = await redis.keys('cca:jobs:*');
52
+ return keys
53
+ .filter(k => !k.includes(':index'))
54
+ .map(k => k.replace('cca:jobs:', ''));
55
+ }
56
+
57
+ /** Get all job IDs for a namespace */
58
+ async function getJobIds(namespace) {
59
+ return redis.sMembers(`cca:jobs:${namespace}`);
60
+ }
61
+
62
+ /** Fetch a single job by ID */
63
+ async function fetchJob(id) {
64
+ const raw = await redis.get(`cca:job:${id}`);
65
+ const job = parseJob(raw);
66
+ if (job) job._id = id;
67
+ return job;
68
+ }
69
+
70
+ /** Fetch multiple jobs in one pipeline */
71
+ async function fetchJobs(ids) {
72
+ if (!ids.length) return [];
73
+ const pipeline = redis.multi();
74
+ for (const id of ids) pipeline.get(`cca:job:${id}`);
75
+ const results = await pipeline.exec();
76
+ return results
77
+ .map((raw, i) => { const j = parseJob(raw); if (j) j.id = j.id || ids[i]; return j; })
78
+ .filter(Boolean);
79
+ }
80
+
81
+ /** Get last N lines from Redis output list (or disk fallback) */
82
+ async function getOutputTail(id, n = TAIL_LINES) {
83
+ try {
84
+ const len = await redis.lLen(`cca:job:${id}:output`);
85
+ if (len > 0) {
86
+ outputLengths[id] = len;
87
+ const start = Math.max(0, len - n);
88
+ return redis.lRange(`cca:job:${id}:output`, start, -1);
89
+ }
90
+ } catch {}
91
+ // Disk fallback
92
+ try {
93
+ const content = fs.readFileSync(path.join(JOBS_DIR, `${id}.log`), 'utf8');
94
+ const lines = content.split('\n').filter(Boolean);
95
+ outputLengths[id] = lines.length;
96
+ return lines.slice(-n);
97
+ } catch { return []; }
98
+ }
99
+
100
+ /** Poll for new output lines since last known length */
101
+ async function pollNewOutput(id) {
102
+ try {
103
+ const len = await redis.lLen(`cca:job:${id}:output`);
104
+ const prev = outputLengths[id] || 0;
105
+ if (len <= prev) return [];
106
+ outputLengths[id] = len;
107
+ return redis.lRange(`cca:job:${id}:output`, prev, -1);
108
+ } catch { return []; }
109
+ }
110
+
111
+ // ── Build initial snapshot ─────────────────────────────────────────────────
112
+ async function buildSnapshot() {
113
+ const namespaces = await getNamespaces();
114
+ const allJobs = [];
115
+
116
+ for (const ns of namespaces) {
117
+ const ids = await getJobIds(ns);
118
+ const jobs = await fetchJobs(ids);
119
+ // Attach namespace
120
+ for (const j of jobs) { j.namespace = ns; jobCache[j.id] = j; }
121
+ allJobs.push(...jobs);
122
+ }
123
+
124
+ // Sort: running/cloning first, then by startedAt desc
125
+ const ORDER = { running:0, cloning:1, pending_approval:2, failed:3, cancelled:4, done:5 };
126
+ allJobs.sort((a, b) =>
127
+ (ORDER[a.status]??9) - (ORDER[b.status]??9) ||
128
+ new Date(b.startedAt||0) - new Date(a.startedAt||0)
129
+ );
130
+
131
+ // Fetch output for each (pipeline-style, batched to avoid overwhelming)
132
+ const withOutput = [];
133
+ const BATCH = 30;
134
+ for (let i = 0; i < allJobs.length; i += BATCH) {
135
+ const batch = allJobs.slice(i, i + BATCH);
136
+ const outputs = await Promise.all(batch.map(j => getOutputTail(j.id)));
137
+ batch.forEach((j, k) => withOutput.push({ ...j, lines: outputs[k] }));
138
+ }
139
+
140
+ return { namespaces, jobs: withOutput };
141
+ }
142
+
143
+ // ── File browser helpers ───────────────────────────────────────────────────
144
+ function mimeFor(ext) {
145
+ const map = {
146
+ js:'text/javascript', ts:'text/typescript', tsx:'text/typescript',
147
+ jsx:'text/javascript', py:'text/x-python', go:'text/x-go',
148
+ rs:'text/x-rust', md:'text/markdown', json:'application/json',
149
+ yaml:'text/yaml', yml:'text/yaml', sh:'text/x-sh', bash:'text/x-sh',
150
+ html:'text/html', css:'text/css', txt:'text/plain',
151
+ png:'image/png', jpg:'image/jpeg', jpeg:'image/jpeg', gif:'image/gif',
152
+ svg:'image/svg+xml', webp:'image/webp',
153
+ mp4:'video/mp4', webm:'video/webm', mov:'video/quicktime',
154
+ mp3:'audio/mpeg', wav:'audio/wav', ogg:'audio/ogg',
155
+ pdf:'application/pdf',
156
+ };
157
+ return map[ext] || 'application/octet-stream';
158
+ }
159
+
160
+ // ── HTTP server ────────────────────────────────────────────────────────────
161
+ const server = http.createServer((req, res) => {
162
+ const url = new URL(req.url, `http://localhost`);
163
+
164
+ if (url.pathname === '/' || url.pathname === '/index.html') {
165
+ try {
166
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
167
+ res.end(fs.readFileSync(UI_FILE));
168
+ } catch { res.writeHead(500); res.end('UI not found'); }
169
+
170
+ } else if (url.pathname === '/api/browse') {
171
+ // List directory or read file
172
+ const p = url.searchParams.get('path');
173
+ if (!p) { res.writeHead(400); res.end('missing path'); return; }
174
+ // Resolve ~ and normalize
175
+ const resolved = p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : path.resolve(p);
176
+ try {
177
+ const stat = fs.statSync(resolved);
178
+ if (stat.isDirectory()) {
179
+ const entries = fs.readdirSync(resolved, { withFileTypes: true }).map(e => ({
180
+ name: e.name,
181
+ type: e.isDirectory() ? 'dir' : 'file',
182
+ path: path.join(resolved, e.name),
183
+ size: e.isFile() ? (() => { try { return fs.statSync(path.join(resolved, e.name)).size; } catch { return 0; } })() : null,
184
+ })).sort((a,b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1));
185
+ res.writeHead(200, { 'Content-Type': 'application/json' });
186
+ res.end(JSON.stringify({ type: 'dir', path: resolved, entries }));
187
+ } else {
188
+ const ext = path.extname(resolved).slice(1).toLowerCase();
189
+ const mime = mimeFor(ext);
190
+ res.writeHead(200, { 'Content-Type': mime });
191
+ fs.createReadStream(resolved).pipe(res);
192
+ }
193
+ } catch (e) {
194
+ res.writeHead(404); res.end(e.message);
195
+ }
196
+
197
+ } else {
198
+ res.writeHead(404); res.end();
199
+ }
200
+ });
201
+
202
+ // ── WebSocket ──────────────────────────────────────────────────────────────
203
+ const wss = new WebSocketServer({ server });
204
+ wss.on('connection', async ws => {
205
+ clients.add(ws);
206
+ console.log(`[ws] client connected (total: ${clients.size})`);
207
+ try {
208
+ const snap = await buildSnapshot();
209
+ ws.send(JSON.stringify({ type: 'snapshot', ...snap }));
210
+ } catch (e) {
211
+ console.error('[snapshot]', e.message);
212
+ }
213
+ ws.on('close', () => { clients.delete(ws); });
214
+ });
215
+
216
+ // ── Polling: job status changes ────────────────────────────────────────────
217
+ setInterval(async () => {
218
+ try {
219
+ const namespaces = await getNamespaces();
220
+ for (const ns of namespaces) {
221
+ const ids = await getJobIds(ns);
222
+ const jobs = await fetchJobs(ids);
223
+ for (const job of jobs) {
224
+ job.namespace = ns;
225
+ const prev = jobCache[job.id];
226
+ if (!prev) {
227
+ // New job
228
+ jobCache[job.id] = job;
229
+ const lines = await getOutputTail(job.id, 50);
230
+ broadcast({ type: 'job_new', job: { ...job, lines } });
231
+ } else if (prev.status !== job.status) {
232
+ jobCache[job.id] = job;
233
+ broadcast({ type: 'job_update', job });
234
+ } else {
235
+ jobCache[job.id] = { ...prev, ...job };
236
+ }
237
+ }
238
+ }
239
+ } catch (e) {
240
+ console.error('[poll:status]', e.message);
241
+ }
242
+ }, 2500);
243
+
244
+ // ── Polling: output for active jobs ───────────────────────────────────────
245
+ setInterval(async () => {
246
+ const activeStatuses = new Set(['running', 'cloning', 'pending_approval']);
247
+ const active = Object.values(jobCache).filter(j => activeStatuses.has(j.status));
248
+ for (const job of active) {
249
+ try {
250
+ const lines = await pollNewOutput(job.id);
251
+ if (lines.length > 0) {
252
+ broadcast({ type: 'job_output', id: job.id, lines });
253
+ }
254
+ } catch {}
255
+ }
256
+ }, 900);
257
+
258
+ // ── Start ──────────────────────────────────────────────────────────────────
259
+ server.listen(PORT, '0.0.0.0', () => {
260
+ console.log(`\n cc-agent UI → http://0.0.0.0:${PORT}\n`);
261
+ const open = process.platform === 'darwin' ? 'open' : 'xdg-open';
262
+ setTimeout(() => exec(`${open} http://127.0.0.1:${PORT}`), 1000);
263
+ });