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
|
@@ -25,6 +25,9 @@ const registry = require('../registry');
|
|
|
25
25
|
const workspace = require('../workspace');
|
|
26
26
|
const { createHandlers } = require('./handlers');
|
|
27
27
|
const store = require('../store');
|
|
28
|
+
const { createConnector } = require('./connector');
|
|
29
|
+
const { ops, OpError } = require('./ops');
|
|
30
|
+
const { injectDesign, DEFAULT_DESIGN } = require('./inject-design');
|
|
28
31
|
|
|
29
32
|
const migrated = workspace.migrateLegacyHome();
|
|
30
33
|
if (!workspace.exists()) workspace.init({});
|
|
@@ -40,18 +43,79 @@ function body(req) { return new Promise((r) => { let b = ''; req.on('data', (c)
|
|
|
40
43
|
// handlers.js for every project (D64): nothing is ever require()'d from a project, so a project that
|
|
41
44
|
// has never run `spectoflow update` opens exactly like a fresh one.
|
|
42
45
|
const projects = new Map();
|
|
46
|
+
|
|
47
|
+
// ---- online dashboard (C1): the connector role ----
|
|
48
|
+
// One outbound connection to the online dashboard named in the workspace's remote.json (written by
|
|
49
|
+
// `spectoflow dashboard login`). Only published projects (meta.json → published:true, cached here as
|
|
50
|
+
// a Set so a chatty run-line stream never re-reads the file) are announced and relayed. Every project's
|
|
51
|
+
// emit is teed: SSE clients first, then the connector — the same emit an op called from the relay
|
|
52
|
+
// uses, so a local click and an online click hit one orchestrator, one pending gate, one state.
|
|
53
|
+
let connector = null;
|
|
54
|
+
let publishedIds = new Set();
|
|
55
|
+
function refreshPublished() { publishedIds = new Set(workspace.listPublished().map((p) => p.id)); }
|
|
56
|
+
const snapshotTimers = new Map();
|
|
57
|
+
function scheduleSnapshot(id) {
|
|
58
|
+
clearTimeout(snapshotTimers.get(id));
|
|
59
|
+
snapshotTimers.set(id, setTimeout(async () => {
|
|
60
|
+
snapshotTimers.delete(id);
|
|
61
|
+
if (!connector || !publishedIds.has(id)) return;
|
|
62
|
+
try { const project = await readSnapshot(id); if (project) connector.pushSnapshot(id, project); } catch (_) {}
|
|
63
|
+
}, 500));
|
|
64
|
+
}
|
|
65
|
+
async function readSnapshot(id) {
|
|
66
|
+
const proj = getProject(id);
|
|
67
|
+
return proj ? ops['project.read'](proj.root, {}, { emit() {} }) : null;
|
|
68
|
+
}
|
|
69
|
+
function listPublishedForHello() {
|
|
70
|
+
return workspace.listPublished().map((p) => ({ localId: p.id, name: p.name, kind: p.kind || 'spectoflow', stats: projectStats(p.path) }));
|
|
71
|
+
}
|
|
72
|
+
async function execOp(localId, op, args) {
|
|
73
|
+
if (!publishedIds.has(localId)) throw new OpError(403, 'This project is not published.');
|
|
74
|
+
const proj = getProject(localId);
|
|
75
|
+
if (!proj) throw new OpError(404, projectErrorMessage(localId));
|
|
76
|
+
if (!Object.prototype.hasOwnProperty.call(ops, op)) throw new OpError(404, `Unknown operation "${op}".`);
|
|
77
|
+
return ops[op](proj.root, args || {}, { emit: proj.emit });
|
|
78
|
+
}
|
|
79
|
+
function startConnector() {
|
|
80
|
+
if (connector) { connector.stop(); connector = null; }
|
|
81
|
+
refreshPublished();
|
|
82
|
+
const remote = workspace.readRemote();
|
|
83
|
+
if (!remote) return;
|
|
84
|
+
connector = createConnector({
|
|
85
|
+
url: remote.url, token: remote.token, machineName: remote.machineName || os.hostname(), transport: remote.transport, version: VERSION,
|
|
86
|
+
listPublished: listPublishedForHello, readSnapshot, execOp, log: (m) => console.log(`spectoflow · ${m}`),
|
|
87
|
+
});
|
|
88
|
+
connector.start();
|
|
89
|
+
}
|
|
90
|
+
function remoteStatus() {
|
|
91
|
+
const remote = workspace.readRemote();
|
|
92
|
+
if (!remote) return { configured: false, url: null, machineName: null, connected: false, transport: null, lastError: null, since: null };
|
|
93
|
+
const s = connector ? connector.status() : { connected: false, transport: remote.transport, lastError: null, since: null };
|
|
94
|
+
return { configured: true, url: remote.url, machineName: remote.machineName, connected: s.connected, transport: s.transport, lastError: s.lastError, since: s.since };
|
|
95
|
+
}
|
|
96
|
+
|
|
43
97
|
function getProject(id) {
|
|
44
98
|
if (projects.has(id)) return projects.get(id);
|
|
45
99
|
const entry = registry.listProjects().find((p) => p.id === id);
|
|
46
100
|
if (!entry || !fs.existsSync(entry.path)) return null;
|
|
47
101
|
const handlers = createHandlers(entry.path);
|
|
48
102
|
const clients = new Set();
|
|
49
|
-
const emit = (obj) => {
|
|
103
|
+
const emit = (obj) => {
|
|
104
|
+
const line = 'data: ' + JSON.stringify(obj) + '\n\n';
|
|
105
|
+
for (const res of clients) res.write(line);
|
|
106
|
+
if (connector && publishedIds.has(id)) { connector.pushEvent(id, obj); if (obj.type === 'change') scheduleSnapshot(id); }
|
|
107
|
+
};
|
|
50
108
|
handlers.onBoot();
|
|
51
109
|
const watchers = [];
|
|
110
|
+
// A single logical write (e.g. store.js's write-then-rename) can fire an fs.watch directory
|
|
111
|
+
// listener several times in a row for one underlying change (worst on Windows, which also reports
|
|
112
|
+
// sibling-entry metadata churn) — coalesce any burst within 150ms into exactly one 'change' emit,
|
|
113
|
+
// so a published project's connector feed gets one 'event' frame per real change, not several.
|
|
114
|
+
let changeTimer = null;
|
|
115
|
+
const onDirChange = () => { clearTimeout(changeTimer); changeTimer = setTimeout(() => emit({ type: 'change' }), 150); };
|
|
52
116
|
handlers.watchDirs.forEach((d) => {
|
|
53
117
|
const dir = path.join(entry.path, d);
|
|
54
|
-
if (fs.existsSync(dir)) { try { watchers.push(fs.watch(dir, { recursive: false },
|
|
118
|
+
if (fs.existsSync(dir)) { try { watchers.push(fs.watch(dir, { recursive: false }, onDirChange)); } catch (_) {} }
|
|
55
119
|
});
|
|
56
120
|
const proj = { id, root: entry.path, handlers, clients, emit, watchers };
|
|
57
121
|
projects.set(id, proj);
|
|
@@ -87,7 +151,7 @@ function projectStats(root) {
|
|
|
87
151
|
} catch { return null; }
|
|
88
152
|
}
|
|
89
153
|
function listHubProjects() {
|
|
90
|
-
return registry.listProjects().map((p) => ({ ...p, stats: projectStats(p.path) }));
|
|
154
|
+
return registry.listProjects().map((p) => ({ ...p, stats: projectStats(p.path), published: publishedIds.has(p.id) }));
|
|
91
155
|
}
|
|
92
156
|
function listRoots() {
|
|
93
157
|
const home = os.homedir();
|
|
@@ -129,7 +193,7 @@ function addHubProject(rawPath) {
|
|
|
129
193
|
}
|
|
130
194
|
async function handleHubApi(req, res, u) {
|
|
131
195
|
const p = u.pathname;
|
|
132
|
-
if (p === '/api/hub/projects' && req.method === 'GET') { sendJSON(res, 200, { projects: listHubProjects() }); return true; }
|
|
196
|
+
if (p === '/api/hub/projects' && req.method === 'GET') { sendJSON(res, 200, { projects: listHubProjects(), remote: remoteStatus() }); return true; }
|
|
133
197
|
if (p === '/api/hub/projects' && req.method === 'POST') {
|
|
134
198
|
const { path: rawPath } = await body(req);
|
|
135
199
|
if (!rawPath || !String(rawPath).trim()) { sendJSON(res, 400, { error: 'A folder path is required.' }); return true; }
|
|
@@ -140,6 +204,7 @@ async function handleHubApi(req, res, u) {
|
|
|
140
204
|
if (/^\/api\/hub\/projects\/[^/]+$/.test(p) && req.method === 'DELETE') {
|
|
141
205
|
const id = decodeURIComponent(p.split('/')[4] || '');
|
|
142
206
|
const ok = registry.removeProject(id);
|
|
207
|
+
if (ok) { refreshPublished(); if (connector) connector.announce(); }
|
|
143
208
|
sendJSON(res, ok ? 200 : 404, ok ? { ok: true } : { error: 'No project registered with that id.' });
|
|
144
209
|
return true;
|
|
145
210
|
}
|
|
@@ -155,13 +220,38 @@ async function handleHubApi(req, res, u) {
|
|
|
155
220
|
sendJSON(res, 200, { ok: true, reloaded });
|
|
156
221
|
return true;
|
|
157
222
|
}
|
|
223
|
+
if (p === '/api/hub/remote' && req.method === 'GET') { sendJSON(res, 200, remoteStatus()); return true; }
|
|
224
|
+
if (p === '/api/hub/remote/reconnect' && req.method === 'POST') { startConnector(); sendJSON(res, 200, { ok: true, ...remoteStatus() }); return true; }
|
|
225
|
+
if (/^\/api\/hub\/projects\/[^/]+\/publish$/.test(p) && req.method === 'POST') {
|
|
226
|
+
const id = decodeURIComponent(p.split('/')[4] || '');
|
|
227
|
+
const { published } = await body(req);
|
|
228
|
+
const meta = workspace.setPublished(id, published !== false);
|
|
229
|
+
if (!meta) { sendJSON(res, 404, { error: 'No project registered with that id.' }); return true; }
|
|
230
|
+
refreshPublished();
|
|
231
|
+
if (connector) connector.announce();
|
|
232
|
+
sendJSON(res, 200, { ok: true, id, published: meta.published });
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
158
235
|
return false;
|
|
159
236
|
}
|
|
160
237
|
|
|
238
|
+
// A viewer's own localStorage choice (once one exists) always wins client-side (app.js's IIFE at the
|
|
239
|
+
// top of the file, and the reconciliation in renderSettings()) — this is only filling the gap for a
|
|
240
|
+
// visitor with nothing saved yet, so the very first paint already matches the project's own
|
|
241
|
+
// configured design instead of flashing whatever static default shipped in the HTML file.
|
|
242
|
+
function projectDesign(root) {
|
|
243
|
+
if (!root) return DEFAULT_DESIGN;
|
|
244
|
+
try { return (store.readConfig(root) || {}).design || DEFAULT_DESIGN; }
|
|
245
|
+
catch { return DEFAULT_DESIGN; }
|
|
246
|
+
}
|
|
247
|
+
|
|
161
248
|
// Serves one static asset (or the SPA index.html fallback for an extensionless path) from the
|
|
162
249
|
// shared, globally-installed PUBLIC dir — factored into a function since both the root-level and
|
|
163
|
-
// /p/<id>/-prefixed requests need it.
|
|
164
|
-
|
|
250
|
+
// /p/<id>/-prefixed requests need it. `root` (the specific project's folder), when known, is used to
|
|
251
|
+
// stamp the served HTML's <html data-design="..."> synchronously with that project's own
|
|
252
|
+
// config.design, before the file ever reaches the browser — avoiding a flash of the wrong theme on a
|
|
253
|
+
// viewer's very first-ever page load (no localStorage value saved yet to reconcile against).
|
|
254
|
+
function serveStatic(reqPath, req, res, root) {
|
|
165
255
|
const file = reqPath === '/' ? '/index.html' : reqPath;
|
|
166
256
|
const full = path.join(PUBLIC, path.normalize(file).replace(/^(\.\.[/\\])+/, ''));
|
|
167
257
|
if (!full.startsWith(PUBLIC)) { res.writeHead(403); return res.end('Forbidden'); }
|
|
@@ -171,14 +261,16 @@ function serveStatic(reqPath, req, res) {
|
|
|
171
261
|
if (req.method === 'GET' && !path.extname(reqPath)) {
|
|
172
262
|
return fs.readFile(path.join(PUBLIC, 'index.html'), (e2, d2) => {
|
|
173
263
|
if (e2) { res.writeHead(404); return res.end('Not found'); }
|
|
174
|
-
|
|
264
|
+
const html = root ? injectDesign(d2.toString('utf8'), projectDesign(root)) : d2;
|
|
265
|
+
res.writeHead(200, Object.assign({ 'Content-Type': MIME['.html'] }, noCache)); res.end(html);
|
|
175
266
|
});
|
|
176
267
|
}
|
|
177
268
|
res.writeHead(404); return res.end('Not found');
|
|
178
269
|
}
|
|
179
270
|
const ext = path.extname(full);
|
|
180
271
|
const headers = ext === '.woff2' || ext === '.woff' ? { 'Cache-Control': 'public, max-age=604800' } : noCache;
|
|
181
|
-
|
|
272
|
+
const payload = (root && ext === '.html') ? injectDesign(data.toString('utf8'), projectDesign(root)) : data;
|
|
273
|
+
res.writeHead(200, Object.assign({ 'Content-Type': MIME[ext] || 'application/octet-stream' }, headers)); res.end(payload);
|
|
182
274
|
});
|
|
183
275
|
}
|
|
184
276
|
|
|
@@ -188,7 +280,7 @@ const LOCK = workspace.lockPath();
|
|
|
188
280
|
function writeLock(){ try{ fs.mkdirSync(path.dirname(LOCK),{recursive:true}); fs.writeFileSync(LOCK, JSON.stringify({ pid:process.pid, port:PORT, url:`http://localhost:${PORT}`, startedAt:new Date().toISOString() })+'\n'); }catch{} }
|
|
189
281
|
function clearLock(){ try{ const l=JSON.parse(fs.readFileSync(LOCK,'utf8')); if(l.pid===process.pid) fs.unlinkSync(LOCK); }catch{} }
|
|
190
282
|
process.on('exit', clearLock);
|
|
191
|
-
['SIGINT','SIGTERM'].forEach((s)=> process.on(s, ()=>{ clearLock(); process.exit(0); }));
|
|
283
|
+
['SIGINT','SIGTERM'].forEach((s)=> process.on(s, ()=>{ if (connector) connector.stop(); clearLock(); process.exit(0); }));
|
|
192
284
|
|
|
193
285
|
const server = http.createServer(async (req, res) => {
|
|
194
286
|
const u = new URL(req.url, `http://localhost:${PORT}`);
|
|
@@ -226,7 +318,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
226
318
|
const proj = getProject(id);
|
|
227
319
|
if (!proj) { res.writeHead(404); return res.end(projectErrorMessage(id)); }
|
|
228
320
|
registry.touchProject(id);
|
|
229
|
-
return serveStatic(m[2] || '/', req, res);
|
|
321
|
+
return serveStatic(m[2] || '/', req, res, proj.root);
|
|
230
322
|
}
|
|
231
323
|
|
|
232
324
|
if (p === '/') {
|
|
@@ -246,4 +338,4 @@ const server = http.createServer(async (req, res) => {
|
|
|
246
338
|
} catch (e) { sendJSON(res, 500, { error: String(e && e.message || e) }); }
|
|
247
339
|
});
|
|
248
340
|
|
|
249
|
-
server.listen(PORT, () => { writeLock(); console.log(`spectoflow · hub → http://localhost:${PORT}${migrated.movedRegistry ? ' (moved your project list into the workspace)' : ''}`); });
|
|
341
|
+
server.listen(PORT, () => { writeLock(); console.log(`spectoflow · hub → http://localhost:${PORT}${migrated.movedRegistry ? ' (moved your project list into the workspace)' : ''}`); startConnector(); });
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* Shared by both dashboard surfaces — the local hub (hub-server.js) and the online relay
|
|
4
|
+
* (server/src/app.js) — so a project's own configured design (config.design) is stamped onto the
|
|
5
|
+
* served index.html's <html data-design="..."> attribute BEFORE the file ever reaches the browser.
|
|
6
|
+
* Design is a shared, project-level setting (not a per-viewer choice, Sous-projet B) — this is the
|
|
7
|
+
* ONLY place the design attribute is ever set for first paint; app.js has no localStorage fallback
|
|
8
|
+
* or reconciliation for it any more, it only reflects P.config.design once the client boots.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Mirrors app.js's currentDesign() own default ('console') when the attribute is absent — same
|
|
12
|
+
// fallback a project with no design saved yet (or an unresolvable project) would land on anyway.
|
|
13
|
+
const DEFAULT_DESIGN = 'console';
|
|
14
|
+
|
|
15
|
+
// The real, live registry of selectable designs (lib/dashboard/public/designs.js) — required, not
|
|
16
|
+
// re-declared here, so an id added/removed there is instantly reflected here with zero drift risk.
|
|
17
|
+
const DESIGNS = require('./public/designs.js');
|
|
18
|
+
const KNOWN_IDS = new Set(DESIGNS.map((d) => d.id));
|
|
19
|
+
|
|
20
|
+
// Only ever accept a value that is one of the real, currently-registered design ids — never trust an
|
|
21
|
+
// arbitrary stored/cached string into the HTML (that would be an injection risk), and never trust a
|
|
22
|
+
// shape-valid but unregistered id either (a stale/typo'd/removed design would silently produce an
|
|
23
|
+
// unstyled page instead of falling back to the same default a missing value gets).
|
|
24
|
+
function sanitizeDesign(d) {
|
|
25
|
+
return (typeof d === 'string' && KNOWN_IDS.has(d)) ? d : DEFAULT_DESIGN;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Rewrites (or adds) the <html> tag's data-design attribute to the given value — leaves everything
|
|
29
|
+
// else in the file untouched, including data-theme (config.theme is applied client-side once app.js
|
|
30
|
+
// boots, same as before — this injector was only ever built to solve design's first-paint flash).
|
|
31
|
+
function injectDesign(html, design) {
|
|
32
|
+
const safe = sanitizeDesign(design);
|
|
33
|
+
return html.replace(/<html([^>]*)>/i, (full, attrs) => {
|
|
34
|
+
const newAttrs = /\bdata-design=/.test(attrs)
|
|
35
|
+
? attrs.replace(/data-design="[^"]*"/, `data-design="${safe}"`)
|
|
36
|
+
: `${attrs} data-design="${safe}"`;
|
|
37
|
+
return `<html${newAttrs}>`;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { injectDesign, sanitizeDesign, DEFAULT_DESIGN };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* Daily meeting — generates a dated standup-style note (.spectoflow/meetings/<date>.md) via the
|
|
4
|
+
* configured agent. Structurally parallel to summarize.js's runSummarize (read that first — this
|
|
5
|
+
* file mirrors its resolveRunnerCommand/spawn/emit shape exactly) but differs in two ways: what it
|
|
6
|
+
* feeds the agent (a digest of recent task activity + the chat log, not just the chat log alone) and
|
|
7
|
+
* where the result lands (a dated file under .spectoflow/meetings/, via files.js, never
|
|
8
|
+
* runtime.messages — a meeting note is a project artifact, not a chat message).
|
|
9
|
+
*/
|
|
10
|
+
const { spawn } = require('child_process');
|
|
11
|
+
const store = require('../store');
|
|
12
|
+
const files = require('./files');
|
|
13
|
+
const { resolveRunnerCommand } = require('./runner');
|
|
14
|
+
const { formatLog } = require('./summarize'); // reused as-is rather than reimplemented — see report
|
|
15
|
+
|
|
16
|
+
const TASK_LIMIT = 20; // mirrors summarize.js's own DEFAULT_LIMIT philosophy (cap, most-recent-last)
|
|
17
|
+
const LOG_LIMIT = 40; // same DEFAULT_LIMIT summarize.js uses for the chat log
|
|
18
|
+
|
|
19
|
+
// YYYY-MM-DD in the machine running the dashboard's OWN local time zone. This is a deliberate,
|
|
20
|
+
// explicit choice (see the project report for the full reasoning): a meeting note's filename must be
|
|
21
|
+
// unambiguous, and this is a single-writer local tool — a viewer's own browser time zone must never
|
|
22
|
+
// decide which file "today" resolves to (it could differ from the server's, or from another viewer's
|
|
23
|
+
// browser, producing two different "today"s for the same project). The client reads this same value
|
|
24
|
+
// back from GET /api/project (ops.js's 'project.read' → p.todayDate), so both halves of this feature
|
|
25
|
+
// (manual edit and agent-generate) agree on exactly one "today".
|
|
26
|
+
function todayLocal() {
|
|
27
|
+
const d = new Date();
|
|
28
|
+
const y = d.getFullYear(), m = String(d.getMonth() + 1).padStart(2, '0'), day = String(d.getDate()).padStart(2, '0');
|
|
29
|
+
return `${y}-${m}-${day}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// "id: title (status)" lines for tasks that are done or in_progress — a cheap, already-available
|
|
33
|
+
// proxy for "what was built / is in flight" (no new aggregation invented), most-recent-last order
|
|
34
|
+
// (readPlans' own file/task order), capped to TASK_LIMIT.
|
|
35
|
+
function formatTasks(plans, limit = TASK_LIMIT) {
|
|
36
|
+
const tasks = [];
|
|
37
|
+
for (const pl of plans || []) {
|
|
38
|
+
for (const ph of pl.phases || []) {
|
|
39
|
+
for (const t of ph.tasks || []) {
|
|
40
|
+
if (t.status === 'done' || t.status === 'in_progress') tasks.push(t);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return tasks.slice(-limit).map((t) => `${t.id}: ${t.title} (${t.status})`).join('\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Strict YYYY-MM-DD only. This is the security boundary for the meetings feature: meetingPath()
|
|
48
|
+
// concatenates `date` straight into a file path, and files.writeFile()'s own safePath() guard only
|
|
49
|
+
// rejects a path that resolves OUTSIDE the project root entirely — it does NOT confine a write to
|
|
50
|
+
// .spectoflow/meetings/, so an unvalidated date like "../../important" would happily write to
|
|
51
|
+
// <root>/important.md. Every caller of meetingPath()/runMeetingGenerate() with a date that did not
|
|
52
|
+
// come from todayLocal() MUST go through this check first (see runMeetingGenerate below).
|
|
53
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
54
|
+
function isValidDate(date) {
|
|
55
|
+
return DATE_RE.test(date);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function meetingPath(date) {
|
|
59
|
+
return `.spectoflow/meetings/${date}.md`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Same "reply with the text only" instruction line runSummarize already uses (already well-tuned),
|
|
63
|
+
// adapted for a daily-meeting-note framing instead of a chat-log-digest framing.
|
|
64
|
+
function buildPrompt(plans, messages) {
|
|
65
|
+
const taskDigest = formatTasks(plans) || '(no done / in-progress tasks yet)';
|
|
66
|
+
const logDigest = formatLog(messages, LOG_LIMIT) || '(no recent chat activity)';
|
|
67
|
+
return 'Write a short daily meeting note for this project, standup-style: what was done, '
|
|
68
|
+
+ 'what\'s in progress or blocked, and suggested next steps — 3-6 concise bullet points or short '
|
|
69
|
+
+ 'paragraphs. Reply with the note text only: no preamble, no sentinel lines, and do not read, '
|
|
70
|
+
+ 'search or edit any files — answer using only the digest below.'
|
|
71
|
+
+ '\n\nRecent tasks:\n' + taskDigest
|
|
72
|
+
+ '\n\nRecent chat log:\n' + logDigest;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Generates a daily meeting note and WRITES it to .spectoflow/meetings/<date>.md (default: today —
|
|
76
|
+
// see todayLocal()), OVERWRITING whatever is already there. Overwrite-SAFETY (not silently
|
|
77
|
+
// destroying a real existing note) is the CLIENT's responsibility — see app.js's meeting-generate
|
|
78
|
+
// confirm flow — this function trusts its caller the same way runSummarize trusts chat.summarize's
|
|
79
|
+
// caller. Returns { child } on success or { error }, mirroring runSummarize's own shape exactly.
|
|
80
|
+
function runMeetingGenerate(root, { agent, date } = {}, emit) {
|
|
81
|
+
const cfg = store.readConfig(root);
|
|
82
|
+
const which = agent || cfg.agent || 'claude';
|
|
83
|
+
const cmdStr = resolveRunnerCommand(root, cfg, which);
|
|
84
|
+
if (!cmdStr) return { error: `No runner configured for "${which}".` };
|
|
85
|
+
|
|
86
|
+
const day = date || todayLocal();
|
|
87
|
+
if (!isValidDate(day)) return { error: 'Invalid date.' };
|
|
88
|
+
const plans = store.readPlans(root);
|
|
89
|
+
const messages = store.readRuntime(root).messages || [];
|
|
90
|
+
const prompt = buildPrompt(plans, messages);
|
|
91
|
+
|
|
92
|
+
const parts = cmdStr.split(/\s+/).filter(Boolean);
|
|
93
|
+
const runId = 'meeting-' + Date.now().toString(36);
|
|
94
|
+
// Emitted before the spawn attempt (same order runner.js/summarize.js use) so the client's "agent
|
|
95
|
+
// running" indicator lights up immediately, and a spawn failure below still gets a matching run-end.
|
|
96
|
+
if (emit) emit({ type: 'run-start', run: { id: runId } });
|
|
97
|
+
let child;
|
|
98
|
+
// windowsHide: without it, spawning a .cmd-shimmed CLI on Windows pops up a real console window.
|
|
99
|
+
try { child = spawn(parts[0], [...parts.slice(1), prompt], { cwd: root, env: process.env, windowsHide: true }); }
|
|
100
|
+
catch (e) { if (emit) emit({ type: 'run-end', runId, code: 1 }); return { error: e.message }; }
|
|
101
|
+
try { child.stdin && child.stdin.end(); } catch {}
|
|
102
|
+
|
|
103
|
+
let out = '';
|
|
104
|
+
child.stdout && child.stdout.on('data', (d) => { out += d.toString(); });
|
|
105
|
+
child.stderr && child.stderr.on('data', (d) => { out += d.toString(); });
|
|
106
|
+
child.on('close', (code) => {
|
|
107
|
+
const text = out.trim() || (code === 0 ? '(no output)' : `meeting generate failed (exit ${code})`);
|
|
108
|
+
files.writeFile(root, meetingPath(day), text);
|
|
109
|
+
// run-end before change, same ordering summarize.js uses — the client must never see "running"
|
|
110
|
+
// linger past the point where the new content is actually ready to be re-fetched.
|
|
111
|
+
if (emit) { emit({ type: 'run-end', runId, code }); emit({ type: 'change' }); }
|
|
112
|
+
});
|
|
113
|
+
return { child };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { runMeetingGenerate, formatTasks, todayLocal, meetingPath, buildPrompt, isValidDate };
|
package/lib/dashboard/ops.js
CHANGED
|
@@ -13,6 +13,7 @@ const store = require('../store');
|
|
|
13
13
|
const files = require('./files');
|
|
14
14
|
const { startRun } = require('./runner');
|
|
15
15
|
const { runSummarize } = require('./summarize');
|
|
16
|
+
const { runMeetingGenerate, todayLocal } = require('./meeting');
|
|
16
17
|
const orchestrator = require('./orchestrator');
|
|
17
18
|
const adapters = require('../adapters');
|
|
18
19
|
const detect = require('../detect');
|
|
@@ -46,12 +47,83 @@ function readAgentFile(root, rel) {
|
|
|
46
47
|
if (!okReal || !real.endsWith('.md') || fs.statSync(real).isDirectory()) bad('not an agent/skill file');
|
|
47
48
|
return { content: fs.readFileSync(real, 'utf8') };
|
|
48
49
|
}
|
|
50
|
+
// The 6 Kanban column ids, mirroring app.js's STATUS keys exactly (order doesn't matter here — the
|
|
51
|
+
// client always re-derives display order from its own STATUS object). Kept as the one server-side
|
|
52
|
+
// source of truth for "what's a real column id" — never trust the client's array blindly.
|
|
53
|
+
const KANBAN_STATUSES = ['todo', 'in_progress', 'to_validate', 'to_analyze', 'done', 'blocked'];
|
|
54
|
+
// The 13 native nav tab ids (Sous-projet C: Task 1 built the mechanism with the original 11; Task 2
|
|
55
|
+
// registered 'notes'; Task 3 registers 'meeting' — the Daily meeting tab — into it), mirroring
|
|
56
|
+
// app.js's ROUTES array exactly — the one server-side source of truth for "what's a real native tab
|
|
57
|
+
// id".
|
|
58
|
+
const NATIVE_TABS = ['board', 'chat', 'requests', 'attention', 'backlog', 'workflow', 'team', 'files', 'notes', 'meeting', 'info', 'docs', 'personalize'];
|
|
49
59
|
function writeConfig(root, patch, detectOpts) {
|
|
50
60
|
const cp = path.join(root, '.spectoflow', 'config.json');
|
|
51
61
|
const cfg = JSON.parse(fs.readFileSync(cp, 'utf8'));
|
|
52
62
|
if (patch.mode && ['autopilot', 'semi', 'manual'].includes(patch.mode)) cfg.mode = patch.mode;
|
|
53
63
|
if (typeof patch.language === 'string' && patch.language.trim()) cfg.language = patch.language.trim();
|
|
54
64
|
if (typeof patch.design === 'string' && /^[a-z0-9-]{1,40}$/.test(patch.design)) cfg.design = patch.design;
|
|
65
|
+
if (typeof patch.theme === 'string' && ['light', 'dark'].includes(patch.theme)) cfg.theme = patch.theme;
|
|
66
|
+
if (typeof patch.boardView === 'string' && ['list', 'kanban'].includes(patch.boardView)) cfg.boardView = patch.boardView;
|
|
67
|
+
if (typeof patch.sideHidden === 'boolean') cfg.sideHidden = patch.sideHidden;
|
|
68
|
+
if (Array.isArray(patch.expandedPhases)) cfg.expandedPhases = patch.expandedPhases.filter((v) => typeof v === 'string');
|
|
69
|
+
if (typeof patch.activeTab === 'string' && patch.activeTab.trim()) cfg.activeTab = patch.activeTab.trim();
|
|
70
|
+
if (typeof patch.chatOpen === 'boolean') cfg.chatOpen = patch.chatOpen;
|
|
71
|
+
// kanbanColumns: reject the whole patch (leave the current value untouched) rather than silently
|
|
72
|
+
// filtering out bad entries — an invalid/unknown status id here means the client sent something it
|
|
73
|
+
// shouldn't have, and a real product-safety rule (never persist zero visible columns) applies too.
|
|
74
|
+
if (Array.isArray(patch.kanbanColumns)) {
|
|
75
|
+
const allKnown = patch.kanbanColumns.every((v) => typeof v === 'string' && KANBAN_STATUSES.includes(v));
|
|
76
|
+
const uniq = [...new Set(patch.kanbanColumns)];
|
|
77
|
+
if (allKnown && uniq.length) cfg.kanbanColumns = uniq;
|
|
78
|
+
}
|
|
79
|
+
if (patch.kanbanPageSize === 10 || patch.kanbanPageSize === 20) cfg.kanbanPageSize = patch.kanbanPageSize;
|
|
80
|
+
// navTabs: same "reject the whole patch" philosophy as kanbanColumns above — plus a stricter safety
|
|
81
|
+
// rule than Kanban's "can't disable the last column": the stored list must always be a COMPLETE
|
|
82
|
+
// reordering/enable-state of the full native tab set (every known id exactly once, never a partial
|
|
83
|
+
// filter), and 'personalize' must always stay enabled — it's the only place the user can ever turn
|
|
84
|
+
// a tab back on, so letting it be disabled would permanently lock them out (short of hand-editing
|
|
85
|
+
// config.json on disk).
|
|
86
|
+
if (patch.navTabs !== undefined) {
|
|
87
|
+
const arr = patch.navTabs;
|
|
88
|
+
const valid = Array.isArray(arr)
|
|
89
|
+
&& arr.every((v) => v && typeof v === 'object' && typeof v.id === 'string' && typeof v.enabled === 'boolean')
|
|
90
|
+
&& arr.length === NATIVE_TABS.length
|
|
91
|
+
&& new Set(arr.map((v) => v.id)).size === NATIVE_TABS.length
|
|
92
|
+
&& arr.every((v) => NATIVE_TABS.includes(v.id));
|
|
93
|
+
const personalizeOk = valid && arr.find((v) => v.id === 'personalize').enabled === true;
|
|
94
|
+
if (personalizeOk) cfg.navTabs = arr.map((v) => ({ id: v.id, enabled: v.enabled }));
|
|
95
|
+
}
|
|
96
|
+
// commands: the dashboard's slash-command macros. Same "reject the whole patch" safety as
|
|
97
|
+
// kanbanColumns/navTabs above — if it isn't an array, or ANY entry is structurally malformed (a
|
|
98
|
+
// trigger that fails the format, or an empty instruction), the stored list is left untouched rather
|
|
99
|
+
// than partially applied. A valid array (including an empty one) replaces it: triggers are deduped
|
|
100
|
+
// case-insensitively (first wins), description/instruction are trimmed and clamped, enabled coerced.
|
|
101
|
+
if (patch.commands !== undefined) {
|
|
102
|
+
const arr = patch.commands;
|
|
103
|
+
// The format regex has no /i flag (the client is expected to normalize to lowercase before
|
|
104
|
+
// sending), but it is tested against the lowercased trigger here rather than the raw one: this
|
|
105
|
+
// still enforces the exact charset/length rule while letting an occasional mixed-case trigger
|
|
106
|
+
// (e.g. a client that didn't normalize) validate and dedupe correctly instead of taking down the
|
|
107
|
+
// whole patch over casing alone. The stored trigger is always the canonical lowercase form.
|
|
108
|
+
const okShape = Array.isArray(arr) && arr.every((c) => c && typeof c === 'object'
|
|
109
|
+
&& typeof c.trigger === 'string' && /^[a-z0-9][a-z0-9_-]{0,39}$/.test(c.trigger.toLowerCase())
|
|
110
|
+
&& typeof c.instruction === 'string' && c.instruction.trim().length > 0);
|
|
111
|
+
if (okShape) {
|
|
112
|
+
const seen = new Set(); const out = [];
|
|
113
|
+
for (const c of arr) {
|
|
114
|
+
const lc = c.trigger.toLowerCase();
|
|
115
|
+
if (seen.has(lc)) continue;
|
|
116
|
+
seen.add(lc);
|
|
117
|
+
out.push({
|
|
118
|
+
trigger: lc,
|
|
119
|
+
description: (typeof c.description === 'string' ? c.description.trim() : '').slice(0, 120),
|
|
120
|
+
instruction: c.instruction.trim().slice(0, 4000),
|
|
121
|
+
enabled: c.enabled !== false,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
cfg.commands = out;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
55
127
|
if (typeof patch.agent === 'string' && patch.agent.trim()) {
|
|
56
128
|
const id = patch.agent.trim();
|
|
57
129
|
const known = adapters.knownAgents().find((a) => a.id === id);
|
|
@@ -73,6 +145,10 @@ const ops = {
|
|
|
73
145
|
p.projectName = path.basename(root);
|
|
74
146
|
p.knownAgents = adapters.knownAgents().map((a) => ({ id: a.id, label: a.label, headless: a.headless, docsUrl: a.docsUrl }));
|
|
75
147
|
p.installedAgents = detect.installedAgents(root);
|
|
148
|
+
// Daily meeting (Sous-projet C, Task 3): the SERVER's own local date — see meeting.js's
|
|
149
|
+
// todayLocal() header comment for why this, not the browser's date, is the one source of truth
|
|
150
|
+
// for which .spectoflow/meetings/<date>.md "today" resolves to.
|
|
151
|
+
p.todayDate = todayLocal();
|
|
76
152
|
return p;
|
|
77
153
|
},
|
|
78
154
|
'agentfile.read': async (root, { path: rel }) => readAgentFile(root, rel),
|
|
@@ -112,9 +188,9 @@ const ops = {
|
|
|
112
188
|
return changed(ctx, { ok: true });
|
|
113
189
|
},
|
|
114
190
|
|
|
115
|
-
'run.start': async (root, { prompt, agent }, ctx) => {
|
|
191
|
+
'run.start': async (root, { prompt, agent, display }, ctx) => {
|
|
116
192
|
text(prompt, 'Empty request.');
|
|
117
|
-
const r = startRun(root, { prompt, agent }, ctx.emit);
|
|
193
|
+
const r = startRun(root, { prompt, agent, display }, ctx.emit);
|
|
118
194
|
if (r.error) bad(r.error);
|
|
119
195
|
return { runId: r.runId };
|
|
120
196
|
},
|
|
@@ -123,6 +199,11 @@ const ops = {
|
|
|
123
199
|
if (r.error) bad(r.error);
|
|
124
200
|
return { ok: true };
|
|
125
201
|
},
|
|
202
|
+
'meeting.generate': async (root, { agent, date }, ctx) => {
|
|
203
|
+
const r = runMeetingGenerate(root, { agent, date }, ctx.emit);
|
|
204
|
+
if (r.error) bad(r.error);
|
|
205
|
+
return { ok: true };
|
|
206
|
+
},
|
|
126
207
|
'chat.clear': async (root, _args, ctx) => {
|
|
127
208
|
const rt = store.readRuntime(root); rt.messages = []; store.writeRuntime(root, rt);
|
|
128
209
|
return changed(ctx, { ok: true });
|