spectoflow 0.24.0 → 0.27.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/README.md +28 -6
- package/bin/spectoflow.js +88 -6
- package/lib/dashboard/connector.js +193 -0
- package/lib/dashboard/handlers.js +2 -27
- package/lib/dashboard/hub-server.js +103 -11
- package/lib/dashboard/inject-design.js +41 -0
- package/lib/dashboard/meeting.js +116 -0
- package/lib/dashboard/ops.js +83 -2
- package/lib/dashboard/public/app.js +738 -118
- package/lib/dashboard/public/charts.js +3 -3
- package/lib/dashboard/public/commands.js +77 -0
- package/lib/dashboard/public/designs/console.css +7 -19
- package/lib/dashboard/public/designs/orbit.css +3 -4
- package/lib/dashboard/public/designs.js +2 -2
- package/lib/dashboard/public/fonts/bricolage-grotesque-400.woff2 +0 -0
- package/lib/dashboard/public/fonts/bricolage-grotesque-600.woff2 +0 -0
- package/lib/dashboard/public/fonts/bricolage-grotesque-700.woff2 +0 -0
- package/lib/dashboard/public/hub.html +1 -1
- package/lib/dashboard/public/hub.js +27 -4
- package/lib/dashboard/public/i18n.js +60 -18
- package/lib/dashboard/public/icons.js +2 -0
- package/lib/dashboard/public/index.html +106 -13
- package/lib/dashboard/public/styles.css +161 -26
- package/lib/dashboard/public/vendor/prism/prism-bash.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-c.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-clike.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-core.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-cpp.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-csharp.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-css.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-docker.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-go.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-java.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-json.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-kotlin.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-markdown.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-markup-templating.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-markup.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-php.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-python.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-ruby.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-rust.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-sql.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-swift.min.js +1 -0
- package/lib/dashboard/public/vendor/prism/prism-yaml.min.js +1 -0
- package/lib/dashboard/routes.js +38 -0
- package/lib/dashboard/runner.js +7 -2
- package/lib/workspace.js +36 -1
- package/package.json +3 -3
- package/templates/config.json +23 -0
- package/lib/dashboard/public/fonts/space-grotesk-400.woff2 +0 -0
- package/lib/dashboard/public/fonts/space-grotesk-500.woff2 +0 -0
- package/lib/dashboard/public/fonts/space-grotesk-700.woff2 +0 -0
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
<p align="center">
|
|
13
13
|
<a href="https://www.npmjs.com/package/spectoflow"><img src="https://img.shields.io/npm/v/spectoflow.svg?color=e6a54b" alt="npm version"></a>
|
|
14
|
-
<img src="https://img.shields.io/badge/node-%3E%
|
|
14
|
+
<img src="https://img.shields.io/badge/node-%3E%3D22-5fb2cc" alt="node >= 22">
|
|
15
15
|
<img src="https://img.shields.io/badge/dependencies-0-4caf72" alt="zero dependencies">
|
|
16
16
|
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
|
|
17
17
|
</p>
|
|
@@ -186,24 +186,36 @@ spectoflow dashboard # → http://localhost:4319 (or --port=
|
|
|
186
186
|
dashboard already running on the port). `spectoflow status` tells you whether one is up. Zero
|
|
187
187
|
dependencies, updates live via SSE + file watching.
|
|
188
188
|
|
|
189
|
+
Want your own hosted, online dashboard instead of (or alongside) the local one? `server/` is a
|
|
190
|
+
separate, self-hostable relay with a ready-made Docker deployment path — see
|
|
191
|
+
[`server/docs/deploy-vps.md`](server/docs/deploy-vps.md).
|
|
192
|
+
|
|
189
193
|
The header bar always shows the brand, the **active agent**, autonomy mode, language, a global-progress
|
|
190
|
-
meter, a sync dot, and a **Run** quick-action.
|
|
194
|
+
meter, a sync dot, and a **Run** quick-action. Thirteen tabs — and **which ones you see, and in what
|
|
195
|
+
order, is up to you** (Personalize → *Navigation tabs*: enable / disable / reorder; two of them ship
|
|
196
|
+
off by default):
|
|
191
197
|
|
|
192
198
|
- **Board** — the control-room Overview (compact KPI cards, a status donut, a **scope-vs-delivered
|
|
193
199
|
area curve**, a workflow-at-a-glance strip, per-phase progress bars, filter chips + search) plus the
|
|
194
|
-
phase board
|
|
195
|
-
|
|
200
|
+
phase board, as a **List** or a **Kanban** whose columns you can show/hide and that pages long
|
|
201
|
+
columns instead of scrolling them.
|
|
202
|
+
- **Chat** — a full-height group-chat panel with **Summarize** / **Clear** and **slash commands** (see
|
|
203
|
+
below).
|
|
196
204
|
- **Requests** — tasks awaiting you (`to_validate` / `to_analyze`).
|
|
197
205
|
- **Attention** — points the agent raised (a `::spectoflow attention msg=…` sentinel) or that you
|
|
198
206
|
noted yourself — edit / resolve / delete, or **validate → task**.
|
|
199
207
|
- **Backlog** — a flat sortable/filterable, paginated table of every task, defaulting to open work.
|
|
200
208
|
- **Workflow** — the pipeline as step cards; click one to enable/disable it, which edits `workflow.md`.
|
|
201
209
|
- **Agents & Skills** — enriched cards that open a full-body markdown drawer.
|
|
210
|
+
- **Files** — a project file tree with read / write / create, syntax-highlighted (self-hosted Prism.js).
|
|
211
|
+
- **Bloc note** *(off by default)* — a per-project post-it Markdown scratchpad.
|
|
212
|
+
- **Daily meeting** *(off by default)* — dated notes (`.spectoflow/meetings/<date>.md`), written by
|
|
213
|
+
hand or generated by the active agent.
|
|
202
214
|
- **Info** — a project-at-a-glance summary.
|
|
203
215
|
- **Documentation** — the live supported-agents table (your own install status + links) plus the CLI
|
|
204
216
|
command reference.
|
|
205
|
-
- **Personalize** — autonomy mode, language, design, the active agent,
|
|
206
|
-
*Customize* below).
|
|
217
|
+
- **Personalize** — autonomy mode, language, design, the active agent, **navigation tabs**, **slash
|
|
218
|
+
commands**, and **Extend spectoflow** (see *Customize* below).
|
|
207
219
|
|
|
208
220
|
URLs are real routes (`/board`, `/backlog/T-012`, …). Charts are zero-dep, hand-rolled SVG in
|
|
209
221
|
`lib/dashboard/public/charts.js` (donut/area/bars/ring, animated, `prefers-reduced-motion`-aware), and
|
|
@@ -232,6 +244,16 @@ drawer is served by the one read-only endpoint, `GET /api/agentfile?path=` (scop
|
|
|
232
244
|
`.spectoflow/agents/**` + `.spectoflow/skills/**`, path-traversal-safe) — the framework's only other
|
|
233
245
|
server surface is unchanged.
|
|
234
246
|
|
|
247
|
+
### Slash commands
|
|
248
|
+
|
|
249
|
+
Type `/` in either chat surface to open an autocomplete of reusable **prompt macros** — pick one,
|
|
250
|
+
type your text, send. Five ship built-in (`/spec`, `/plan`, `/revue`, `/resume`, `/rapport_jour`);
|
|
251
|
+
add your own from **Personalize → Commands** (a trigger, a short description, and the instruction the
|
|
252
|
+
agent receives — put `{{input}}` where your text should land, or it's appended). Expansion happens
|
|
253
|
+
**client-side**, so it's fully agent-agnostic: the agent never sees the `/`, it gets the full
|
|
254
|
+
instruction, while the chat log keeps the short `/rapport_jour focus bugs` you typed. Commands persist
|
|
255
|
+
in `config.json` (the DB online), so they survive a refresh and travel with the project.
|
|
256
|
+
|
|
235
257
|
### Customize
|
|
236
258
|
|
|
237
259
|
Personalize → **Extend spectoflow** lets you extend the project's own spectoflow install: add a
|
package/bin/spectoflow.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const http = require('http');
|
|
6
|
+
const os = require('os');
|
|
6
7
|
const readline = require('readline');
|
|
7
8
|
const { spawn } = require('child_process');
|
|
8
9
|
const store = require('../lib/store');
|
|
@@ -212,7 +213,6 @@ function dashboardInit() {
|
|
|
212
213
|
} catch (e) { console.log(`${c.y('!')} ${e.message}`); process.exitCode = 1; }
|
|
213
214
|
}
|
|
214
215
|
|
|
215
|
-
const REMOTE_NOTE = 'This version manages local dashboards. Remote dashboards (login with a token) come in a later release — continuing with your local dashboard.';
|
|
216
216
|
function isLocalUrl(u) { try { return ['localhost', '127.0.0.1', '::1'].includes(new URL(u).hostname); } catch { return true; } }
|
|
217
217
|
// The one-time question every dashboard-starting command answers before doing anything else: which
|
|
218
218
|
// dashboard should this project talk to? `--url` answers it without prompting. Otherwise, only when
|
|
@@ -241,7 +241,6 @@ async function resolveDashboardUrl() {
|
|
|
241
241
|
globalConfig.set('dashboard.url', answer || globalConfig.get('dashboard.url').value);
|
|
242
242
|
}
|
|
243
243
|
const url = globalConfig.get('dashboard.url').value;
|
|
244
|
-
if (!isLocalUrl(url)) console.log(`${c.y('!')} ${REMOTE_NOTE}`);
|
|
245
244
|
return url;
|
|
246
245
|
}
|
|
247
246
|
|
|
@@ -250,7 +249,10 @@ async function resolveDashboardUrl() {
|
|
|
250
249
|
async function dashboard() {
|
|
251
250
|
const sub = argv[1];
|
|
252
251
|
if (sub === 'init') return dashboardInit();
|
|
253
|
-
if (sub === 'login')
|
|
252
|
+
if (sub === 'login') return dashboardLogin();
|
|
253
|
+
if (sub === 'logout') return dashboardLogout();
|
|
254
|
+
if (sub === 'publish') return dashboardPublish(true);
|
|
255
|
+
if (sub === 'unpublish') return dashboardPublish(false);
|
|
254
256
|
if (sub === 'stop') return stopDashboard();
|
|
255
257
|
if (sub === 'status') return dashboardStatus();
|
|
256
258
|
if (sub === 'restart') return restartDashboard();
|
|
@@ -259,6 +261,80 @@ async function dashboard() {
|
|
|
259
261
|
return startDashboard();
|
|
260
262
|
}
|
|
261
263
|
|
|
264
|
+
// ---- online dashboard (C1): login / logout / publish / unpublish ----
|
|
265
|
+
// The machine token authenticates this machine to a hosted dashboard (server/). `login` checks it
|
|
266
|
+
// against POST /connector/whoami, stores it in the workspace's remote.json (0600) and points
|
|
267
|
+
// dashboard.url at the server; the hub — running or next started — opens the outbound connection.
|
|
268
|
+
async function fetchJSON(url, init = {}, timeoutMs = 10000) {
|
|
269
|
+
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
|
|
270
|
+
let data = {}; try { data = await res.json(); } catch {}
|
|
271
|
+
return { status: res.status, ok: res.ok, data };
|
|
272
|
+
}
|
|
273
|
+
// Best-effort call on the running local hub; null when no hub is up (the caller says "next start will").
|
|
274
|
+
async function hubCall(pathname, payload) {
|
|
275
|
+
const info = workspace.readLock();
|
|
276
|
+
if (!info || !info.port || !(await probeDashboard(info.port))) return null;
|
|
277
|
+
try { return await fetchJSON(`http://localhost:${info.port}${pathname}`, payload === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); }
|
|
278
|
+
catch { return null; }
|
|
279
|
+
}
|
|
280
|
+
function fail(msg) { console.log(`${c.y('!')} ${msg}`); process.exitCode = 1; }
|
|
281
|
+
async function dashboardLogin() {
|
|
282
|
+
const url = flag('url'), token = flag('token'), transport = flag('transport') || 'ws';
|
|
283
|
+
if (!url || !token) { console.log(`Usage: spectoflow dashboard login --url=<https://…> --token=<spf_…> ${c.dim('[--name=<machine name>] [--transport=ws|http]')}`); process.exitCode = 1; return; }
|
|
284
|
+
let base;
|
|
285
|
+
try { base = new URL(url); if (!/^https?:$/.test(base.protocol)) throw new Error(); } catch { return fail('--url must be an http(s) URL, e.g. https://dashboard.example.com'); }
|
|
286
|
+
if (!['ws', 'http'].includes(transport)) return fail('--transport must be ws or http');
|
|
287
|
+
const clean = base.toString().replace(/\/+$/, '');
|
|
288
|
+
let who;
|
|
289
|
+
try { who = await fetchJSON(`${clean}/connector/whoami`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } }); }
|
|
290
|
+
catch (e) { return fail(`could not reach ${clean} (${e.cause && e.cause.code ? e.cause.code : e.name === 'TimeoutError' ? 'timeout' : e.message})`); }
|
|
291
|
+
if (who.status === 401) return fail(`${clean} rejected this token — create one on the server: node cli.js token create --name="${os.hostname()}"`);
|
|
292
|
+
if (!who.ok) return fail(`${clean} answered HTTP ${who.status} — is that the spectoflow server?`);
|
|
293
|
+
const machineName = flag('name') || os.hostname();
|
|
294
|
+
workspace.migrateLegacyHome(); if (!workspace.exists()) workspace.init({});
|
|
295
|
+
workspace.writeRemote({ url: clean, token, machineName, transport });
|
|
296
|
+
globalConfig.set('dashboard.url', clean);
|
|
297
|
+
console.log(`${c.g('✓')} logged in to ${c.bold(clean)} as ${c.bold(machineName)} ${c.dim(`(machine ${who.data.machineId || '?'}, ${transport})`)}`);
|
|
298
|
+
const r = await hubCall('/api/hub/remote/reconnect', {});
|
|
299
|
+
console.log(` ${c.dim(r ? 'the running hub is connecting now' : 'the next `spectoflow dashboard` will connect')}`);
|
|
300
|
+
console.log(` publish a project from inside it: ${c.g('spectoflow dashboard publish')}`);
|
|
301
|
+
}
|
|
302
|
+
async function dashboardLogout() {
|
|
303
|
+
const had = workspace.clearRemote();
|
|
304
|
+
globalConfig.set('dashboard.url', `http://localhost:${resolvePort(argv)}`);
|
|
305
|
+
await hubCall('/api/hub/remote/reconnect', {});
|
|
306
|
+
console.log(had ? `${c.g('✓')} logged out — this machine no longer connects to an online dashboard` : `${c.dim('○')} not logged in to any online dashboard`);
|
|
307
|
+
}
|
|
308
|
+
async function dashboardPublish(published) {
|
|
309
|
+
let id = flag('id');
|
|
310
|
+
if (!id) {
|
|
311
|
+
const entry = registry.findByPath(process.cwd());
|
|
312
|
+
if (!entry) return fail(`this folder isn't registered in the dashboard yet — run ${c.g('spectoflow dashboard')} here first, or pass --id=<id> (see ${c.g('spectoflow projects')})`);
|
|
313
|
+
id = entry.id;
|
|
314
|
+
}
|
|
315
|
+
const meta = workspace.setPublished(id, published);
|
|
316
|
+
if (!meta) return fail(`no project registered with id ${id}`);
|
|
317
|
+
await hubCall(`/api/hub/projects/${encodeURIComponent(id)}/publish`, { published });
|
|
318
|
+
if (!published) return console.log(`${c.g('✓')} ${id} is no longer published`);
|
|
319
|
+
console.log(`${c.g('✓')} ${id} is published`);
|
|
320
|
+
const remote = workspace.readRemote();
|
|
321
|
+
if (remote) console.log(` online: ${c.bold(remote.url)}`);
|
|
322
|
+
else console.log(` ${c.dim('not logged in yet —')} ${c.g('spectoflow dashboard login --url=… --token=…')}`);
|
|
323
|
+
}
|
|
324
|
+
// The one-line "online" status under the local one — used by `dashboard` and `dashboard status`.
|
|
325
|
+
async function printOnlineLine(port, running) {
|
|
326
|
+
const remote = workspace.readRemote();
|
|
327
|
+
if (remote) {
|
|
328
|
+
let s = null;
|
|
329
|
+
if (running) { try { s = (await fetchJSON(`http://localhost:${port}/api/hub/remote`)).data; } catch {} }
|
|
330
|
+
if (s && s.connected) return console.log(`${c.g('●')} online → ${c.bold(remote.url)} ${c.dim(`(connected via ${s.transport}, machine "${remote.machineName}")`)}`);
|
|
331
|
+
const why = !running ? 'hub not running' : s && s.lastError ? `not connected: ${s.lastError}` : 'connecting…';
|
|
332
|
+
return console.log(`${c.dim('○')} online → ${c.bold(remote.url)} ${c.dim(`(${why})`)}`);
|
|
333
|
+
}
|
|
334
|
+
const url = globalConfig.get('dashboard.url').value;
|
|
335
|
+
if (!isLocalUrl(url)) console.log(`${c.dim('○')} online → ${url} ${c.dim('— not logged in:')} ${c.g('spectoflow dashboard login --url=… --token=…')}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
262
338
|
// ---- projects: the multi-project registry's CLI surface (the workspace's projects.json) ----
|
|
263
339
|
function projectsCmd() {
|
|
264
340
|
const sub = argv[1];
|
|
@@ -361,6 +437,7 @@ async function startDashboard() {
|
|
|
361
437
|
const info = workspace.readLock();
|
|
362
438
|
if (info && info.port && await probeDashboard(info.port)) {
|
|
363
439
|
console.log(`${c.g('●')} hub already running → ${c.bold(boardUrl(info.port))}`);
|
|
440
|
+
await printOnlineLine(info.port, true);
|
|
364
441
|
return printDashboardCommands();
|
|
365
442
|
}
|
|
366
443
|
const port = resolvePort(argv);
|
|
@@ -374,6 +451,7 @@ async function startDashboard() {
|
|
|
374
451
|
for (let i = 0; i < 20 && !up; i++) { await new Promise((r) => setTimeout(r, 250)); up = await probeDashboard(port, 300); }
|
|
375
452
|
if (up) console.log(`${c.g('✓')} hub started → ${c.bold(boardUrl(port))} ${c.dim('(pid ' + child.pid + ')')}`);
|
|
376
453
|
else console.log(`${c.y('!')} spawned (pid ${child.pid}) but it isn't responding on http://localhost:${port} yet — check ${c.g('spectoflow dashboard status')} in a moment, or its own output if something's wrong.`);
|
|
454
|
+
await printOnlineLine(port, up);
|
|
377
455
|
printDashboardCommands();
|
|
378
456
|
}
|
|
379
457
|
|
|
@@ -392,6 +470,7 @@ async function dashboardStatus() {
|
|
|
392
470
|
const running = await probeDashboard(port);
|
|
393
471
|
if (running) console.log(`${c.g('●')} hub running → ${c.bold('http://localhost:' + port)}${info && info.pid ? c.dim(' (pid ' + info.pid + ')') : ''}`);
|
|
394
472
|
else console.log(`${c.dim('○')} hub not running`);
|
|
473
|
+
await printOnlineLine(port, running);
|
|
395
474
|
}
|
|
396
475
|
|
|
397
476
|
async function restartDashboard() {
|
|
@@ -504,7 +583,7 @@ ${c.bold('Dashboard')}
|
|
|
504
583
|
${c.g('dashboard status')} is it running? (url + pid)
|
|
505
584
|
${c.g('dashboard stop')} stop it ${c.dim('(alias: stop)')}
|
|
506
585
|
${c.g('dashboard restart')} stop then start
|
|
507
|
-
${c.g('dashboard login')}
|
|
586
|
+
${c.g('dashboard login')} ${c.dim('--url=<u> --token=<t>')} connect this machine to an online dashboard ${c.dim('(logout · publish · unpublish)')}
|
|
508
587
|
${c.g('projects')} ${c.dim('[remove <id>]')} list every project seen so far
|
|
509
588
|
|
|
510
589
|
${c.bold('Customize')} ${c.dim('— same as Settings → Customize, from the terminal')}
|
|
@@ -542,7 +621,7 @@ const HELP = {
|
|
|
542
621
|
${c.g('--force')} (${c.g('-f')}) overwrites a diverged file in place instead of dropping a ${c.dim('*.new')}
|
|
543
622
|
— use it when you know you have no local edits worth keeping (e.g. a file stuck diverged from an
|
|
544
623
|
earlier update). It never touches config.json, workflow.md, specs/ or plans/.`,
|
|
545
|
-
dashboard: `${c.bold('spectoflow dashboard')} ${c.dim('[--port=NNNN] [--url=<u>] [init|status|stop|restart|create|validate|login]')}\n
|
|
624
|
+
dashboard: `${c.bold('spectoflow dashboard')} ${c.dim('[--port=NNNN] [--url=<u>] [init|status|stop|restart|create|validate|login|logout|publish|unpublish]')}\n
|
|
546
625
|
Start the local control plane in the ${c.bold('background')} (default ${c.dim('4319')} or
|
|
547
626
|
${c.dim('$SPECTOFLOW_PORT')}) and hand the prompt back. ${c.g('--url=<u>')} sets which dashboard this
|
|
548
627
|
project talks to (${c.dim('~/.spectoflow/config.json → dashboard.url')}) — asked once, interactively,
|
|
@@ -553,7 +632,10 @@ const HELP = {
|
|
|
553
632
|
${c.g('restart')} stop then start
|
|
554
633
|
${c.g('create')} generate a custom dashboard, e.g. ${c.dim('spectoflow dashboard create "..." --auto')}
|
|
555
634
|
${c.g('validate <file>')} check a custom-view JSON against the block schema
|
|
556
|
-
${c.g('login')} connect to
|
|
635
|
+
${c.g('login')} connect this machine to an online dashboard: ${c.dim('--url=<https://…> --token=<spf_…> [--name=<machine>] [--transport=ws|http]')}
|
|
636
|
+
${c.g('logout')} forget it (remote.json removed, dashboard.url back to local)
|
|
637
|
+
${c.g('publish')} make the current project (or ${c.dim('--id=<id>')}) visible online — nothing is published by default
|
|
638
|
+
${c.g('unpublish')} take it back offline`,
|
|
557
639
|
projects: `${c.bold('spectoflow projects')} ${c.dim('[remove <id>]')}\n
|
|
558
640
|
List every registered project in the global registry (the dashboard workspace's projects.json) (stored by
|
|
559
641
|
${c.g('spectoflow dashboard')}) — id, name, path. ${c.g('remove <id>')} drops one (e.g. a project that moved
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* The local hub's outbound connector to an online dashboard (docs/online-dashboard-connector-design.md §2).
|
|
4
|
+
* One connection per machine; JSON frames, identical on both transports:
|
|
5
|
+
* up: auth {token,version} · hello {machineName,projects} · event {p,event} · snapshot {p,project} · reply {reqId,result|error} · pong
|
|
6
|
+
* down: auth-ok {machineId} · op {reqId,p,op,args} · ping
|
|
7
|
+
* Transport 1 is Node's native WebSocket client (zero-dep, Node ≥ 22); transport 2 is HTTP long-poll
|
|
8
|
+
* (POST batches + GET held ≤ 25 s) for hosts that don't pass WebSockets (cPanel/Passenger). The hub
|
|
9
|
+
* decides nothing here: it hands in listPublished/readSnapshot/execOp and tees its emit into
|
|
10
|
+
* pushEvent/pushSnapshot. Reconnection: exponential backoff 1 s → 30 s with jitter; after an upgrade
|
|
11
|
+
* failure or 3 drops in 60 s the connector falls back to HTTP and retries WebSocket every 10 min.
|
|
12
|
+
* The token travels in-band (auth frame / Authorization header) — never in a URL, never logged.
|
|
13
|
+
*/
|
|
14
|
+
const DEFAULT_TIMING = { backoffMin: 1000, backoffMax: 30000, dropWindow: 60000, dropLimit: 3, wsRetryEvery: 600000, pollTimeout: 35000, wsOpenTimeout: 15000 };
|
|
15
|
+
const REJECTED = 'token rejected by the server — run `spectoflow dashboard login` again';
|
|
16
|
+
|
|
17
|
+
function createConnector(opts) {
|
|
18
|
+
const { token, machineName, listPublished, readSnapshot, execOp } = opts;
|
|
19
|
+
const version = opts.version || '0.0.0';
|
|
20
|
+
const base = String(opts.url || '').replace(/\/+$/, '');
|
|
21
|
+
const preferred = opts.transport === 'http' ? 'http' : 'ws';
|
|
22
|
+
const timing = { ...DEFAULT_TIMING, ...(opts.timing || {}) };
|
|
23
|
+
const log = opts.log || (() => {});
|
|
24
|
+
|
|
25
|
+
const state = { connected: false, lastError: null, machineId: null, attempts: 0, since: null };
|
|
26
|
+
let mode = preferred; // transport in use ('ws' | 'http'); may fall back to 'http'
|
|
27
|
+
let stopped = true;
|
|
28
|
+
let active = null; // the live transport: { send(frame), close() }
|
|
29
|
+
let generation = 0; // bumps on every connect/stop so a stale transport's callbacks are ignored
|
|
30
|
+
const drops = [];
|
|
31
|
+
let reconnectTimer = null, wsRetryTimer = null;
|
|
32
|
+
|
|
33
|
+
function status() {
|
|
34
|
+
return { url: base, machineName, connected: state.connected, transport: mode, lastError: state.lastError, machineId: state.machineId, since: state.since };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---- frames ----
|
|
38
|
+
async function handleDownward(frame, send) {
|
|
39
|
+
if (!frame || typeof frame !== 'object') return;
|
|
40
|
+
if (frame.type === 'auth-ok') { state.machineId = frame.machineId || null; onConnected(); return; }
|
|
41
|
+
if (frame.type === 'ping') { send({ type: 'pong' }); return; }
|
|
42
|
+
if (frame.type === 'op') {
|
|
43
|
+
let reply;
|
|
44
|
+
try {
|
|
45
|
+
const result = await execOp(frame.p, frame.op, frame.args || {});
|
|
46
|
+
reply = { type: 'reply', reqId: frame.reqId, result: result === undefined ? {} : result };
|
|
47
|
+
} catch (e) {
|
|
48
|
+
reply = { type: 'reply', reqId: frame.reqId, error: { status: Number(e && e.status) || 500, message: String(e && e.message || e) } };
|
|
49
|
+
}
|
|
50
|
+
send(reply);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function onConnected() {
|
|
54
|
+
state.connected = true; state.lastError = null; state.attempts = 0; state.since = new Date().toISOString();
|
|
55
|
+
log(`online dashboard: connected to ${base} (${mode})`);
|
|
56
|
+
announce();
|
|
57
|
+
}
|
|
58
|
+
async function announce() {
|
|
59
|
+
const t = active;
|
|
60
|
+
if (!t || !state.connected) return;
|
|
61
|
+
const projects = listPublished();
|
|
62
|
+
t.send({ type: 'hello', machineName, projects });
|
|
63
|
+
for (const p of projects) {
|
|
64
|
+
try { const project = await readSnapshot(p.localId); if (project && t === active) t.send({ type: 'snapshot', p: p.localId, project }); }
|
|
65
|
+
catch (e) { log(`online dashboard: snapshot of ${p.localId} failed: ${e.message}`); }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function pushEvent(localId, event) { if (active && state.connected) active.send({ type: 'event', p: localId, event }); }
|
|
69
|
+
function pushSnapshot(localId, project) { if (active && state.connected) active.send({ type: 'snapshot', p: localId, project }); }
|
|
70
|
+
|
|
71
|
+
// ---- lifecycle ----
|
|
72
|
+
function onDisconnected(gen, err, { upgradeFailed = false } = {}) {
|
|
73
|
+
if (gen !== generation) return; // a transport we already replaced
|
|
74
|
+
const was = state.connected;
|
|
75
|
+
state.connected = false; active = null;
|
|
76
|
+
if (err) state.lastError = String(err.message || err);
|
|
77
|
+
if (was) log(`online dashboard: disconnected from ${base}${err ? ' — ' + state.lastError : ''}`);
|
|
78
|
+
if (stopped) return;
|
|
79
|
+
if (mode === 'ws' && preferred === 'ws') {
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
drops.push(now); while (drops.length && now - drops[0] > timing.dropWindow) drops.shift();
|
|
82
|
+
if (upgradeFailed || drops.length >= timing.dropLimit) {
|
|
83
|
+
mode = 'http'; drops.length = 0;
|
|
84
|
+
log('online dashboard: WebSocket unavailable, falling back to HTTP long-poll');
|
|
85
|
+
scheduleWsRetry();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
scheduleReconnect();
|
|
89
|
+
}
|
|
90
|
+
function backoff() {
|
|
91
|
+
const exp = Math.min(timing.backoffMax, timing.backoffMin * 2 ** Math.min(state.attempts, 10));
|
|
92
|
+
state.attempts++;
|
|
93
|
+
return Math.round(exp * (0.75 + Math.random() * 0.5));
|
|
94
|
+
}
|
|
95
|
+
function scheduleReconnect() { clearTimeout(reconnectTimer); reconnectTimer = setTimeout(connect, backoff()); }
|
|
96
|
+
function scheduleWsRetry() {
|
|
97
|
+
clearTimeout(wsRetryTimer);
|
|
98
|
+
wsRetryTimer = setTimeout(() => { if (!stopped && mode === 'http') { mode = 'ws'; forceReconnect(); } }, timing.wsRetryEvery);
|
|
99
|
+
}
|
|
100
|
+
function connect() {
|
|
101
|
+
if (stopped) return;
|
|
102
|
+
clearTimeout(reconnectTimer);
|
|
103
|
+
const gen = ++generation;
|
|
104
|
+
active = mode === 'http' ? httpTransport(gen) : wsTransport(gen);
|
|
105
|
+
}
|
|
106
|
+
function start() { if (!stopped) return; stopped = false; mode = preferred; state.attempts = 0; connect(); }
|
|
107
|
+
function stop() {
|
|
108
|
+
stopped = true; generation++;
|
|
109
|
+
clearTimeout(reconnectTimer); clearTimeout(wsRetryTimer);
|
|
110
|
+
const t = active; active = null; state.connected = false;
|
|
111
|
+
if (t) t.close();
|
|
112
|
+
}
|
|
113
|
+
function forceReconnect() {
|
|
114
|
+
if (stopped) return;
|
|
115
|
+
const t = active; generation++; active = null; state.connected = false; state.attempts = 0;
|
|
116
|
+
if (t) t.close();
|
|
117
|
+
connect();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- transport 1: native WebSocket ----
|
|
121
|
+
// A handshake that never resolves (no open/error/close at all — observed on some platforms when
|
|
122
|
+
// the server rejects the upgrade with a plain HTTP response) must still fail: wsOpenTimeout bounds
|
|
123
|
+
// it so the connector always falls back to HTTP instead of hanging forever in 'ws' mode.
|
|
124
|
+
function wsTransport(gen) {
|
|
125
|
+
let ws, opened = false, settled = false, lastErr = null;
|
|
126
|
+
const settle = (err, opts) => { if (settled) return; settled = true; clearTimeout(openTimer); onDisconnected(gen, err, opts); };
|
|
127
|
+
try { ws = new WebSocket(base.replace(/^http/, 'ws') + '/connector/ws'); }
|
|
128
|
+
catch (e) { setImmediate(() => settle(e, { upgradeFailed: true })); return { send() {}, close() {} }; }
|
|
129
|
+
const openTimer = setTimeout(() => {
|
|
130
|
+
try { ws.close(); } catch (_) {}
|
|
131
|
+
settle(new Error('WebSocket handshake timed out'), { upgradeFailed: true });
|
|
132
|
+
}, timing.wsOpenTimeout);
|
|
133
|
+
const send = (frame) => { if (ws.readyState === 1) ws.send(JSON.stringify(frame)); };
|
|
134
|
+
ws.addEventListener('open', () => { opened = true; clearTimeout(openTimer); send({ type: 'auth', token, version }); });
|
|
135
|
+
ws.addEventListener('message', (ev) => { let f; try { f = JSON.parse(String(ev.data)); } catch { return; } handleDownward(f, send); });
|
|
136
|
+
ws.addEventListener('error', (ev) => { lastErr = new Error((ev && ev.message) || 'websocket error'); });
|
|
137
|
+
ws.addEventListener('close', (ev) => {
|
|
138
|
+
const code = ev && ev.code;
|
|
139
|
+
const err = code === 4401 ? new Error(REJECTED) : lastErr || (code && code !== 1000 && code !== 1005 ? new Error(`connection closed (${code}${ev.reason ? ' ' + ev.reason : ''})`) : null);
|
|
140
|
+
settle(err, { upgradeFailed: !opened });
|
|
141
|
+
});
|
|
142
|
+
return { send, close: () => { clearTimeout(openTimer); try { ws.close(1000); } catch (_) {} } };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---- transport 2: HTTP long-poll ----
|
|
146
|
+
// Upward frames are POSTed in batches, strictly one batch in flight at a time (ordering). Downward
|
|
147
|
+
// frames arrive in the POST responses and through a GET loop the server holds open ≤ 25 s. The first
|
|
148
|
+
// batch carries `auth` so the frame sequence is the same as on WebSocket; the Authorization header is
|
|
149
|
+
// what actually authenticates every request.
|
|
150
|
+
function httpTransport(gen) {
|
|
151
|
+
const ac = new AbortController();
|
|
152
|
+
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
|
153
|
+
let queue = [{ type: 'auth', token, version }];
|
|
154
|
+
let flushing = false, closed = false;
|
|
155
|
+
const alive = () => !closed && gen === generation;
|
|
156
|
+
const signal = () => AbortSignal.any([ac.signal, AbortSignal.timeout(timing.pollTimeout)]);
|
|
157
|
+
const fail = (e) => { if (!alive()) return; closed = true; ac.abort(); onDisconnected(gen, e); };
|
|
158
|
+
const check = (res, what) => {
|
|
159
|
+
if (res.status === 401) throw new Error(REJECTED);
|
|
160
|
+
if (!res.ok) throw new Error(`${what} /connector/frames → HTTP ${res.status}`);
|
|
161
|
+
return res.json();
|
|
162
|
+
};
|
|
163
|
+
async function flush() {
|
|
164
|
+
if (flushing) return;
|
|
165
|
+
flushing = true;
|
|
166
|
+
try {
|
|
167
|
+
while (alive() && queue.length) {
|
|
168
|
+
const batch = queue; queue = [];
|
|
169
|
+
const res = await fetch(base + '/connector/frames', { method: 'POST', headers, body: JSON.stringify(batch), signal: signal() });
|
|
170
|
+
const down = await check(res, 'POST');
|
|
171
|
+
for (const f of down) handleDownward(f, send); // not awaited: a slow op must not stall the batch loop
|
|
172
|
+
}
|
|
173
|
+
} catch (e) { fail(e); } finally { flushing = false; }
|
|
174
|
+
}
|
|
175
|
+
async function poll() {
|
|
176
|
+
try {
|
|
177
|
+
while (alive()) {
|
|
178
|
+
const res = await fetch(base + '/connector/frames', { headers: { Authorization: headers.Authorization }, signal: signal() });
|
|
179
|
+
if (!alive()) return;
|
|
180
|
+
const down = await check(res, 'GET');
|
|
181
|
+
for (const f of down) handleDownward(f, send); // not awaited: a slow op must not stall the batch loop
|
|
182
|
+
}
|
|
183
|
+
} catch (e) { fail(e); }
|
|
184
|
+
}
|
|
185
|
+
function send(frame) { if (!alive()) return; queue.push(frame); flush(); }
|
|
186
|
+
flush(); poll();
|
|
187
|
+
return { send, close: () => { closed = true; ac.abort(); } };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { start, stop, status, announce, pushEvent, pushSnapshot, forceReconnect };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = { createConnector, DEFAULT_TIMING };
|
|
@@ -18,37 +18,12 @@ const orchestrator = require('./orchestrator');
|
|
|
18
18
|
function sendJSON(res, code, obj) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(obj)); }
|
|
19
19
|
function body(req) { return new Promise((r) => { let b = ''; req.on('data', (c) => b += c); req.on('end', () => { try { r(JSON.parse(b || '{}')); } catch { r({}); } }); }); }
|
|
20
20
|
|
|
21
|
-
const
|
|
22
|
-
const q = (u, k) => u.searchParams.get(k) || '';
|
|
23
|
-
// [method, matcher, op, args(u, body, pathname)]
|
|
24
|
-
const ROUTES = [
|
|
25
|
-
['GET', '/api/project', 'project.read', () => ({})],
|
|
26
|
-
['GET', '/api/agentfile', 'agentfile.read', (u) => ({ path: q(u, 'path') })],
|
|
27
|
-
['GET', '/api/files/tree', 'files.tree', () => ({})],
|
|
28
|
-
['GET', '/api/files/read', 'files.read', (u) => ({ path: q(u, 'path') })],
|
|
29
|
-
['POST', '/api/files/write', 'files.write', (_u, b) => b],
|
|
30
|
-
['POST', '/api/files/mkdir', 'files.mkdir', (_u, b) => b],
|
|
31
|
-
['POST', '/api/task', 'task.add', (_u, b) => b],
|
|
32
|
-
['PATCH', /^\/api\/task\/[^/]+$/, 'task.update', (_u, b, p) => ({ id: seg(p, 3), patch: b })],
|
|
33
|
-
['POST', /^\/api\/task\/[^/]+\/comment$/, 'task.comment', (_u, b, p) => ({ id: seg(p, 3), text: b.text, action: b.action })],
|
|
34
|
-
['POST', '/api/workflow/toggle', 'workflow.toggle', (_u, b) => b],
|
|
35
|
-
['POST', '/api/run', 'run.start', (_u, b) => b],
|
|
36
|
-
['POST', '/api/chat/summarize', 'chat.summarize', (_u, b) => b],
|
|
37
|
-
['POST', '/api/chat/clear', 'chat.clear', () => ({})],
|
|
38
|
-
['POST', '/api/orchestrate', 'orchestrate.start', (_u, b) => b],
|
|
39
|
-
['POST', '/api/orchestrate/approve', 'orchestrate.approve', (_u, b) => b],
|
|
40
|
-
['POST', '/api/settings', 'settings.save', (_u, b) => b],
|
|
41
|
-
['POST', '/api/attention', 'attention.add', (_u, b) => b],
|
|
42
|
-
['POST', /^\/api\/attention\/[^/]+\/promote$/, 'attention.promote', (_u, _b, p) => ({ id: seg(p, 3) })],
|
|
43
|
-
['PATCH', /^\/api\/attention\/[^/]+$/, 'attention.update', (_u, b, p) => ({ id: seg(p, 3), patch: b })],
|
|
44
|
-
['DELETE', /^\/api\/attention\/[^/]+$/, 'attention.remove', (_u, _b, p) => ({ id: seg(p, 3) })],
|
|
45
|
-
];
|
|
46
|
-
const matches = (m, p) => (typeof m === 'string' ? m === p : m.test(p));
|
|
21
|
+
const { ROUTES, findRoute } = require('./routes');
|
|
47
22
|
|
|
48
23
|
function createHandlers(root) {
|
|
49
24
|
async function handleApi(req, res, u, emit) {
|
|
50
25
|
const p = u.pathname;
|
|
51
|
-
const route =
|
|
26
|
+
const route = findRoute(req.method, p);
|
|
52
27
|
if (!route) return false;
|
|
53
28
|
const [, , opName, args] = route;
|
|
54
29
|
const b = req.method === 'GET' ? {} : await body(req);
|