moflo 4.9.25 → 4.9.27
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/skills/healer/SKILL.md +2 -2
- package/bin/session-start-launcher.mjs +18 -0
- package/dist/src/cli/commands/daemon.js +12 -0
- package/dist/src/cli/commands/doctor-checks-config.js +83 -5
- package/dist/src/cli/commands/doctor-checks-runtime.js +25 -71
- package/dist/src/cli/commands/doctor-fixes.js +0 -22
- package/dist/src/cli/commands/doctor-registry.js +14 -7
- package/dist/src/cli/commands/doctor-version.js +6 -2
- package/dist/src/cli/commands/doctor.js +15 -1
- package/dist/src/cli/epic/runner-adapter.js +47 -3
- package/dist/src/cli/index.js +46 -8
- package/dist/src/cli/mcp-tools/aidefence-moflodb-store.js +9 -3
- package/dist/src/cli/memory/bridge-core.js +38 -3
- package/dist/src/cli/memory/bridge-entries.js +124 -22
- package/dist/src/cli/memory/daemon-write-client.js +217 -0
- package/dist/src/cli/memory/memory-initializer.js +64 -0
- package/dist/src/cli/services/daemon-dashboard.js +28 -2
- package/dist/src/cli/services/daemon-memory-rpc.js +335 -0
- package/dist/src/cli/swarm/message-bus/write-through-adapter.js +30 -2
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -9,8 +9,14 @@
|
|
|
9
9
|
* adapter is the seam where the bridge is plugged in. Arbitrary values are
|
|
10
10
|
* JSON-serialised into the bridge's `content` field; namespaces are prefixed
|
|
11
11
|
* with `aidefence:` to isolate from general memory entries.
|
|
12
|
+
*
|
|
13
|
+
* #981 / #986 — writes funnel through `storeEntry` / `deleteEntry` so the
|
|
14
|
+
* single-writer daemon-routing preamble (memory-initializer.ts) covers them.
|
|
15
|
+
* Calling the bridge directly here would bypass the daemon and resurrect
|
|
16
|
+
* the multi-process clobber.
|
|
12
17
|
*/
|
|
13
|
-
import {
|
|
18
|
+
import { bridgeSearchEntries, bridgeGetEntry, isBridgeAvailable, } from '../memory/memory-bridge.js';
|
|
19
|
+
import { storeEntry, deleteEntry } from '../memory/memory-initializer.js';
|
|
14
20
|
const NS_PREFIX = 'aidefence:';
|
|
15
21
|
function prefixNs(namespace) {
|
|
16
22
|
return `${NS_PREFIX}${namespace}`;
|
|
@@ -30,7 +36,7 @@ function safeParse(raw) {
|
|
|
30
36
|
*/
|
|
31
37
|
export class MofloDbAIDefenceStore {
|
|
32
38
|
async store(params) {
|
|
33
|
-
await
|
|
39
|
+
await storeEntry({
|
|
34
40
|
namespace: prefixNs(params.namespace),
|
|
35
41
|
key: params.key,
|
|
36
42
|
value: JSON.stringify(params.value),
|
|
@@ -64,7 +70,7 @@ export class MofloDbAIDefenceStore {
|
|
|
64
70
|
return safeParse(result.entry.content);
|
|
65
71
|
}
|
|
66
72
|
async delete(namespace, key) {
|
|
67
|
-
await
|
|
73
|
+
await deleteEntry({
|
|
68
74
|
namespace: prefixNs(namespace),
|
|
69
75
|
key,
|
|
70
76
|
});
|
|
@@ -68,8 +68,15 @@ export const REQUIRED_BRIDGE_CONTROLLERS = Object.freeze([
|
|
|
68
68
|
export function getBridgeLastError() {
|
|
69
69
|
return lastBridgeError;
|
|
70
70
|
}
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Log a bridge error. By default `MOFLO_BRIDGE_QUIET` suppresses the line
|
|
73
|
+
* to keep test output clean for read-path noise. Pass `{ alwaysLog: true }`
|
|
74
|
+
* for write-path errors that mean data did NOT reach disk — those MUST
|
|
75
|
+
* always log, since the quiet env var is for read-path noise control,
|
|
76
|
+
* not for masking data loss (#982 / #854 / #962 anti-pattern).
|
|
77
|
+
*/
|
|
78
|
+
export function logBridgeError(context, err, opts) {
|
|
79
|
+
if (process.env.MOFLO_BRIDGE_QUIET && !opts?.alwaysLog)
|
|
73
80
|
return;
|
|
74
81
|
const msg = errorDetail(err);
|
|
75
82
|
console.error(`[moflo] ${context}: ${msg}`);
|
|
@@ -186,6 +193,17 @@ export function execRows(db, sql, params) {
|
|
|
186
193
|
* Persist the in-memory sql.js DB back to disk. sql.js is purely in-memory —
|
|
187
194
|
* without an explicit export+writeFileSync after each mutation, writes vanish
|
|
188
195
|
* when the process exits, which breaks store→retrieve across CLI commands.
|
|
196
|
+
*
|
|
197
|
+
* Throws on failure (#982). Callers that issued a mutation MUST treat a
|
|
198
|
+
* persist throw as the mutation having failed: the in-memory DB still has
|
|
199
|
+
* the new row, but it never reached disk and dies with the process.
|
|
200
|
+
*
|
|
201
|
+
* Pre-#982 this swallowed silently and logged once to stderr — the
|
|
202
|
+
* `bridgeStoreEntry` path then returned `{ success: true }` despite the
|
|
203
|
+
* data being lost, the success-lie pattern that cost #854 and #962 too.
|
|
204
|
+
*
|
|
205
|
+
* Use {@link tryPersistBridgeDb} for the rare best-effort caller (cache
|
|
206
|
+
* invalidation, idempotent maintenance) that genuinely doesn't care.
|
|
189
207
|
*/
|
|
190
208
|
export function persistBridgeDb(db, dbPath) {
|
|
191
209
|
// Mirror the read-side resolution so writes land where reads come from.
|
|
@@ -200,7 +218,24 @@ export function persistBridgeDb(db, dbPath) {
|
|
|
200
218
|
atomicWriteFileSync(target, db.export());
|
|
201
219
|
}
|
|
202
220
|
catch (err) {
|
|
203
|
-
logBridgeError('bridge persist failed', err);
|
|
221
|
+
logBridgeError('bridge persist failed', err, { alwaysLog: true });
|
|
222
|
+
throw err;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Best-effort variant of {@link persistBridgeDb}. Returns `{ ok: false }`
|
|
227
|
+
* on failure instead of throwing. Reserve for callers where a missed
|
|
228
|
+
* persist is genuinely acceptable (e.g. cache invalidation that the next
|
|
229
|
+
* mutation will redo). Always-log policy still applies — write failures
|
|
230
|
+
* cannot be silenced.
|
|
231
|
+
*/
|
|
232
|
+
export function tryPersistBridgeDb(db, dbPath) {
|
|
233
|
+
try {
|
|
234
|
+
persistBridgeDb(db, dbPath);
|
|
235
|
+
return { ok: true };
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
return { ok: false, error: err instanceof Error ? err : new Error(String(err)) };
|
|
204
239
|
}
|
|
205
240
|
}
|
|
206
241
|
// Kept in sync with MEMORY_SCHEMA_V3.memory_entries in memory-initializer.ts.
|
|
@@ -7,9 +7,24 @@
|
|
|
7
7
|
*
|
|
8
8
|
* @module v3/cli/bridge-entries
|
|
9
9
|
*/
|
|
10
|
-
import { cosineSim, execRows, generateId, persistBridgeDb, refreshVectorStatsCache, withDb } from './bridge-core.js';
|
|
10
|
+
import { cosineSim, execRows, generateId, logBridgeError, persistBridgeDb, refreshVectorStatsCache, withDb } from './bridge-core.js';
|
|
11
11
|
import { embeddingResponseFrom, getBridgeEmbedder, resolveBridgeEmbedding } from './bridge-embedder.js';
|
|
12
12
|
import { errorDetail } from '../shared/utils/error-detail.js';
|
|
13
|
+
/**
|
|
14
|
+
* Run `persistBridgeDb` and convert any throw into a `persist failed:`
|
|
15
|
+
* error string for the caller. Centralises the #982 single-store /
|
|
16
|
+
* bulk-store / delete pattern so the failure shape can never drift
|
|
17
|
+
* across the three call sites.
|
|
18
|
+
*/
|
|
19
|
+
function tryPersist(db, dbPath) {
|
|
20
|
+
try {
|
|
21
|
+
persistBridgeDb(db, dbPath);
|
|
22
|
+
return { ok: true };
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
return { ok: false, error: `persist failed: ${errorDetail(err)}` };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
13
28
|
function makeEntryCacheKey(namespace, key) {
|
|
14
29
|
const safeNs = String(namespace).replace(/:/g, '_');
|
|
15
30
|
const safeKey = String(key).replace(/:/g, '_');
|
|
@@ -126,18 +141,54 @@ export async function bridgeStoreEntry(options) {
|
|
|
126
141
|
now, now,
|
|
127
142
|
ttl ? now + (ttl * 1000) : null,
|
|
128
143
|
]);
|
|
129
|
-
|
|
144
|
+
// Honest persist (#982). If atomicWriteFileSync throws (Windows EBUSY
|
|
145
|
+
// on a daemon-held file, ENOSPC, perm denied, antivirus rename block),
|
|
146
|
+
// surface it as `success: false` instead of returning a lying success.
|
|
147
|
+
// Skip post-persist bookkeeping so cache + attestation cannot diverge
|
|
148
|
+
// from on-disk state.
|
|
149
|
+
const persisted = tryPersist(ctx.db, options.dbPath);
|
|
150
|
+
if (!persisted.ok) {
|
|
151
|
+
return { success: false, id, error: persisted.error };
|
|
152
|
+
}
|
|
153
|
+
// Post-persist bookkeeping (#994). The row is durable on disk; cache
|
|
154
|
+
// warming, attestation, and statusline stats are observability only.
|
|
155
|
+
// A throw here MUST NOT propagate — withDb would catch it, return null,
|
|
156
|
+
// and storeEntry would fall back to raw sql.js, which then fails with
|
|
157
|
+
// UNIQUE constraint (the bridge already wrote the row) and reports
|
|
158
|
+
// exit 1 even though `memory retrieve` finds the value moments later.
|
|
159
|
+
// Same #982 invariant in the inverse direction.
|
|
130
160
|
const cacheKey = makeEntryCacheKey(namespace, key);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
161
|
+
let cached = true;
|
|
162
|
+
try {
|
|
163
|
+
await cacheSet(registry, cacheKey, { id, key, namespace, content: value, embedding: embeddingJson });
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
cached = false;
|
|
167
|
+
logBridgeError('post-persist cache set failed', err);
|
|
168
|
+
}
|
|
169
|
+
// logAttestation already swallows internally; the await catches any
|
|
170
|
+
// pre-call registry-resolution throw too. Logged so a recurring failure
|
|
171
|
+
// is visible without crashing the write path.
|
|
172
|
+
try {
|
|
173
|
+
await logAttestation(registry, 'store', id, { key, namespace, hasEmbedding: !!embeddingJson });
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
logBridgeError('post-persist attestation failed', err);
|
|
177
|
+
}
|
|
178
|
+
if (embeddingJson) {
|
|
179
|
+
try {
|
|
180
|
+
refreshVectorStatsCache();
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
logBridgeError('post-persist stats refresh failed', err);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
135
186
|
return {
|
|
136
187
|
success: true,
|
|
137
188
|
id,
|
|
138
189
|
embedding: embeddingResponse,
|
|
139
190
|
guarded: true,
|
|
140
|
-
cached
|
|
191
|
+
cached,
|
|
141
192
|
attested: true,
|
|
142
193
|
};
|
|
143
194
|
});
|
|
@@ -152,7 +203,13 @@ export async function bridgeStoreEntries(items, dbPath) {
|
|
|
152
203
|
return [];
|
|
153
204
|
return withDb(dbPath, async (ctx, registry) => {
|
|
154
205
|
const results = [];
|
|
155
|
-
|
|
206
|
+
/**
|
|
207
|
+
* Per-item bookkeeping fired AFTER persist succeeds (#982). If we
|
|
208
|
+
* fired cache/attestation during the loop and then persist threw,
|
|
209
|
+
* the cache would be warm with rows that never reached disk — the
|
|
210
|
+
* exact divergence #982 is fixing in the single-store path. Defer.
|
|
211
|
+
*/
|
|
212
|
+
const deferredBookkeeping = [];
|
|
156
213
|
let anyEmbedded = false;
|
|
157
214
|
let anyWritten = false;
|
|
158
215
|
// Validate the batch once as a single 'bulk-store' mutation. Per-item
|
|
@@ -212,23 +269,60 @@ export async function bridgeStoreEntries(items, dbPath) {
|
|
|
212
269
|
anyWritten = true;
|
|
213
270
|
if (embeddingJson)
|
|
214
271
|
anyEmbedded = true;
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
272
|
+
deferredBookkeeping.push({
|
|
273
|
+
cacheKey: makeEntryCacheKey(namespace, key),
|
|
274
|
+
cacheValue: { id, key, namespace, content: value, embedding: embeddingJson },
|
|
275
|
+
entryId: id,
|
|
276
|
+
entryKey: key,
|
|
277
|
+
namespace,
|
|
278
|
+
hasEmbedding: !!embeddingJson,
|
|
279
|
+
});
|
|
218
280
|
results.push({
|
|
219
281
|
success: true,
|
|
220
282
|
id,
|
|
221
283
|
embedding: embeddingResponse,
|
|
222
284
|
});
|
|
223
285
|
}
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
if (
|
|
231
|
-
|
|
286
|
+
// Honest persist (#982). The whole batch shares one persist call: if it
|
|
287
|
+
// throws, NONE of the rows reached disk, so flip every successful entry
|
|
288
|
+
// to a failure with the same error. Per-row partial success is impossible
|
|
289
|
+
// — sql.js dumps the entire DB snapshot atomically. Bookkeeping (cache
|
|
290
|
+
// + attestation) is deferred until AFTER persist succeeds so the cache
|
|
291
|
+
// cannot warm rows that never reached disk.
|
|
292
|
+
if (anyWritten) {
|
|
293
|
+
const persisted = tryPersist(ctx.db, dbPath);
|
|
294
|
+
if (!persisted.ok) {
|
|
295
|
+
for (let i = 0; i < results.length; i++) {
|
|
296
|
+
if (results[i].success) {
|
|
297
|
+
results[i] = { success: false, id: results[i].id, error: persisted.error };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return results;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// Persist succeeded — fire deferred bookkeeping in parallel.
|
|
304
|
+
// Wrapped in try/catch (#994): rows are already durable, so a cache or
|
|
305
|
+
// attestation throw must not propagate to withDb's catch and downgrade
|
|
306
|
+
// every successful row to a fallback retry that fails on UNIQUE.
|
|
307
|
+
// Promise.all short-circuits, so partial bookkeeping is silently lost
|
|
308
|
+
// on a throw — log so a recurring failure is debuggable.
|
|
309
|
+
try {
|
|
310
|
+
await Promise.all(deferredBookkeeping.flatMap(b => [
|
|
311
|
+
cacheSet(registry, b.cacheKey, b.cacheValue),
|
|
312
|
+
logAttestation(registry, 'store', b.entryId, { key: b.entryKey, namespace: b.namespace, hasEmbedding: b.hasEmbedding }),
|
|
313
|
+
]));
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
logBridgeError('post-persist batch bookkeeping failed', err);
|
|
317
|
+
}
|
|
318
|
+
if (anyEmbedded) {
|
|
319
|
+
try {
|
|
320
|
+
refreshVectorStatsCache();
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
logBridgeError('post-persist stats refresh failed', err);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
232
326
|
return results;
|
|
233
327
|
});
|
|
234
328
|
}
|
|
@@ -397,8 +491,10 @@ export async function bridgeGetEntry(options) {
|
|
|
397
491
|
// Use getAsObject to read columns by name downstream. Bindings are
|
|
398
492
|
// passed as a single array — varargs are silently ignored.
|
|
399
493
|
row = stmt.getAsObject([key, namespace]);
|
|
400
|
-
//
|
|
401
|
-
|
|
494
|
+
// #998: sql.js `getAsObject` zips SELECT column names with their values
|
|
495
|
+
// even on a no-row result, so the returned object always has keys —
|
|
496
|
+
// check the TEXT-NOT-NULL primary key to detect a real row.
|
|
497
|
+
if (!row || row.id == null)
|
|
402
498
|
row = null;
|
|
403
499
|
}
|
|
404
500
|
catch {
|
|
@@ -479,7 +575,13 @@ export async function bridgeDeleteEntry(options) {
|
|
|
479
575
|
// (sql.js writeback semantics — see feedback_sqljs_writeback_clobber.md).
|
|
480
576
|
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.`);
|
|
481
577
|
}
|
|
482
|
-
|
|
578
|
+
// Honest persist (#982). If the persist throws, the DELETE didn't reach
|
|
579
|
+
// disk — the row will reappear on next process load. Surface as a failure
|
|
580
|
+
// and skip cache invalidation so the cache stays consistent with disk.
|
|
581
|
+
const persisted = tryPersist(ctx.db, options.dbPath);
|
|
582
|
+
if (!persisted.ok) {
|
|
583
|
+
return deleteFail(persisted.error);
|
|
584
|
+
}
|
|
483
585
|
await cacheInvalidate(registry, makeEntryCacheKey(namespace, key));
|
|
484
586
|
await logAttestation(registry, 'delete', key, { namespace });
|
|
485
587
|
let remaining = 0;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon write client (#981 / #984 — single-writer architecture).
|
|
3
|
+
*
|
|
4
|
+
* HTTP client for the `POST /api/memory/{store,delete,batch}` RPC added by
|
|
5
|
+
* Story #983. Lets short-lived CLI processes and the long-lived MCP server
|
|
6
|
+
* route their `.moflo/moflo.db` writes through the daemon, which owns the
|
|
7
|
+
* authoritative sql.js handle. Avoids the multi-process clobber from #981.
|
|
8
|
+
*
|
|
9
|
+
* Contract — every function in this module:
|
|
10
|
+
* - Never throws. Any error path returns `{ routed: false }`.
|
|
11
|
+
* - Returns within ≤100ms even if the daemon is dead/slow (HTTP timeout).
|
|
12
|
+
* - Caches daemon health for 5s to keep the hot write path cheap.
|
|
13
|
+
* - Short-circuits without HTTP when:
|
|
14
|
+
* (a) `process.env.MOFLO_IS_DAEMON === '1'` (daemon's own process)
|
|
15
|
+
* (b) `moflo.yaml` has `daemon.auto_start: false`
|
|
16
|
+
*
|
|
17
|
+
* Story #984 ships the client without any consumer wiring — Story #985 / #986
|
|
18
|
+
* add the routing preamble inside `storeEntry` / `deleteEntry` (see
|
|
19
|
+
* `docs/internal/981-writer-audit.md`).
|
|
20
|
+
*
|
|
21
|
+
* @module cli/memory/daemon-write-client
|
|
22
|
+
*/
|
|
23
|
+
import * as http from 'node:http';
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// Constants
|
|
26
|
+
// ============================================================================
|
|
27
|
+
/** Default daemon HTTP port. Mirrors `DEFAULT_DASHBOARD_PORT` in daemon-dashboard.ts. */
|
|
28
|
+
const DEFAULT_DAEMON_PORT = 3117;
|
|
29
|
+
/** HTTP timeout for ALL daemon requests (probe + write). Bounds the worst-case CLI hang. */
|
|
30
|
+
const DAEMON_HTTP_TIMEOUT_MS = 100;
|
|
31
|
+
/** Health-probe cache TTL. Probe at most once per 5s in either direction. */
|
|
32
|
+
const HEALTH_CACHE_TTL_MS = 5_000;
|
|
33
|
+
let healthCache = null;
|
|
34
|
+
let configCache = null;
|
|
35
|
+
/**
|
|
36
|
+
* Test seam: clear all caches. Production callers never invoke this; tests
|
|
37
|
+
* use it between cases so cached state doesn't leak across.
|
|
38
|
+
*/
|
|
39
|
+
export function _resetForTest() {
|
|
40
|
+
healthCache = null;
|
|
41
|
+
configCache = null;
|
|
42
|
+
}
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// Resolve daemon port (env override → moflo.yaml unused for v1 → default)
|
|
45
|
+
// ============================================================================
|
|
46
|
+
function getDaemonPort() {
|
|
47
|
+
const fromEnv = process.env.MOFLO_DAEMON_PORT;
|
|
48
|
+
if (fromEnv) {
|
|
49
|
+
const n = parseInt(fromEnv, 10);
|
|
50
|
+
if (Number.isFinite(n) && n > 0 && n < 65536)
|
|
51
|
+
return n;
|
|
52
|
+
}
|
|
53
|
+
return DEFAULT_DAEMON_PORT;
|
|
54
|
+
}
|
|
55
|
+
// ============================================================================
|
|
56
|
+
// Daemon-disabled check (cached) — reads `daemon.auto_start` from moflo.yaml
|
|
57
|
+
// ============================================================================
|
|
58
|
+
async function isDaemonEnabledInConfig() {
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
if (configCache && (now - configCache.checkedAt) < HEALTH_CACHE_TTL_MS) {
|
|
61
|
+
return configCache.daemonEnabled;
|
|
62
|
+
}
|
|
63
|
+
let enabled = true; // default-on — matches moflo.yaml default
|
|
64
|
+
try {
|
|
65
|
+
const { loadMofloConfig } = await import('../config/moflo-config.js');
|
|
66
|
+
const config = loadMofloConfig();
|
|
67
|
+
enabled = config?.daemon?.auto_start !== false;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// If we can't read the config (e.g., not in a moflo project), assume
|
|
71
|
+
// daemon-enabled — we'll still probe and the probe will fail safely.
|
|
72
|
+
enabled = true;
|
|
73
|
+
}
|
|
74
|
+
configCache = { daemonEnabled: enabled, checkedAt: now };
|
|
75
|
+
return enabled;
|
|
76
|
+
}
|
|
77
|
+
// ============================================================================
|
|
78
|
+
// Health probe (cached) — GET /api/status
|
|
79
|
+
// ============================================================================
|
|
80
|
+
/**
|
|
81
|
+
* Cached daemon health probe. Returns true iff the daemon's HTTP server
|
|
82
|
+
* is reachable on `127.0.0.1:<port>` within {@link DAEMON_HTTP_TIMEOUT_MS}.
|
|
83
|
+
*
|
|
84
|
+
* Cache survives 5s in either direction — so a daemon that just came up
|
|
85
|
+
* is missed for ≤5s, and a daemon that just died is incorrectly assumed
|
|
86
|
+
* up for ≤5s. Caller falls back to direct write either way.
|
|
87
|
+
*/
|
|
88
|
+
export async function isDaemonAvailable() {
|
|
89
|
+
// 1) In-daemon short-circuit — never probe ourselves.
|
|
90
|
+
if (process.env.MOFLO_IS_DAEMON === '1')
|
|
91
|
+
return false;
|
|
92
|
+
// 2) Config short-circuit — daemon explicitly disabled.
|
|
93
|
+
if (!(await isDaemonEnabledInConfig()))
|
|
94
|
+
return false;
|
|
95
|
+
// 3) Cached probe.
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
if (healthCache && (now - healthCache.checkedAt) < HEALTH_CACHE_TTL_MS) {
|
|
98
|
+
return healthCache.available;
|
|
99
|
+
}
|
|
100
|
+
const available = await probeDaemonHealth(getDaemonPort());
|
|
101
|
+
healthCache = { available, checkedAt: now };
|
|
102
|
+
return available;
|
|
103
|
+
}
|
|
104
|
+
function probeDaemonHealth(port) {
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
let done = false;
|
|
107
|
+
const finish = (ok) => {
|
|
108
|
+
if (done)
|
|
109
|
+
return;
|
|
110
|
+
done = true;
|
|
111
|
+
resolve(ok);
|
|
112
|
+
};
|
|
113
|
+
const req = http.get({ host: '127.0.0.1', port, path: '/api/status', timeout: DAEMON_HTTP_TIMEOUT_MS }, (res) => {
|
|
114
|
+
// Discard body; status code is enough.
|
|
115
|
+
res.resume();
|
|
116
|
+
finish(res.statusCode === 200);
|
|
117
|
+
});
|
|
118
|
+
req.on('error', () => finish(false));
|
|
119
|
+
req.on('timeout', () => { req.destroy(); finish(false); });
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
// ============================================================================
|
|
123
|
+
// Write helpers — POST /api/memory/{store,delete}
|
|
124
|
+
// ============================================================================
|
|
125
|
+
/**
|
|
126
|
+
* Route a single store write through the daemon. Returns
|
|
127
|
+
* `{ routed: false }` if the daemon is unavailable, the env disables
|
|
128
|
+
* routing, or any HTTP error fires.
|
|
129
|
+
*/
|
|
130
|
+
export async function tryDaemonStore(opts) {
|
|
131
|
+
if (!(await isDaemonAvailable()))
|
|
132
|
+
return { routed: false };
|
|
133
|
+
return postJson('/api/memory/store', {
|
|
134
|
+
namespace: opts.namespace,
|
|
135
|
+
key: opts.key,
|
|
136
|
+
value: opts.value,
|
|
137
|
+
tags: opts.tags,
|
|
138
|
+
ttl: opts.ttl,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Route a single delete through the daemon. Returns `{ routed: false }`
|
|
143
|
+
* on any failure mode.
|
|
144
|
+
*/
|
|
145
|
+
export async function tryDaemonDelete(opts) {
|
|
146
|
+
if (!(await isDaemonAvailable()))
|
|
147
|
+
return { routed: false };
|
|
148
|
+
return postJson('/api/memory/delete', {
|
|
149
|
+
namespace: opts.namespace,
|
|
150
|
+
key: opts.key,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
// ============================================================================
|
|
154
|
+
// Internal HTTP poster — never throws, bounded timeout
|
|
155
|
+
// ============================================================================
|
|
156
|
+
function postJson(path, body) {
|
|
157
|
+
return new Promise((resolve) => {
|
|
158
|
+
let done = false;
|
|
159
|
+
const finish = (result) => {
|
|
160
|
+
if (done)
|
|
161
|
+
return;
|
|
162
|
+
done = true;
|
|
163
|
+
// On routed-failure, invalidate the health cache so the next call
|
|
164
|
+
// re-probes and trips back to direct-write quickly when the daemon
|
|
165
|
+
// is dying.
|
|
166
|
+
if (result.routed === false)
|
|
167
|
+
healthCache = null;
|
|
168
|
+
resolve(result);
|
|
169
|
+
};
|
|
170
|
+
const payload = JSON.stringify(body);
|
|
171
|
+
const req = http.request({
|
|
172
|
+
host: '127.0.0.1',
|
|
173
|
+
port: getDaemonPort(),
|
|
174
|
+
path,
|
|
175
|
+
method: 'POST',
|
|
176
|
+
timeout: DAEMON_HTTP_TIMEOUT_MS,
|
|
177
|
+
headers: {
|
|
178
|
+
'Content-Type': 'application/json',
|
|
179
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
180
|
+
},
|
|
181
|
+
}, (res) => {
|
|
182
|
+
let buf = '';
|
|
183
|
+
res.setEncoding('utf8');
|
|
184
|
+
res.on('data', (chunk) => { buf += chunk; });
|
|
185
|
+
res.on('end', () => {
|
|
186
|
+
// Status >=500 is a daemon-side fault; treat as unrouted so the
|
|
187
|
+
// caller falls back. Status 4xx (validation) is ALSO unrouted —
|
|
188
|
+
// we don't want a malformed payload silently lost just because
|
|
189
|
+
// the HTTP delivery succeeded.
|
|
190
|
+
const status = res.statusCode ?? 0;
|
|
191
|
+
if (status < 200 || status >= 300) {
|
|
192
|
+
finish({ routed: false });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const data = JSON.parse(buf);
|
|
197
|
+
finish({
|
|
198
|
+
routed: true,
|
|
199
|
+
ok: !!data?.ok,
|
|
200
|
+
id: typeof data?.id === 'string' ? data.id : undefined,
|
|
201
|
+
deleted: typeof data?.deleted === 'boolean' ? data.deleted : undefined,
|
|
202
|
+
error: typeof data?.error === 'string' ? data.error : undefined,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
finish({ routed: false });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
res.on('error', () => finish({ routed: false }));
|
|
210
|
+
});
|
|
211
|
+
req.on('error', () => finish({ routed: false }));
|
|
212
|
+
req.on('timeout', () => { req.destroy(); finish({ routed: false }); });
|
|
213
|
+
req.write(payload);
|
|
214
|
+
req.end();
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
//# sourceMappingURL=daemon-write-client.js.map
|
|
@@ -20,6 +20,18 @@ import { parseEmbeddingJson, toFloat32 } from './controllers/_shared.js';
|
|
|
20
20
|
import { writeVectorStatsJson } from './bridge-core.js';
|
|
21
21
|
import { errorDetail } from '../shared/utils/error-detail.js';
|
|
22
22
|
import { MOFLO_DIR, hnswIndexPath, legacyMemoryDbPath, memoryDbPath, } from '../services/moflo-paths.js';
|
|
23
|
+
import { tryDaemonStore, tryDaemonDelete } from './daemon-write-client.js';
|
|
24
|
+
// #981 — daemon-write-client throws are a contract violation (it's documented
|
|
25
|
+
// as never-throw). When a throw escapes anyway, log to stderr ONCE per process
|
|
26
|
+
// and fall through to the direct-write path. Silent swallow would hide bugs;
|
|
27
|
+
// per-call logging would spam.
|
|
28
|
+
let _routingFaultLogged = false;
|
|
29
|
+
function logRoutingFault(err) {
|
|
30
|
+
if (_routingFaultLogged)
|
|
31
|
+
return;
|
|
32
|
+
_routingFaultLogged = true;
|
|
33
|
+
process.stderr.write(`moflo: daemon-write-client routing fault (#981, falling back to direct write): ${errorDetail(err)}\n`);
|
|
34
|
+
}
|
|
23
35
|
/**
|
|
24
36
|
* Write vector-stats.json cache for the statusline (no subprocess needed).
|
|
25
37
|
* Called after memory store in the raw-sql.js fallback path. The bridge path
|
|
@@ -1561,6 +1573,30 @@ export async function storeEntry(options) {
|
|
|
1561
1573
|
merged.add('locked');
|
|
1562
1574
|
options = { ...options, namespace: 'learnings', tags: [...merged] };
|
|
1563
1575
|
}
|
|
1576
|
+
// #981 — single-writer routing. When an external daemon is reachable AND
|
|
1577
|
+
// we're not the daemon ourselves AND no custom dbPath was supplied, route
|
|
1578
|
+
// the write through the daemon's HTTP RPC so its in-memory sql.js handle
|
|
1579
|
+
// stays authoritative. Any failure path falls through to the existing
|
|
1580
|
+
// bridge / raw-sql.js logic below — byte-identical behaviour to today.
|
|
1581
|
+
if (!options.dbPath
|
|
1582
|
+
&& process.env.MOFLO_IS_DAEMON !== '1'
|
|
1583
|
+
&& process.env.MOFLO_DISABLE_DAEMON_ROUTING !== '1') {
|
|
1584
|
+
try {
|
|
1585
|
+
const routed = await tryDaemonStore({
|
|
1586
|
+
namespace: options.namespace ?? 'default',
|
|
1587
|
+
key: options.key,
|
|
1588
|
+
value: options.value,
|
|
1589
|
+
tags: options.tags,
|
|
1590
|
+
ttl: options.ttl,
|
|
1591
|
+
});
|
|
1592
|
+
if (routed.routed && routed.ok) {
|
|
1593
|
+
return { success: true, id: routed.id ?? '' };
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
catch (err) {
|
|
1597
|
+
logRoutingFault(err);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1564
1600
|
// ADR-053: Try AgentDB v3 bridge first. The bridge calls
|
|
1565
1601
|
// refreshVectorStatsCache() itself (bridge-entries.ts:191) — a second
|
|
1566
1602
|
// write here was redundant and previously clobbered the correct count
|
|
@@ -1973,6 +2009,34 @@ export async function getEntry(options) {
|
|
|
1973
2009
|
* Issue #980: Properly supports namespaced entries
|
|
1974
2010
|
*/
|
|
1975
2011
|
export async function deleteEntry(options) {
|
|
2012
|
+
// #981 — single-writer routing for deletes. Same gates as storeEntry:
|
|
2013
|
+
// not the daemon, no custom dbPath, routing not opted out. Failure paths
|
|
2014
|
+
// fall through to the existing bridge / raw-sql.js logic below.
|
|
2015
|
+
if (!options.dbPath
|
|
2016
|
+
&& process.env.MOFLO_IS_DAEMON !== '1'
|
|
2017
|
+
&& process.env.MOFLO_DISABLE_DAEMON_ROUTING !== '1') {
|
|
2018
|
+
try {
|
|
2019
|
+
const routed = await tryDaemonDelete({
|
|
2020
|
+
namespace: options.namespace ?? 'default',
|
|
2021
|
+
key: options.key,
|
|
2022
|
+
});
|
|
2023
|
+
if (routed.routed && routed.ok) {
|
|
2024
|
+
return {
|
|
2025
|
+
success: true,
|
|
2026
|
+
deleted: routed.deleted ?? true,
|
|
2027
|
+
key: options.key,
|
|
2028
|
+
namespace: options.namespace ?? 'default',
|
|
2029
|
+
// Daemon doesn't surface remainingEntries; callers that depend on
|
|
2030
|
+
// this value (the `flo memory delete` CLI) read it from a
|
|
2031
|
+
// subsequent stat query, not this return shape.
|
|
2032
|
+
remainingEntries: 0,
|
|
2033
|
+
};
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
catch (err) {
|
|
2037
|
+
logRoutingFault(err);
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
1976
2040
|
// ADR-053: Try AgentDB v3 bridge first
|
|
1977
2041
|
const bridge = await getBridge();
|
|
1978
2042
|
if (bridge) {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { createServer } from 'node:http';
|
|
16
16
|
import { errorDetail } from '../shared/utils/error-detail.js';
|
|
17
|
+
import { handleMemoryStore, handleMemoryDelete, handleMemoryBatch, matchMemoryRpcRoute, } from './daemon-memory-rpc.js';
|
|
17
18
|
export const DEFAULT_DASHBOARD_PORT = 3117;
|
|
18
19
|
/**
|
|
19
20
|
* Create a MemoryAccessor backed by the sql.js/HNSW memory database.
|
|
@@ -36,7 +37,12 @@ export async function createDashboardMemoryAccessor() {
|
|
|
36
37
|
async write(namespace, key, value) {
|
|
37
38
|
const result = await storeEntry({ key, value: typeof value === 'string' ? value : JSON.stringify(value), namespace, upsert: true });
|
|
38
39
|
if (!result.success) {
|
|
39
|
-
|
|
40
|
+
// #982 — surface the failure to callers (runner.storeProgress wraps
|
|
41
|
+
// this in a try/catch + console.warn). Pre-#982 we just warned and
|
|
42
|
+
// returned cleanly, which let the spell engine claim success on a
|
|
43
|
+
// run whose progress writes never reached disk.
|
|
44
|
+
const err = `memory.write(${namespace}, ${key}) failed: ${result.error ?? 'unknown'}`;
|
|
45
|
+
throw new Error(err);
|
|
40
46
|
}
|
|
41
47
|
},
|
|
42
48
|
async search(namespace, query) {
|
|
@@ -320,7 +326,8 @@ async function handleRequest(req, res, daemon, opts) {
|
|
|
320
326
|
const url = req.url ?? '/';
|
|
321
327
|
const method = req.method ?? 'GET';
|
|
322
328
|
try {
|
|
323
|
-
// POST: schedule actions (disable / enable / run)
|
|
329
|
+
// POST: schedule actions (disable / enable / run) and memory write RPC
|
|
330
|
+
// (#981 single-writer architecture — Story #983). Only 127.0.0.1 traffic
|
|
324
331
|
// reaches here (server.listen bind), so no CSRF layer is needed. Any
|
|
325
332
|
// other POST falls through to the read-only 405 below.
|
|
326
333
|
if (method === 'POST') {
|
|
@@ -329,11 +336,30 @@ async function handleRequest(req, res, daemon, opts) {
|
|
|
329
336
|
await handleScheduleAction(res, daemon, action.id, action.verb);
|
|
330
337
|
return;
|
|
331
338
|
}
|
|
339
|
+
const memoryRoute = matchMemoryRpcRoute(url);
|
|
340
|
+
if (memoryRoute === 'store') {
|
|
341
|
+
await handleMemoryStore(req, res, opts.memory);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (memoryRoute === 'delete') {
|
|
345
|
+
await handleMemoryDelete(req, res, opts.memory);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (memoryRoute === 'batch') {
|
|
349
|
+
await handleMemoryBatch(req, res, opts.memory);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
332
352
|
}
|
|
333
353
|
if (method !== 'GET') {
|
|
334
354
|
sendJson(res, 405, { error: 'Method not allowed' });
|
|
335
355
|
return;
|
|
336
356
|
}
|
|
357
|
+
// Memory RPC endpoints are POST-only; return 405 (not 404) on GET so
|
|
358
|
+
// clients distinguish "wrong method" from "no such endpoint".
|
|
359
|
+
if (matchMemoryRpcRoute(url)) {
|
|
360
|
+
sendJson(res, 405, { error: 'Method not allowed' });
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
337
363
|
if (url === '/') {
|
|
338
364
|
sendHtml(res, DASHBOARD_HTML);
|
|
339
365
|
}
|