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.
@@ -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 { bridgeStoreEntry, bridgeSearchEntries, bridgeGetEntry, bridgeDeleteEntry, isBridgeAvailable, } from '../memory/memory-bridge.js';
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 bridgeStoreEntry({
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 bridgeDeleteEntry({
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
- function logBridgeError(context, err) {
72
- if (process.env.MOFLO_BRIDGE_QUIET)
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
- persistBridgeDb(ctx.db, options.dbPath);
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
- await cacheSet(registry, cacheKey, { id, key, namespace, content: value, embedding: embeddingJson });
132
- await logAttestation(registry, 'store', id, { key, namespace, hasEmbedding: !!embeddingJson });
133
- if (embeddingJson)
134
- refreshVectorStatsCache();
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: true,
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
- const bookkeeping = [];
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
- const cacheKey = makeEntryCacheKey(namespace, key);
216
- bookkeeping.push(cacheSet(registry, cacheKey, { id, key, namespace, content: value, embedding: embeddingJson }));
217
- bookkeeping.push(logAttestation(registry, 'store', id, { key, namespace, hasEmbedding: !!embeddingJson }));
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
- // Cache writes and attestation logs are independent post-hoc bookkeeping
225
- // overlap them while the final persist runs. SQL inserts above stayed
226
- // sequential because sql.js is single-threaded.
227
- await Promise.all(bookkeeping);
228
- if (anyWritten)
229
- persistBridgeDb(ctx.db, dbPath);
230
- if (anyEmbedded)
231
- refreshVectorStatsCache();
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
- // getAsObject returns {} when no row matches; treat as null.
401
- if (!row || Object.keys(row).length === 0)
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
- persistBridgeDb(ctx.db, options.dbPath);
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
- console.warn(`[dashboard] memory.write(${namespace}, ${key}) failed: ${result.error ?? 'unknown'}`);
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). Only 127.0.0.1 traffic
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
  }