moflo 4.9.22 → 4.9.24
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/.claude/guidance/shipped/moflo-cli-reference.md +19 -16
- package/.claude/guidance/shipped/moflo-core-guidance.md +0 -2
- package/.claude/guidance/shipped/moflo-spell-runner.md +1 -0
- package/.claude/guidance/shipped/moflo-spell-scheduling.md +225 -0
- package/.claude/guidance/shipped/moflo-spell-troubleshooting.md +1 -0
- package/.claude/skills/fl/phases.md +67 -0
- package/.claude/skills/spell-schedule/SKILL.md +18 -5
- package/README.md +1 -1
- package/bin/index-guidance.mjs +32 -6
- package/bin/session-start-launcher.mjs +15 -8
- package/dist/src/cli/commands/daemon.js +13 -17
- package/dist/src/cli/commands/hooks.js +3 -6
- package/dist/src/cli/commands/spell-schedule.js +237 -49
- package/dist/src/cli/init/settings-generator.js +5 -6
- package/dist/src/cli/mcp-tools/memory-tools.js +16 -5
- package/dist/src/cli/memory/bridge-embedder.js +26 -6
- package/dist/src/cli/memory/bridge-entries.js +33 -15
- package/dist/src/cli/services/daemon-autostart-lifecycle.js +62 -0
- package/dist/src/cli/services/daemon-dashboard.js +192 -18
- package/dist/src/cli/services/daemon-readiness.js +19 -31
- package/dist/src/cli/services/ephemeral-namespace-purge.js +61 -33
- package/dist/src/cli/services/headless-worker-executor.js +7 -94
- package/dist/src/cli/services/worker-daemon.js +40 -66
- package/dist/src/cli/spells/core/runner.js +12 -0
- package/dist/src/cli/spells/scheduler/scheduler.js +24 -9
- package/dist/src/cli/spells/schema/validator.js +2 -1
- package/dist/src/cli/spells/schema/validators/top-level.js +18 -0
- package/dist/src/cli/version.js +1 -1
- package/package.json +4 -2
|
@@ -44,11 +44,9 @@ export const EMBEDDING_MODEL_OPT_OUT = 'none';
|
|
|
44
44
|
*/
|
|
45
45
|
export const EMBEDDING_MODEL_LEGACY_DEFAULT = 'local';
|
|
46
46
|
/**
|
|
47
|
-
* Namespaces that
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* tags rows with `'none'`. Existing rows in these namespaces are hard-deleted
|
|
51
|
-
* on upgrade by `services/ephemeral-namespace-purge.ts`.
|
|
47
|
+
* Namespaces that skip embedding generation in the bridge write path. Rows
|
|
48
|
+
* land with both `embedding` and `embedding_model` NULL (distinct from the
|
|
49
|
+
* opt-out path which still tags rows with `'none'`).
|
|
52
50
|
*
|
|
53
51
|
* Members:
|
|
54
52
|
* - `hive-mind` — MCP broadcast traffic (msg:*, agent_join, consensus_propose)
|
|
@@ -56,7 +54,11 @@ export const EMBEDDING_MODEL_LEGACY_DEFAULT = 'local';
|
|
|
56
54
|
* - `epic-state` — Epic progress (epic-N, story-M) written by commands/epic.ts
|
|
57
55
|
* - `test-bridge-fix` — Single 2026-04-23 row left over from a one-off test
|
|
58
56
|
*
|
|
59
|
-
* See story #729 for the source-trace and rationale.
|
|
57
|
+
* See story #729 for the source-trace and rationale. The session-start
|
|
58
|
+
* launcher only purges {@link PURGE_ON_SESSION_START_NAMESPACES} — a strict
|
|
59
|
+
* subset that *excludes* `tasklist`, because the dashboard's Flo Runs tab
|
|
60
|
+
* (`daemon-dashboard.ts handleSpells`) reads tasklist; purging it on every
|
|
61
|
+
* session would empty the tab between sessions (#968).
|
|
60
62
|
*/
|
|
61
63
|
export const EPHEMERAL_NAMESPACES = new Set([
|
|
62
64
|
'hive-mind',
|
|
@@ -64,6 +66,24 @@ export const EPHEMERAL_NAMESPACES = new Set([
|
|
|
64
66
|
'epic-state',
|
|
65
67
|
'test-bridge-fix',
|
|
66
68
|
]);
|
|
69
|
+
/**
|
|
70
|
+
* Subset of {@link EPHEMERAL_NAMESPACES} that the session-start launcher
|
|
71
|
+
* hard-purges via `services/ephemeral-namespace-purge.ts`. Excludes
|
|
72
|
+
* `tasklist` — those rows back the dashboard's "Flo Runs" tab and are
|
|
73
|
+
* trimmed by row-count retention instead of bulk purge (#968).
|
|
74
|
+
*/
|
|
75
|
+
export const PURGE_ON_SESSION_START_NAMESPACES = new Set([
|
|
76
|
+
'hive-mind',
|
|
77
|
+
'epic-state',
|
|
78
|
+
'test-bridge-fix',
|
|
79
|
+
]);
|
|
80
|
+
/**
|
|
81
|
+
* Maximum number of `tasklist` rows kept across session restarts. The
|
|
82
|
+
* session-start retention pass deletes oldest rows beyond this cap, so the
|
|
83
|
+
* dashboard's "Flo Runs" tab shows recent history without unbounded growth
|
|
84
|
+
* (#968). Sized for ~2 weeks of /flo activity at typical use.
|
|
85
|
+
*/
|
|
86
|
+
export const TASKLIST_RETENTION_CAP = 200;
|
|
67
87
|
let cachedEmbedder = null;
|
|
68
88
|
let testOverride = null;
|
|
69
89
|
class LazyFastembedBridgeEmbedder {
|
|
@@ -435,14 +435,30 @@ export async function bridgeGetEntry(options) {
|
|
|
435
435
|
});
|
|
436
436
|
}
|
|
437
437
|
/**
|
|
438
|
-
*
|
|
438
|
+
* Hard-delete an entry. Guarded, cache-invalidated, attested.
|
|
439
|
+
*
|
|
440
|
+
* Failure modes (issue #963): every non-success path now carries a
|
|
441
|
+
* human-readable `error` so MCP callers can surface the reason instead
|
|
442
|
+
* of seeing a silent `{ deleted: false }`.
|
|
439
443
|
*/
|
|
440
444
|
export async function bridgeDeleteEntry(options) {
|
|
441
445
|
return withDb(options.dbPath, async (ctx, registry) => {
|
|
442
446
|
const { key, namespace = 'default' } = options;
|
|
447
|
+
const deleteFail = (error) => ({ success: false, deleted: false, key, namespace, remainingEntries: 0, error });
|
|
443
448
|
const guardResult = await guardValidate(registry, 'delete', { key, namespace });
|
|
444
449
|
if (!guardResult.allowed) {
|
|
445
|
-
return
|
|
450
|
+
return deleteFail(`MutationGuard rejected: ${guardResult.reason}`);
|
|
451
|
+
}
|
|
452
|
+
let existed = false;
|
|
453
|
+
try {
|
|
454
|
+
const existsRows = execRows(ctx.db, `SELECT 1 as found FROM memory_entries WHERE key = ? AND namespace = ? AND status = 'active' LIMIT 1`, [key, namespace]);
|
|
455
|
+
existed = existsRows.length > 0;
|
|
456
|
+
}
|
|
457
|
+
catch (err) {
|
|
458
|
+
return deleteFail(`DB read failed during delete pre-check: ${errorDetail(err)}`);
|
|
459
|
+
}
|
|
460
|
+
if (!existed) {
|
|
461
|
+
return deleteFail(`Key '${key}' not found in namespace '${namespace}'`);
|
|
446
462
|
}
|
|
447
463
|
let changes = 0;
|
|
448
464
|
try {
|
|
@@ -454,28 +470,30 @@ export async function bridgeDeleteEntry(options) {
|
|
|
454
470
|
// db.getRowsModified() to read the row count from the last statement.
|
|
455
471
|
changes = ctx.db.getRowsModified?.() ?? 0;
|
|
456
472
|
}
|
|
457
|
-
catch {
|
|
458
|
-
return
|
|
473
|
+
catch (err) {
|
|
474
|
+
return deleteFail(`DELETE failed: ${errorDetail(err)}`);
|
|
459
475
|
}
|
|
460
|
-
if (changes
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
476
|
+
if (changes === 0) {
|
|
477
|
+
// SELECT found the row but DELETE removed nothing. Most likely cause:
|
|
478
|
+
// bridge holds an in-memory snapshot that diverged from disk
|
|
479
|
+
// (sql.js writeback semantics — see feedback_sqljs_writeback_clobber.md).
|
|
480
|
+
return deleteFail(`Internal inconsistency: row matched SELECT but DELETE removed 0 rows (key='${key}', namespace='${namespace}'). Possible bridge cache staleness — restart the daemon and retry.`);
|
|
465
481
|
}
|
|
482
|
+
persistBridgeDb(ctx.db, options.dbPath);
|
|
483
|
+
await cacheInvalidate(registry, makeEntryCacheKey(namespace, key));
|
|
484
|
+
await logAttestation(registry, 'delete', key, { namespace });
|
|
466
485
|
let remaining = 0;
|
|
467
486
|
try {
|
|
468
|
-
const
|
|
469
|
-
remaining =
|
|
487
|
+
const countRows = execRows(ctx.db, `SELECT COUNT(*) as cnt FROM memory_entries WHERE status = 'active'`);
|
|
488
|
+
remaining = Number(countRows[0]?.cnt ?? 0);
|
|
470
489
|
}
|
|
471
490
|
catch {
|
|
472
|
-
// Non-fatal
|
|
491
|
+
// Non-fatal — count is informational
|
|
473
492
|
}
|
|
474
|
-
|
|
475
|
-
refreshVectorStatsCache();
|
|
493
|
+
refreshVectorStatsCache();
|
|
476
494
|
return {
|
|
477
495
|
success: true,
|
|
478
|
-
deleted:
|
|
496
|
+
deleted: true,
|
|
479
497
|
key,
|
|
480
498
|
namespace,
|
|
481
499
|
remainingEntries: remaining,
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon Autostart Lifecycle
|
|
3
|
+
*
|
|
4
|
+
* Reconciles the OS-native daemon login service against the count of enabled
|
|
5
|
+
* scheduled spells. Replaces the old prompt-based flow in `daemon-readiness.ts`
|
|
6
|
+
* (which left users with stale autostart entries when their last schedule was
|
|
7
|
+
* cancelled — see #960, #961).
|
|
8
|
+
*
|
|
9
|
+
* Idempotent: callers invoke `reconcileDaemonAutostart` after every mutation
|
|
10
|
+
* to `scheduled-spells`. It installs once, uninstalls once, and is a no-op
|
|
11
|
+
* for every other transition.
|
|
12
|
+
*/
|
|
13
|
+
import { isDaemonInstalled as defaultIsDaemonInstalled, installDaemonService as defaultInstallDaemonService, uninstallDaemonService as defaultUninstallDaemonService, } from './daemon-service.js';
|
|
14
|
+
const NOOP = { transition: 'noop', message: null, warning: null };
|
|
15
|
+
/**
|
|
16
|
+
* Reconcile OS-native daemon autostart against schedule count.
|
|
17
|
+
*
|
|
18
|
+
* - count ≥ 1 + not installed → install
|
|
19
|
+
* - count = 0 + installed → uninstall
|
|
20
|
+
* - all other states → noop
|
|
21
|
+
*
|
|
22
|
+
* Never throws. Install/uninstall failures are returned as non-fatal warnings
|
|
23
|
+
* — the caller decides whether to print them. The schedule mutation itself is
|
|
24
|
+
* always considered the primary operation; autostart is best-effort.
|
|
25
|
+
*/
|
|
26
|
+
export function reconcileDaemonAutostart(options) {
|
|
27
|
+
if (options.skip)
|
|
28
|
+
return NOOP;
|
|
29
|
+
const isInstalled = (options.isDaemonInstalled ?? defaultIsDaemonInstalled)(options.projectRoot);
|
|
30
|
+
if (options.enabledScheduleCount >= 1 && !isInstalled) {
|
|
31
|
+
const result = (options.installDaemonService ?? defaultInstallDaemonService)(options.projectRoot);
|
|
32
|
+
if (result.success) {
|
|
33
|
+
return {
|
|
34
|
+
transition: 'installed',
|
|
35
|
+
message: 'Daemon registered as OS login service so this schedule survives reboot.',
|
|
36
|
+
warning: null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
transition: 'noop',
|
|
41
|
+
message: null,
|
|
42
|
+
warning: `Could not register daemon as OS login service: ${result.message}`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (options.enabledScheduleCount === 0 && isInstalled) {
|
|
46
|
+
const result = (options.uninstallDaemonService ?? defaultUninstallDaemonService)(options.projectRoot);
|
|
47
|
+
if (result.success) {
|
|
48
|
+
return {
|
|
49
|
+
transition: 'uninstalled',
|
|
50
|
+
message: 'No enabled schedules remain — daemon unregistered from OS login services.',
|
|
51
|
+
warning: null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
transition: 'noop',
|
|
56
|
+
message: null,
|
|
57
|
+
warning: `Could not unregister daemon from OS login services: ${result.message}`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return NOOP;
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=daemon-autostart-lifecycle.js.map
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The Luminarium — Lightweight localhost HTTP server
|
|
3
3
|
*
|
|
4
|
-
* Serves
|
|
5
|
-
* and memory stats. Binds to 127.0.0.1
|
|
4
|
+
* Serves the moflo Luminarium (read-only daemon view) for status,
|
|
5
|
+
* scheduled spells, executions, and memory stats. Binds to 127.0.0.1
|
|
6
|
+
* only (no auth needed).
|
|
7
|
+
*
|
|
8
|
+
* Internal/code identifiers retain the term "dashboard" (CLI flags
|
|
9
|
+
* `--dashboard-port` / `--no-dashboard`, internal port constant) for
|
|
10
|
+
* stability with existing consumer scripts; only user-facing surface
|
|
11
|
+
* is rebranded.
|
|
6
12
|
*
|
|
7
13
|
* @module daemon-dashboard
|
|
8
14
|
*/
|
|
@@ -78,10 +84,17 @@ function tryParseSafe(s) {
|
|
|
78
84
|
}
|
|
79
85
|
function handleStatus(daemon) {
|
|
80
86
|
const status = daemon.getStatus();
|
|
87
|
+
// Index config rows by worker type so the row renderer can show a
|
|
88
|
+
// "disabled" badge instead of "Last run: never" for default-off workers
|
|
89
|
+
// (audit, predict, document — see #968 user feedback).
|
|
90
|
+
const configByType = new Map();
|
|
91
|
+
for (const w of status.config.workers)
|
|
92
|
+
configByType.set(w.type, { enabled: w.enabled });
|
|
81
93
|
const workers = [];
|
|
82
94
|
for (const [type, state] of status.workers) {
|
|
83
95
|
workers.push({
|
|
84
96
|
type,
|
|
97
|
+
enabled: configByType.get(type)?.enabled ?? true,
|
|
85
98
|
isRunning: state.isRunning,
|
|
86
99
|
lastRun: state.lastRun?.toISOString() ?? null,
|
|
87
100
|
nextRun: state.nextRun?.toISOString() ?? null,
|
|
@@ -330,6 +343,9 @@ async function handleRequest(req, res, daemon, opts) {
|
|
|
330
343
|
else if (url === '/api/schedules') {
|
|
331
344
|
sendJson(res, 200, await handleSchedules(daemon, opts));
|
|
332
345
|
}
|
|
346
|
+
else if (url === '/api/schedules/events') {
|
|
347
|
+
handleSchedulesEventStream(req, res, daemon);
|
|
348
|
+
}
|
|
333
349
|
else if (url === '/api/spells') {
|
|
334
350
|
sendJson(res, 200, await handleSpells(opts.memory));
|
|
335
351
|
}
|
|
@@ -400,6 +416,71 @@ function getSchedulerErrorCode(err) {
|
|
|
400
416
|
}
|
|
401
417
|
return null;
|
|
402
418
|
}
|
|
419
|
+
/** Heartbeat interval for the schedule-event SSE stream (keeps proxies + idle clients alive). */
|
|
420
|
+
const SSE_HEARTBEAT_MS = 25_000;
|
|
421
|
+
/**
|
|
422
|
+
* Stream `schedule:*` events to the client via Server-Sent Events.
|
|
423
|
+
*
|
|
424
|
+
* Subscribes to `scheduler.on(listener)` and forwards each event as an SSE
|
|
425
|
+
* frame (`event: <type>\ndata: <JSON>\n\n`). Sends an initial `ready` frame
|
|
426
|
+
* so the client can distinguish "connected, waiting" from "scheduler down",
|
|
427
|
+
* plus a comment heartbeat every 25s. Cleans up on client disconnect.
|
|
428
|
+
*
|
|
429
|
+
* Returns 503 when the scheduler is not attached so the client can fall back
|
|
430
|
+
* to polling. Reuses duck-typing via `daemon.getScheduler()` — no new types
|
|
431
|
+
* exported from this module.
|
|
432
|
+
*/
|
|
433
|
+
function handleSchedulesEventStream(req, res, daemon) {
|
|
434
|
+
const scheduler = daemon.getScheduler();
|
|
435
|
+
if (!scheduler) {
|
|
436
|
+
sendJson(res, 503, { error: 'Scheduler not attached' });
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
res.writeHead(200, {
|
|
440
|
+
'Content-Type': 'text/event-stream',
|
|
441
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
442
|
+
'Connection': 'keep-alive',
|
|
443
|
+
'X-Accel-Buffering': 'no',
|
|
444
|
+
});
|
|
445
|
+
// Initial frame so the client knows the channel is live even before
|
|
446
|
+
// the first scheduler event arrives (which may be minutes away).
|
|
447
|
+
res.write(`event: ready\ndata: ${JSON.stringify({ timestamp: Date.now() })}\n\n`);
|
|
448
|
+
let cleaned = false;
|
|
449
|
+
const cleanup = () => {
|
|
450
|
+
if (cleaned)
|
|
451
|
+
return;
|
|
452
|
+
cleaned = true;
|
|
453
|
+
clearInterval(heartbeat);
|
|
454
|
+
unsubscribe();
|
|
455
|
+
try {
|
|
456
|
+
res.end();
|
|
457
|
+
}
|
|
458
|
+
catch { /* already ended */ }
|
|
459
|
+
};
|
|
460
|
+
const unsubscribe = scheduler.on((event) => {
|
|
461
|
+
try {
|
|
462
|
+
res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
// Half-open socket: the request may never emit 'close'. Defer cleanup
|
|
466
|
+
// to the next microtask so we don't splice the listeners array we are
|
|
467
|
+
// currently being iterated from inside.
|
|
468
|
+
queueMicrotask(cleanup);
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
const heartbeat = setInterval(() => {
|
|
472
|
+
try {
|
|
473
|
+
res.write(`: ping ${Date.now()}\n\n`);
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
queueMicrotask(cleanup);
|
|
477
|
+
}
|
|
478
|
+
}, SSE_HEARTBEAT_MS);
|
|
479
|
+
if (typeof heartbeat.unref === 'function')
|
|
480
|
+
heartbeat.unref();
|
|
481
|
+
req.on('close', cleanup);
|
|
482
|
+
req.on('error', cleanup);
|
|
483
|
+
}
|
|
403
484
|
// ============================================================================
|
|
404
485
|
// Server lifecycle
|
|
405
486
|
// ============================================================================
|
|
@@ -479,11 +560,33 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
|
|
|
479
560
|
<head>
|
|
480
561
|
<meta charset="utf-8">
|
|
481
562
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
482
|
-
<title>
|
|
563
|
+
<title>The Luminarium</title>
|
|
564
|
+
<meta name="description" content="The Luminarium — moflo daemon, scheduled spells, and live event stream">
|
|
565
|
+
<meta property="og:title" content="The Luminarium">
|
|
566
|
+
<meta property="og:description" content="The Luminarium — moflo daemon, scheduled spells, and live event stream">
|
|
567
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
568
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
569
|
+
<link href="https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@700;900&display=swap" rel="stylesheet">
|
|
483
570
|
<style>
|
|
484
571
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
485
572
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0d1117; color: #c9d1d9; padding: 20px; }
|
|
486
|
-
|
|
573
|
+
/* Wizardy chain — Cinzel Decorative is the Google Font; the rest are
|
|
574
|
+
the most likely system serifs across macOS / Windows / Linux so the
|
|
575
|
+
title still reads "arcane" if the user is offline or behind a font-CDN
|
|
576
|
+
block (Georgia ships everywhere; serif is the universal fallback). */
|
|
577
|
+
h1 { font-family: 'Cinzel Decorative', 'Cinzel', 'Trajan Pro', 'Palatino Linotype', 'Book Antiqua', Georgia, serif; font-weight: 900; letter-spacing: 0.04em; margin-bottom: 4px; font-size: 1.85rem; }
|
|
578
|
+
/* Luminous gradient flowing across the whole title (amber → pale gold → pale
|
|
579
|
+
cyan). background-clip: text paints the gradient through the glyphs;
|
|
580
|
+
color: transparent reveals it. text-shadow doesn't paint on transparent
|
|
581
|
+
text, so the glow uses filter: drop-shadow which respects rendered
|
|
582
|
+
glyph shape. Mid-gradient hue chosen for the glow tint. */
|
|
583
|
+
h1 .luminarium-title {
|
|
584
|
+
background: linear-gradient(90deg, #f59e0b 0%, #fde68a 50%, #67e8f4 100%);
|
|
585
|
+
-webkit-background-clip: text;
|
|
586
|
+
background-clip: text;
|
|
587
|
+
color: transparent;
|
|
588
|
+
filter: drop-shadow(0 0 14px rgba(253, 230, 138, 0.22));
|
|
589
|
+
}
|
|
487
590
|
h2 { color: #8b949e; font-size: 1.1rem; margin: 16px 0 12px; border-bottom: 1px solid #21262d; padding-bottom: 6px; }
|
|
488
591
|
.header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
|
|
489
592
|
.subtitle { color: #8b949e; font-size: 0.85rem; }
|
|
@@ -531,13 +634,13 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
|
|
|
531
634
|
</head>
|
|
532
635
|
<body>
|
|
533
636
|
<div class="header">
|
|
534
|
-
<h1>
|
|
535
|
-
<span class="subtitle">
|
|
637
|
+
<h1><span class="luminarium-title">The Luminarium</span></h1>
|
|
638
|
+
<span class="subtitle">moflo daemon • localhost</span>
|
|
536
639
|
</div>
|
|
537
640
|
<div id="status-bar" class="status-bar"><div class="empty">Loading...</div></div>
|
|
538
641
|
<div class="nav" id="nav"></div>
|
|
539
642
|
<div id="panel-workers" class="panel"></div>
|
|
540
|
-
<div id="panel-schedules" class="panel" style="display:none"></div>
|
|
643
|
+
<div id="panel-schedules" class="panel" style="display:none"><div id="schedules-active"></div><div id="schedules-events"></div></div>
|
|
541
644
|
<div id="panel-executions" class="panel" style="display:none"></div>
|
|
542
645
|
<div id="panel-memory" class="panel" style="display:none"></div>
|
|
543
646
|
<div id="poll-indicator" class="poll-indicator"></div>
|
|
@@ -613,15 +716,25 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
|
|
|
613
716
|
|
|
614
717
|
function renderWorkers(s) {
|
|
615
718
|
if (!s) return;
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
719
|
+
// Disabled workers show a clear "disabled" badge and dim "—" cells
|
|
720
|
+
// instead of "idle"/"never" — those terms imply the worker is healthy
|
|
721
|
+
// but quiet, which misled users into thinking audit/predict/document
|
|
722
|
+
// were broken (#968).
|
|
723
|
+
const rows = s.workers.map(w => {
|
|
724
|
+
const statusBadge = w.enabled === false
|
|
725
|
+
? badge('disabled', 'gray')
|
|
726
|
+
: (w.isRunning ? badge('running', 'yellow') : badge('idle', 'gray'));
|
|
727
|
+
const dim = '<span class="dim">—</span>';
|
|
728
|
+
const lastRun = w.enabled === false && !w.lastRun ? dim : fmtTimeAgo(w.lastRun);
|
|
729
|
+
const nextRun = w.enabled === false ? dim : (w.nextRun ? fmtTime(w.nextRun) : '-');
|
|
730
|
+
return '<tr><td>' + esc(w.type) + '</td>' +
|
|
731
|
+
'<td>' + statusBadge + '</td>' +
|
|
732
|
+
'<td>' + w.runCount + '</td>' +
|
|
733
|
+
'<td>' + pct(w.successCount, w.failureCount) + '</td>' +
|
|
734
|
+
'<td>' + fmtDuration(w.averageDurationMs) + '</td>' +
|
|
735
|
+
'<td>' + lastRun + '</td>' +
|
|
736
|
+
'<td>' + nextRun + '</td></tr>';
|
|
737
|
+
}).join('');
|
|
625
738
|
document.getElementById('panel-workers').innerHTML =
|
|
626
739
|
'<h2>Worker Status</h2>' +
|
|
627
740
|
'<table><thead><tr><th>Worker</th><th>Status</th><th>Runs</th><th>Success</th><th>Avg</th><th>Last Run</th><th>Next Run</th></tr></thead>' +
|
|
@@ -643,7 +756,7 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
|
|
|
643
756
|
window.__schedAction = scheduleAction;
|
|
644
757
|
|
|
645
758
|
function renderSchedules(sc) {
|
|
646
|
-
const el = document.getElementById('
|
|
759
|
+
const el = document.getElementById('schedules-active');
|
|
647
760
|
if (!sc) { el.innerHTML = '<div class="empty">Loading...</div>'; return; }
|
|
648
761
|
|
|
649
762
|
if (sc.disabledInConfig) {
|
|
@@ -684,6 +797,67 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
|
|
|
684
797
|
if (sc.history && sc.history.length) renderSchedulesHistory(el, sc.history, /*append*/ true);
|
|
685
798
|
}
|
|
686
799
|
|
|
800
|
+
// Live events tail (Server-Sent Events from /api/schedules/events).
|
|
801
|
+
// The events div lives outside renderSchedules' write target so polled
|
|
802
|
+
// re-renders of the schedules panel don't touch it; pushSchedEvent is
|
|
803
|
+
// the only writer. Single source of truth for known event types + their
|
|
804
|
+
// badge color — adding a new schedule:* type means one entry here.
|
|
805
|
+
const SCHED_EVENT_BADGES = {
|
|
806
|
+
'schedule:due': 'gray',
|
|
807
|
+
'schedule:started': 'gray',
|
|
808
|
+
'schedule:completed': 'green',
|
|
809
|
+
'schedule:failed': 'red',
|
|
810
|
+
'schedule:skipped': 'yellow',
|
|
811
|
+
'schedule:disabled': 'yellow',
|
|
812
|
+
'schedule:catchup': 'yellow',
|
|
813
|
+
};
|
|
814
|
+
const SCHED_EVENT_TYPES = Object.keys(SCHED_EVENT_BADGES);
|
|
815
|
+
const SCHED_EVENTS_MAX = 50;
|
|
816
|
+
const schedEvents = [];
|
|
817
|
+
|
|
818
|
+
function renderEventsTail() {
|
|
819
|
+
const el = document.getElementById('schedules-events');
|
|
820
|
+
if (!el) return;
|
|
821
|
+
if (schedEvents.length === 0) {
|
|
822
|
+
el.innerHTML = '<h2>Live Events</h2><div class="empty">Waiting for scheduler activity…</div>';
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
const rows = schedEvents.map(e => {
|
|
826
|
+
const t = e.type || '?';
|
|
827
|
+
const short = String(t).replace('schedule:', '');
|
|
828
|
+
return '<tr><td>' + new Date(e.timestamp || Date.now()).toLocaleTimeString() + '</td>' +
|
|
829
|
+
'<td>' + badge(short, SCHED_EVENT_BADGES[t] || 'gray') + '</td>' +
|
|
830
|
+
'<td>' + esc(e.spellName || '-') + '</td>' +
|
|
831
|
+
'<td>' + esc(e.message || '') + '</td></tr>';
|
|
832
|
+
}).join('');
|
|
833
|
+
el.innerHTML = '<h2>Live Events</h2>' +
|
|
834
|
+
'<table><thead><tr><th>Time</th><th>Event</th><th>Spell</th><th>Detail</th></tr></thead>' +
|
|
835
|
+
'<tbody>' + rows + '</tbody></table>';
|
|
836
|
+
}
|
|
837
|
+
function pushSchedEvent(ev) {
|
|
838
|
+
schedEvents.unshift(ev);
|
|
839
|
+
if (schedEvents.length > SCHED_EVENTS_MAX) schedEvents.length = SCHED_EVENTS_MAX;
|
|
840
|
+
renderEventsTail();
|
|
841
|
+
}
|
|
842
|
+
renderEventsTail(); // Initial empty-state paint.
|
|
843
|
+
|
|
844
|
+
// Subscribe via EventSource. Browser handles auto-reconnect with backoff.
|
|
845
|
+
let evtSource = null;
|
|
846
|
+
function connectEventStream() {
|
|
847
|
+
try { if (evtSource) evtSource.close(); } catch (e) { /* ignore */ }
|
|
848
|
+
try {
|
|
849
|
+
evtSource = new EventSource('/api/schedules/events');
|
|
850
|
+
SCHED_EVENT_TYPES.forEach(t => {
|
|
851
|
+
evtSource.addEventListener(t, ev => {
|
|
852
|
+
try { pushSchedEvent(JSON.parse(ev.data)); } catch (e) { /* malformed frame */ }
|
|
853
|
+
});
|
|
854
|
+
});
|
|
855
|
+
} catch (e) {
|
|
856
|
+
console.warn('Event stream unavailable:', e);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
connectEventStream();
|
|
860
|
+
|
|
687
861
|
function renderSchedulesHistory(el, history, append) {
|
|
688
862
|
const rows = history.map(h => {
|
|
689
863
|
const statusBadge = h.success === true ? badge('pass','green')
|
|
@@ -1,29 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Daemon Readiness Check for Scheduled Spells
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Confirms the daemon is currently running so a freshly-created schedule
|
|
5
|
+
* actually fires. Prompts the user to start it interactively, or warns
|
|
6
|
+
* non-interactively. Always returns regardless of state — the caller still
|
|
7
|
+
* writes the schedule, and the daemon picks it up on next start.
|
|
8
|
+
*
|
|
9
|
+
* OS-autostart install/uninstall is no longer handled here — see
|
|
10
|
+
* `daemon-autostart-lifecycle.ts`. That side effect is now driven by the
|
|
11
|
+
* count of enabled schedules, not a per-create prompt.
|
|
8
12
|
*/
|
|
9
13
|
import { join, resolve } from 'path';
|
|
10
14
|
import { mkdirSync, openSync, closeSync } from 'fs';
|
|
11
15
|
import { spawn } from 'child_process';
|
|
12
16
|
import { getDaemonLockHolder } from './daemon-lock.js';
|
|
13
|
-
import { isDaemonInstalled
|
|
17
|
+
import { isDaemonInstalled } from './daemon-service.js';
|
|
14
18
|
import { locateMofloCliBin } from './moflo-require.js';
|
|
15
19
|
import { registerBackgroundPid } from './process-registry.js';
|
|
16
20
|
/**
|
|
17
21
|
* Ensure the daemon is ready for scheduled spell execution.
|
|
18
22
|
*
|
|
19
|
-
* Checks daemon state and prompts the user to start
|
|
20
|
-
*
|
|
21
|
-
*
|
|
23
|
+
* Checks daemon state and prompts the user to start it as needed. Always
|
|
24
|
+
* returns — never throws. The caller should create the schedule regardless
|
|
25
|
+
* of the result, since the daemon can pick it up later. OS-autostart is
|
|
26
|
+
* reconciled separately by the create/cancel paths via
|
|
27
|
+
* `reconcileDaemonAutostart`.
|
|
22
28
|
*/
|
|
23
29
|
export async function ensureDaemonForScheduling(options) {
|
|
24
30
|
const resolvedRoot = resolve(options.projectRoot);
|
|
25
31
|
const promptFn = options.promptConfirm ?? defaultPromptConfirm;
|
|
26
32
|
const startFn = options.startDaemon ?? defaultStartDaemon;
|
|
33
|
+
const installedFn = options.isDaemonInstalledFn ?? isDaemonInstalled;
|
|
27
34
|
const result = {
|
|
28
35
|
daemonRunning: false,
|
|
29
36
|
daemonInstalled: false,
|
|
@@ -50,29 +57,10 @@ export async function ensureDaemonForScheduling(options) {
|
|
|
50
57
|
result.warnings.push('Daemon is not running. Start it with: moflo daemon start');
|
|
51
58
|
}
|
|
52
59
|
}
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
result.daemonInstalled = isDaemonInstalled(resolvedRoot);
|
|
58
|
-
if (!result.daemonInstalled) {
|
|
59
|
-
if (options.interactive && result.daemonRunning) {
|
|
60
|
-
const shouldInstall = await promptFn('Register the daemon as a login service so schedules survive reboots?');
|
|
61
|
-
if (shouldInstall) {
|
|
62
|
-
const installResult = installDaemonService(resolvedRoot);
|
|
63
|
-
result.daemonInstalled = installResult.success;
|
|
64
|
-
if (!installResult.success) {
|
|
65
|
-
result.warnings.push(`Failed to install daemon service: ${installResult.message}`);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
else {
|
|
69
|
-
result.warnings.push("Daemon is not set to autostart. Run 'moflo daemon install' so this schedule survives reboot.");
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
else {
|
|
73
|
-
result.warnings.push("Daemon is not set to autostart. Run 'moflo daemon install' so this schedule survives reboot.");
|
|
74
|
-
}
|
|
75
|
-
}
|
|
60
|
+
// Surface OS install state purely informationally — no prompts. The
|
|
61
|
+
// create/cancel commands reconcile install/uninstall via
|
|
62
|
+
// reconcileDaemonAutostart, driven by the enabled-schedule count.
|
|
63
|
+
result.daemonInstalled = installedFn(resolvedRoot);
|
|
76
64
|
return result;
|
|
77
65
|
}
|
|
78
66
|
async function defaultPromptConfirm(message) {
|