@shadowforge0/aquifer-memory 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/doctor.js ADDED
@@ -0,0 +1,924 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { backendCapabilities } = require('./backends/capabilities');
7
+ const { MCP_TOOL_MANIFEST } = require('./mcp-manifest');
8
+ const { createOperatorObservability } = require('./operator-observability');
9
+
10
+ const STATUS_ORDER = {
11
+ ok: 0,
12
+ warn: 1,
13
+ fail: 2,
14
+ };
15
+
16
+ const EXPECTED_MCP_TOOL_COUNT = 10;
17
+
18
+ function qi(identifier) {
19
+ return `"${String(identifier).replace(/"/g, '""')}"`;
20
+ }
21
+
22
+ function sqlSchema(schema) {
23
+ if (typeof schema === 'string' && schema.startsWith('"') && schema.endsWith('"')) return schema;
24
+ return qi(schema || 'public');
25
+ }
26
+
27
+ function toCount(value) {
28
+ const number = Number(value);
29
+ return Number.isFinite(number) ? number : 0;
30
+ }
31
+
32
+ function overallStatus(checks = []) {
33
+ let current = 'ok';
34
+ for (const check of checks) {
35
+ if (!check || !STATUS_ORDER.hasOwnProperty(check.status)) continue;
36
+ if (STATUS_ORDER[check.status] > STATUS_ORDER[current]) current = check.status;
37
+ }
38
+ return current;
39
+ }
40
+
41
+ function makeCheck(id, status, summary, details = {}, nextAction = null) {
42
+ return {
43
+ id,
44
+ status,
45
+ summary,
46
+ details,
47
+ nextAction,
48
+ };
49
+ }
50
+
51
+ function isTableMissing(error) {
52
+ return error && error.code === '42P01';
53
+ }
54
+
55
+ function readPackageMeta(overrides = {}) {
56
+ if (overrides.packageName && overrides.packageVersion) {
57
+ return {
58
+ name: overrides.packageName,
59
+ version: overrides.packageVersion,
60
+ };
61
+ }
62
+ try {
63
+ const packageJsonPath = overrides.packageJsonPath || path.join(__dirname, '..', 'package.json');
64
+ const parsed = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
65
+ return {
66
+ name: overrides.packageName || parsed.name || null,
67
+ version: overrides.packageVersion || parsed.version || null,
68
+ };
69
+ } catch {
70
+ return {
71
+ name: overrides.packageName || null,
72
+ version: overrides.packageVersion || null,
73
+ };
74
+ }
75
+ }
76
+
77
+ function resolveManifestTools(manifest) {
78
+ if (Array.isArray(manifest)) return manifest;
79
+ if (manifest && Array.isArray(manifest.tools)) return manifest.tools;
80
+ return [];
81
+ }
82
+
83
+ function normalizeToolCount(input) {
84
+ if (input === null || input === undefined) return null;
85
+ if (Array.isArray(input)) return input.length;
86
+ if (typeof input === 'number') return Number.isFinite(input) ? input : null;
87
+ if (typeof input === 'object' && Array.isArray(input.tools)) return input.tools.length;
88
+ return null;
89
+ }
90
+
91
+ function sumCounts(counts = {}) {
92
+ return Object.values(counts).reduce((sum, value) => sum + toCount(value), 0);
93
+ }
94
+
95
+ function readJsonSafe(filePath) {
96
+ try {
97
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ function fileExists(filePath) {
104
+ try {
105
+ return fs.existsSync(filePath);
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ function pickCapabilitySummary(profile = {}) {
112
+ const capabilities = profile.capabilities || {};
113
+ return {
114
+ persistence: capabilities.persistence || 'unknown',
115
+ curatedBootstrap: capabilities.curatedBootstrap || 'unknown',
116
+ curatedRecall: capabilities.curatedRecall || 'unknown',
117
+ finalizationLedger: capabilities.finalizationLedger || 'unknown',
118
+ operatorCompaction: capabilities.operatorCompaction || 'unknown',
119
+ operatorCheckpoint: capabilities.operatorCheckpoint || 'unknown',
120
+ migrationHandshake: capabilities.migrationHandshake || 'unknown',
121
+ };
122
+ }
123
+
124
+ async function checkDbReadiness({ pool, schemaName, schemaSql, tenantId, dbConfigured, backendKind }) {
125
+ if (backendKind !== 'postgres') {
126
+ return makeCheck(
127
+ 'db',
128
+ 'warn',
129
+ 'Database/schema readiness is only available on the PostgreSQL backend.',
130
+ {
131
+ backendKind,
132
+ configured: Boolean(dbConfigured),
133
+ schema: schemaName,
134
+ tenantId,
135
+ },
136
+ 'Use the PostgreSQL backend if you need DB-backed governance diagnostics.',
137
+ );
138
+ }
139
+ if (!dbConfigured || !pool || typeof pool.query !== 'function') {
140
+ return makeCheck(
141
+ 'db',
142
+ 'fail',
143
+ 'Database is not configured for read-only governance checks.',
144
+ {
145
+ configured: Boolean(dbConfigured),
146
+ schema: schemaName,
147
+ tenantId,
148
+ },
149
+ 'Configure a PostgreSQL database connection, then rerun `aquifer doctor`.',
150
+ );
151
+ }
152
+
153
+ try {
154
+ const tableResult = await pool.query(
155
+ `SELECT
156
+ EXISTS(
157
+ SELECT 1
158
+ FROM information_schema.tables
159
+ WHERE table_schema = $1
160
+ AND table_name = 'sessions'
161
+ ) AS has_sessions,
162
+ EXISTS(
163
+ SELECT 1
164
+ FROM information_schema.tables
165
+ WHERE table_schema = $1
166
+ AND table_name = 'session_summaries'
167
+ ) AS has_session_summaries,
168
+ EXISTS(
169
+ SELECT 1
170
+ FROM information_schema.tables
171
+ WHERE table_schema = $1
172
+ AND table_name = 'memory_records'
173
+ ) AS has_memory_records,
174
+ EXISTS(
175
+ SELECT 1
176
+ FROM information_schema.tables
177
+ WHERE table_schema = $1
178
+ AND table_name = 'session_finalizations'
179
+ ) AS has_session_finalizations,
180
+ EXISTS(
181
+ SELECT 1
182
+ FROM information_schema.tables
183
+ WHERE table_schema = $1
184
+ AND table_name = 'compaction_runs'
185
+ ) AS has_compaction_runs,
186
+ EXISTS(
187
+ SELECT 1
188
+ FROM information_schema.tables
189
+ WHERE table_schema = $1
190
+ AND table_name = 'checkpoint_runs'
191
+ ) AS has_checkpoint_runs`,
192
+ [schemaName],
193
+ );
194
+ const row = tableResult.rows[0] || {};
195
+ const tables = {
196
+ sessions: row.has_sessions === true,
197
+ sessionSummaries: row.has_session_summaries === true,
198
+ memoryRecords: row.has_memory_records === true,
199
+ sessionFinalizations: row.has_session_finalizations === true,
200
+ compactionRuns: row.has_compaction_runs === true,
201
+ checkpointRuns: row.has_checkpoint_runs === true,
202
+ };
203
+ const missingBase = [];
204
+ if (!tables.sessions) missingBase.push('sessions');
205
+ if (!tables.sessionSummaries) missingBase.push('session_summaries');
206
+
207
+ if (missingBase.length > 0) {
208
+ return makeCheck(
209
+ 'db',
210
+ 'fail',
211
+ `Schema ${schemaName} is missing required tables: ${missingBase.join(', ')}.`,
212
+ {
213
+ configured: true,
214
+ schema: schemaName,
215
+ tenantId,
216
+ tables,
217
+ },
218
+ 'Run `aquifer migrate`, then rerun `aquifer doctor`.',
219
+ );
220
+ }
221
+
222
+ await pool.query(
223
+ `SELECT 1
224
+ FROM ${schemaSql}.sessions
225
+ WHERE tenant_id = $1
226
+ LIMIT 1`,
227
+ [tenantId],
228
+ );
229
+
230
+ const missingGovernance = [];
231
+ if (!tables.memoryRecords) missingGovernance.push('memory_records');
232
+ if (!tables.sessionFinalizations) missingGovernance.push('session_finalizations');
233
+ if (!tables.compactionRuns) missingGovernance.push('compaction_runs');
234
+ if (!tables.checkpointRuns) missingGovernance.push('checkpoint_runs');
235
+
236
+ if (missingGovernance.length > 0) {
237
+ return makeCheck(
238
+ 'db',
239
+ 'warn',
240
+ `Schema ${schemaName} is reachable, but governance tables are incomplete: ${missingGovernance.join(', ')}.`,
241
+ {
242
+ configured: true,
243
+ schema: schemaName,
244
+ tenantId,
245
+ tables,
246
+ },
247
+ 'Run `aquifer migrate` to materialize the missing governance tables.',
248
+ );
249
+ }
250
+
251
+ return makeCheck(
252
+ 'db',
253
+ 'ok',
254
+ `Schema ${schemaName} is reachable and tenant-scoped reads are ready.`,
255
+ {
256
+ configured: true,
257
+ schema: schemaName,
258
+ tenantId,
259
+ tables,
260
+ },
261
+ null,
262
+ );
263
+ } catch (error) {
264
+ return makeCheck(
265
+ 'db',
266
+ 'fail',
267
+ `Database readiness check failed: ${error.message}`,
268
+ {
269
+ configured: true,
270
+ schema: schemaName,
271
+ tenantId,
272
+ error: error.message,
273
+ },
274
+ 'Verify the database connection, schema name, and tenant configuration.',
275
+ );
276
+ }
277
+ }
278
+
279
+ async function checkMigrations({ backendKind, listPendingMigrations }) {
280
+ if (backendKind !== 'postgres') {
281
+ return makeCheck(
282
+ 'migrations',
283
+ 'warn',
284
+ 'Migration readiness is only available on the PostgreSQL backend.',
285
+ {
286
+ backendKind,
287
+ },
288
+ 'Use the PostgreSQL backend if you need migration and governance workflows.',
289
+ );
290
+ }
291
+ if (typeof listPendingMigrations !== 'function') {
292
+ return makeCheck(
293
+ 'migrations',
294
+ 'warn',
295
+ 'Migration planner is not wired into this doctor integration.',
296
+ {},
297
+ 'Wire `listPendingMigrations()` into the doctor runner.',
298
+ );
299
+ }
300
+ try {
301
+ const plan = await listPendingMigrations();
302
+ const pending = Array.isArray(plan && plan.pending) ? plan.pending : [];
303
+ if (pending.length > 0) {
304
+ return makeCheck(
305
+ 'migrations',
306
+ 'fail',
307
+ `${pending.length} pending migration${pending.length === 1 ? '' : 's'} detected.`,
308
+ {
309
+ required: Array.isArray(plan.required) ? plan.required : [],
310
+ applied: Array.isArray(plan.applied) ? plan.applied : [],
311
+ pending,
312
+ },
313
+ 'Run `aquifer migrate`, then rerun `aquifer doctor`.',
314
+ );
315
+ }
316
+ return makeCheck(
317
+ 'migrations',
318
+ 'ok',
319
+ 'No pending migrations.',
320
+ {
321
+ required: Array.isArray(plan.required) ? plan.required : [],
322
+ applied: Array.isArray(plan.applied) ? plan.applied : [],
323
+ pending: [],
324
+ },
325
+ null,
326
+ );
327
+ } catch (error) {
328
+ return makeCheck(
329
+ 'migrations',
330
+ 'fail',
331
+ `Migration readiness check failed: ${error.message}`,
332
+ {
333
+ error: error.message,
334
+ },
335
+ 'Verify the migration planner can read the target schema.',
336
+ );
337
+ }
338
+ }
339
+
340
+ function checkPackage({ packageMeta, nodeVersion }) {
341
+ const details = {
342
+ packageName: packageMeta.name,
343
+ packageVersion: packageMeta.version,
344
+ nodeVersion,
345
+ };
346
+ if (!packageMeta.version) {
347
+ return makeCheck(
348
+ 'package',
349
+ 'warn',
350
+ `Aquifer package metadata is incomplete${nodeVersion ? ` on Node ${nodeVersion}` : ''}.`,
351
+ details,
352
+ 'Verify the installed package metadata before release or support work.',
353
+ );
354
+ }
355
+ return makeCheck(
356
+ 'package',
357
+ 'ok',
358
+ `${packageMeta.name || 'aquifer'}@${packageMeta.version} on Node ${nodeVersion}.`,
359
+ details,
360
+ null,
361
+ );
362
+ }
363
+
364
+ function checkBackend({ backendKind, backendProfile }) {
365
+ const profile = backendCapabilities(backendKind);
366
+ const profileName = backendProfile || profile.profile;
367
+ const details = {
368
+ kind: profile.kind,
369
+ profile: profileName,
370
+ label: profile.label,
371
+ capabilities: pickCapabilitySummary(profile),
372
+ };
373
+ if (profile.profile !== 'full') {
374
+ return makeCheck(
375
+ 'backend',
376
+ 'warn',
377
+ `${profile.label}; governance coverage is degraded on this backend.`,
378
+ details,
379
+ profile.upgradeHint || 'Use the PostgreSQL backend for full governance coverage.',
380
+ );
381
+ }
382
+ return makeCheck(
383
+ 'backend',
384
+ 'ok',
385
+ profile.label,
386
+ details,
387
+ null,
388
+ );
389
+ }
390
+
391
+ function checkServing({ memoryServing }) {
392
+ const mode = memoryServing && memoryServing.servingMode ? memoryServing.servingMode : 'legacy';
393
+ const activeScopeKey = memoryServing && memoryServing.defaultActiveScopeKey
394
+ ? memoryServing.defaultActiveScopeKey
395
+ : null;
396
+ const activeScopePath = memoryServing && Array.isArray(memoryServing.defaultActiveScopePath)
397
+ ? memoryServing.defaultActiveScopePath
398
+ : [];
399
+ const details = {
400
+ mode,
401
+ activeScopeKey,
402
+ activeScopePath,
403
+ };
404
+ if (mode === 'curated' && !activeScopeKey && activeScopePath.length === 0) {
405
+ return makeCheck(
406
+ 'serving',
407
+ 'warn',
408
+ 'Serving mode is curated, but no active scope is configured.',
409
+ details,
410
+ 'Set `AQUIFER_MEMORY_ACTIVE_SCOPE_KEY` or `AQUIFER_MEMORY_ACTIVE_SCOPE_PATH` for curated serving.',
411
+ );
412
+ }
413
+ return makeCheck(
414
+ 'serving',
415
+ 'ok',
416
+ mode === 'curated'
417
+ ? `Curated serving is scoped to ${activeScopeKey || activeScopePath[activeScopePath.length - 1]}.`
418
+ : 'Legacy serving mode is active.',
419
+ details,
420
+ null,
421
+ );
422
+ }
423
+
424
+ function checkMcpTools({ manifestTools, runtimeTools, expectedMcpToolCount }) {
425
+ const manifestCount = manifestTools.length;
426
+ const runtimeCount = normalizeToolCount(runtimeTools);
427
+ const details = {
428
+ expectedCount: expectedMcpToolCount,
429
+ manifestCount,
430
+ runtimeCount,
431
+ manifestTools: manifestTools.map(tool => tool.name).filter(Boolean),
432
+ };
433
+ if (manifestCount !== expectedMcpToolCount) {
434
+ return makeCheck(
435
+ 'mcp-tools',
436
+ 'fail',
437
+ `MCP manifest exposes ${manifestCount} tools; expected ${expectedMcpToolCount}.`,
438
+ details,
439
+ 'Restore the fixed MCP surface before release or deployment.',
440
+ );
441
+ }
442
+ if (runtimeCount !== null && runtimeCount !== manifestCount) {
443
+ return makeCheck(
444
+ 'mcp-tools',
445
+ 'fail',
446
+ `MCP runtime exposes ${runtimeCount} tools, but the manifest declares ${manifestCount}.`,
447
+ details,
448
+ 'Reload the MCP server so runtime and manifest tool counts match.',
449
+ );
450
+ }
451
+ return makeCheck(
452
+ 'mcp-tools',
453
+ 'ok',
454
+ `MCP surface exposes ${manifestCount} tools.`,
455
+ details,
456
+ null,
457
+ );
458
+ }
459
+
460
+ async function checkSessionBacklog({ pool, schemaSql, tenantId, backendKind }) {
461
+ if (backendKind !== 'postgres') {
462
+ return makeCheck(
463
+ 'session-backlog',
464
+ 'warn',
465
+ 'Session backlog inspection is only available on the PostgreSQL backend.',
466
+ {
467
+ backendKind,
468
+ },
469
+ 'Use the PostgreSQL backend to inspect DB-backed session processing backlog.',
470
+ );
471
+ }
472
+ try {
473
+ const result = await pool.query(
474
+ `SELECT processing_status, COUNT(*)::int AS count
475
+ FROM ${schemaSql}.sessions
476
+ WHERE tenant_id = $1
477
+ AND processing_status IN ('pending', 'processing', 'partial', 'failed')
478
+ GROUP BY processing_status`,
479
+ [tenantId],
480
+ );
481
+ const counts = Object.fromEntries(result.rows.map(row => [row.processing_status, toCount(row.count)]));
482
+ const failed = toCount(counts.failed);
483
+ const backlog = sumCounts(counts);
484
+ if (failed > 0) {
485
+ return makeCheck(
486
+ 'session-backlog',
487
+ 'fail',
488
+ `${failed} failed session${failed === 1 ? '' : 's'} require attention.`,
489
+ {
490
+ counts,
491
+ totalBacklog: backlog,
492
+ },
493
+ 'Inspect failed session processing before relying on continuity output.',
494
+ );
495
+ }
496
+ if (backlog > 0) {
497
+ return makeCheck(
498
+ 'session-backlog',
499
+ 'warn',
500
+ `${backlog} session${backlog === 1 ? '' : 's'} are still pending, processing, or partial.`,
501
+ {
502
+ counts,
503
+ totalBacklog: backlog,
504
+ },
505
+ 'Let session processing drain or re-run the worker path before trust-sensitive reads.',
506
+ );
507
+ }
508
+ return makeCheck(
509
+ 'session-backlog',
510
+ 'ok',
511
+ 'No session processing backlog.',
512
+ {
513
+ counts,
514
+ totalBacklog: 0,
515
+ },
516
+ null,
517
+ );
518
+ } catch (error) {
519
+ return makeCheck(
520
+ 'session-backlog',
521
+ isTableMissing(error) ? 'warn' : 'fail',
522
+ isTableMissing(error)
523
+ ? 'Session backlog table is not available yet.'
524
+ : `Session backlog check failed: ${error.message}`,
525
+ {
526
+ error: error.message,
527
+ },
528
+ isTableMissing(error)
529
+ ? 'Run `aquifer migrate`, then rerun `aquifer doctor`.'
530
+ : 'Verify the sessions table is readable for the target tenant.',
531
+ );
532
+ }
533
+ }
534
+
535
+ async function checkFinalizationBacklog({ pool, schemaSql, tenantId, backendKind }) {
536
+ if (backendKind !== 'postgres') {
537
+ return makeCheck(
538
+ 'finalization-backlog',
539
+ 'warn',
540
+ 'Finalization backlog inspection is only available on the PostgreSQL backend.',
541
+ {
542
+ backendKind,
543
+ },
544
+ 'Use the PostgreSQL backend to inspect the finalization ledger.',
545
+ );
546
+ }
547
+ try {
548
+ const result = await pool.query(
549
+ `SELECT status, COUNT(*)::int AS count
550
+ FROM ${schemaSql}.session_finalizations
551
+ WHERE tenant_id = $1
552
+ AND status IN ('pending', 'processing', 'failed', 'deferred')
553
+ GROUP BY status`,
554
+ [tenantId],
555
+ );
556
+ const counts = Object.fromEntries(result.rows.map(row => [row.status, toCount(row.count)]));
557
+ const failed = toCount(counts.failed);
558
+ const backlog = sumCounts(counts);
559
+ if (failed > 0) {
560
+ return makeCheck(
561
+ 'finalization-backlog',
562
+ 'fail',
563
+ `${failed} failed finalization row${failed === 1 ? '' : 's'} require attention.`,
564
+ {
565
+ counts,
566
+ totalBacklog: backlog,
567
+ },
568
+ 'Inspect the failed finalization rows before relying on curated continuity.',
569
+ );
570
+ }
571
+ if (backlog > 0) {
572
+ return makeCheck(
573
+ 'finalization-backlog',
574
+ 'warn',
575
+ `${backlog} finalization row${backlog === 1 ? '' : 's'} are still pending, processing, or deferred.`,
576
+ {
577
+ counts,
578
+ totalBacklog: backlog,
579
+ },
580
+ 'Let finalization complete or clear deferred rows before expecting new current-memory output.',
581
+ );
582
+ }
583
+ return makeCheck(
584
+ 'finalization-backlog',
585
+ 'ok',
586
+ 'No finalization backlog.',
587
+ {
588
+ counts,
589
+ totalBacklog: 0,
590
+ },
591
+ null,
592
+ );
593
+ } catch (error) {
594
+ return makeCheck(
595
+ 'finalization-backlog',
596
+ isTableMissing(error) ? 'warn' : 'fail',
597
+ isTableMissing(error)
598
+ ? 'Finalization ledger table is not available yet.'
599
+ : `Finalization backlog check failed: ${error.message}`,
600
+ {
601
+ error: error.message,
602
+ },
603
+ isTableMissing(error)
604
+ ? 'Run `aquifer migrate`, then rerun `aquifer doctor`.'
605
+ : 'Verify the finalization ledger is readable for the target tenant.',
606
+ );
607
+ }
608
+ }
609
+
610
+ async function checkOperatorClaims({ operatorObservability, tenantId, backendKind }) {
611
+ if (backendKind !== 'postgres') {
612
+ return makeCheck(
613
+ 'operator-stale-claims',
614
+ 'warn',
615
+ 'Operator stale-claim inspection is only available on the PostgreSQL backend.',
616
+ {
617
+ backendKind,
618
+ },
619
+ 'Use the PostgreSQL backend to inspect compaction and checkpoint ledgers.',
620
+ );
621
+ }
622
+ if (!operatorObservability || typeof operatorObservability.status !== 'function') {
623
+ return makeCheck(
624
+ 'operator-stale-claims',
625
+ 'warn',
626
+ 'Operator observability is not wired into this doctor integration.',
627
+ {},
628
+ 'Wire operator observability into the doctor runner.',
629
+ );
630
+ }
631
+ try {
632
+ const result = await operatorObservability.status({ tenantId, limit: 5 });
633
+ const staleClaims = Array.isArray(result.compaction && result.compaction.staleClaims)
634
+ ? result.compaction.staleClaims
635
+ : [];
636
+ const checkpointCounts = result.checkpoint && result.checkpoint.statusCounts
637
+ ? result.checkpoint.statusCounts
638
+ : {};
639
+ if (staleClaims.length > 0) {
640
+ return makeCheck(
641
+ 'operator-stale-claims',
642
+ 'warn',
643
+ `${staleClaims.length} stale compaction claim${staleClaims.length === 1 ? '' : 's'} detected.`,
644
+ {
645
+ staleClaims,
646
+ checkpointStatusCounts: checkpointCounts,
647
+ },
648
+ 'Inspect or reclaim stale operator claims before the next apply run.',
649
+ );
650
+ }
651
+ return makeCheck(
652
+ 'operator-stale-claims',
653
+ 'ok',
654
+ 'No stale compaction claims detected.',
655
+ {
656
+ staleClaims: [],
657
+ checkpointStatusCounts: checkpointCounts,
658
+ },
659
+ null,
660
+ );
661
+ } catch (error) {
662
+ return makeCheck(
663
+ 'operator-stale-claims',
664
+ 'fail',
665
+ `Operator stale-claim check failed: ${error.message}`,
666
+ {
667
+ error: error.message,
668
+ },
669
+ 'Verify the operator ledgers are readable for the target tenant.',
670
+ );
671
+ }
672
+ }
673
+
674
+ function checkOpenClawHost({ openclawHome, env = process.env }) {
675
+ const home = path.resolve(openclawHome || env.OPENCLAW_HOME || path.join(os.homedir(), '.openclaw'));
676
+ const configPath = path.join(home, 'openclaw.json');
677
+ const extensionPath = path.join(home, 'extensions', 'aquifer-memory');
678
+ const packagePath = path.join(home, 'node_modules', '@shadowforge0', 'aquifer-memory', 'package.json');
679
+ const config = readJsonSafe(configPath);
680
+ const mcpServer = config && config.mcp && config.mcp.servers ? config.mcp.servers.aquifer : null;
681
+ const pluginEntry = config && config.plugins && config.plugins.entries ? config.plugins.entries['aquifer-memory'] : null;
682
+ const loadPaths = config && config.plugins && config.plugins.load && Array.isArray(config.plugins.load.paths)
683
+ ? config.plugins.load.paths
684
+ : [];
685
+ const details = {
686
+ openclawHome: home,
687
+ homeExists: fileExists(home),
688
+ configPath,
689
+ configExists: fileExists(configPath),
690
+ packagePath,
691
+ packageInstalled: fileExists(packagePath),
692
+ extensionPath,
693
+ extensionExists: fileExists(extensionPath),
694
+ pluginEnabled: pluginEntry ? pluginEntry.enabled !== false : false,
695
+ loadPathPresent: loadPaths.includes(extensionPath),
696
+ mcpConfigured: Boolean(mcpServer),
697
+ mcpCommand: mcpServer ? mcpServer.command || null : null,
698
+ mcpArgs: mcpServer && Array.isArray(mcpServer.args) ? mcpServer.args : [],
699
+ };
700
+
701
+ if (!details.homeExists) {
702
+ return makeCheck(
703
+ 'openclaw-host',
704
+ 'fail',
705
+ `OpenClaw home does not exist: ${home}.`,
706
+ details,
707
+ 'Pass --openclaw-home or set OPENCLAW_HOME to the installed OpenClaw home.',
708
+ );
709
+ }
710
+ if (!details.configExists) {
711
+ return makeCheck(
712
+ 'openclaw-host',
713
+ 'warn',
714
+ 'OpenClaw home exists, but openclaw.json was not found.',
715
+ details,
716
+ 'Run `aquifer install-openclaw --openclaw-home "$OPENCLAW_HOME"` after OpenClaw creates its config.',
717
+ );
718
+ }
719
+
720
+ const missing = [];
721
+ if (!details.packageInstalled) missing.push('package');
722
+ if (!details.extensionExists) missing.push('extension');
723
+ if (!details.pluginEnabled) missing.push('plugin');
724
+ if (!details.loadPathPresent) missing.push('plugin_load_path');
725
+ if (!details.mcpConfigured) missing.push('mcp_server');
726
+
727
+ if (missing.length > 0) {
728
+ return makeCheck(
729
+ 'openclaw-host',
730
+ 'warn',
731
+ `OpenClaw Aquifer wiring is incomplete: ${missing.join(', ')}.`,
732
+ details,
733
+ 'Run `aquifer install-openclaw --openclaw-home "$OPENCLAW_HOME"` and review the dry-run output first if needed.',
734
+ );
735
+ }
736
+
737
+ return makeCheck(
738
+ 'openclaw-host',
739
+ 'ok',
740
+ 'OpenClaw Aquifer MCP and extension wiring are present.',
741
+ details,
742
+ null,
743
+ );
744
+ }
745
+
746
+ function codexHookInstalled(hooksPath) {
747
+ const hooks = readJsonSafe(hooksPath);
748
+ if (!hooks || typeof hooks !== 'object') return false;
749
+ const text = JSON.stringify(hooks);
750
+ return text.includes('checkpoint-heartbeat');
751
+ }
752
+
753
+ function checkCodexHost({ codexHome, hooksPath, env = process.env }) {
754
+ const home = path.resolve(codexHome || env.CODEX_HOME || path.join(os.homedir(), '.codex'));
755
+ const resolvedHooksPath = path.resolve(hooksPath || env.CODEX_HOOKS_PATH || path.join(home, 'hooks.json'));
756
+ const sessionsDir = path.resolve(env.CODEX_SESSIONS_DIR || path.join(home, 'sessions'));
757
+ const stateDir = path.resolve(env.AQUIFER_CODEX_STATE_DIR || path.join(home, 'aquifer-state'));
758
+ const wrapperEnvPresent = Boolean(
759
+ env.CODEX_AQUIFER_AGENT_ID
760
+ || env.CODEX_AQUIFER_SOURCE
761
+ || env.CODEX_AQUIFER_SESSION_KEY
762
+ || env.CODEX_HOME
763
+ || env.CODEX_ENV_PATH
764
+ );
765
+ const details = {
766
+ codexHome: home,
767
+ homeExists: fileExists(home),
768
+ hooksPath: resolvedHooksPath,
769
+ hooksFileExists: fileExists(resolvedHooksPath),
770
+ checkpointHeartbeatHookInstalled: codexHookInstalled(resolvedHooksPath),
771
+ sessionsDir,
772
+ sessionsDirExists: fileExists(sessionsDir),
773
+ stateDir,
774
+ stateDirExists: fileExists(stateDir),
775
+ wrapperEnvPresent,
776
+ };
777
+
778
+ if (!details.homeExists) {
779
+ return makeCheck(
780
+ 'codex-host',
781
+ 'warn',
782
+ `Codex home does not exist or is not discoverable: ${home}.`,
783
+ details,
784
+ 'Set CODEX_HOME or run this check from a Codex environment before relying on hook diagnostics.',
785
+ );
786
+ }
787
+ if (!details.hooksFileExists) {
788
+ return makeCheck(
789
+ 'codex-host',
790
+ 'warn',
791
+ 'Codex hooks file was not found; checkpoint heartbeat hook is not installed.',
792
+ details,
793
+ 'Preview `aquifer codex-recovery checkpoint-heartbeat-hook --scope-key <scope> --json`, then apply it deliberately.',
794
+ );
795
+ }
796
+ if (!details.checkpointHeartbeatHookInstalled) {
797
+ return makeCheck(
798
+ 'codex-host',
799
+ 'warn',
800
+ 'Codex hooks file exists, but no Aquifer checkpoint heartbeat hook was detected.',
801
+ details,
802
+ 'Install the checkpoint heartbeat hook with `aquifer codex-recovery checkpoint-heartbeat-hook --scope-key <scope> --apply`.',
803
+ );
804
+ }
805
+
806
+ return makeCheck(
807
+ 'codex-host',
808
+ 'ok',
809
+ 'Codex checkpoint heartbeat hook is installed.',
810
+ details,
811
+ null,
812
+ );
813
+ }
814
+
815
+ function createDoctor(options = {}) {
816
+ const schemaName = options.schema || 'public';
817
+ const schemaSql = options.recordsSchema || options.schemaSql || sqlSchema(schemaName);
818
+ const packageMeta = readPackageMeta(options);
819
+ const manifestTools = resolveManifestTools(options.mcpManifest || MCP_TOOL_MANIFEST);
820
+ const expectedMcpToolCount = options.expectedMcpToolCount || EXPECTED_MCP_TOOL_COUNT;
821
+ const operatorObservability = options.operatorObservability
822
+ || (options.pool && options.backendKind !== 'local'
823
+ ? createOperatorObservability({
824
+ pool: options.pool,
825
+ schema: schemaSql,
826
+ defaultTenantId: options.defaultTenantId || 'default',
827
+ })
828
+ : null);
829
+
830
+ async function run(input = {}) {
831
+ const backendKind = input.backendKind || options.backendKind || 'postgres';
832
+ const backendProfile = input.backendProfile || options.backendProfile || null;
833
+ const tenantId = input.tenantId || options.defaultTenantId || 'default';
834
+ const dbConfigured = input.dbConfigured !== undefined
835
+ ? Boolean(input.dbConfigured)
836
+ : (options.dbConfigured !== undefined ? Boolean(options.dbConfigured) : Boolean(options.pool));
837
+ const memoryServing = input.memoryServing || options.memoryServing || null;
838
+ const runtimeTools = input.mcpRuntimeTools !== undefined ? input.mcpRuntimeTools : options.mcpRuntimeTools;
839
+ const listPendingMigrations = input.listPendingMigrations || options.listPendingMigrations || null;
840
+ const host = input.host || options.host || null;
841
+ const env = input.env || options.env || process.env;
842
+
843
+ const checks = [];
844
+ checks.push(checkPackage({
845
+ packageMeta,
846
+ nodeVersion: input.nodeVersion || options.nodeVersion || process.version,
847
+ }));
848
+ checks.push(checkBackend({ backendKind, backendProfile }));
849
+ checks.push(await checkDbReadiness({
850
+ pool: options.pool,
851
+ schemaName,
852
+ schemaSql,
853
+ tenantId,
854
+ dbConfigured,
855
+ backendKind,
856
+ }));
857
+ checks.push(await checkMigrations({
858
+ backendKind,
859
+ listPendingMigrations,
860
+ }));
861
+ checks.push(checkServing({ memoryServing }));
862
+ checks.push(checkMcpTools({
863
+ manifestTools,
864
+ runtimeTools,
865
+ expectedMcpToolCount,
866
+ }));
867
+ checks.push(await checkSessionBacklog({
868
+ pool: options.pool,
869
+ schemaSql,
870
+ tenantId,
871
+ backendKind,
872
+ }));
873
+ checks.push(await checkFinalizationBacklog({
874
+ pool: options.pool,
875
+ schemaSql,
876
+ tenantId,
877
+ backendKind,
878
+ }));
879
+ checks.push(await checkOperatorClaims({
880
+ operatorObservability,
881
+ tenantId,
882
+ backendKind,
883
+ }));
884
+ const normalizedHost = host ? String(host).trim().toLowerCase() : null;
885
+ if (normalizedHost === 'openclaw' || input.openclawHome || options.openclawHome) {
886
+ checks.push(checkOpenClawHost({
887
+ openclawHome: input.openclawHome || options.openclawHome || null,
888
+ env,
889
+ }));
890
+ }
891
+ if (normalizedHost === 'codex') {
892
+ checks.push(checkCodexHost({
893
+ codexHome: input.codexHome || options.codexHome || null,
894
+ hooksPath: input.hooksPath || options.hooksPath || null,
895
+ env,
896
+ }));
897
+ }
898
+ if (normalizedHost && !['openclaw', 'codex'].includes(normalizedHost)) {
899
+ checks.push(makeCheck(
900
+ 'host',
901
+ 'warn',
902
+ `No host-scoped doctor checks are defined for "${normalizedHost}".`,
903
+ { host: normalizedHost },
904
+ 'Use --host openclaw or --host codex for host-specific diagnostics.',
905
+ ));
906
+ }
907
+
908
+ const status = overallStatus(checks);
909
+ return {
910
+ ok: status === 'ok',
911
+ status,
912
+ checks,
913
+ };
914
+ }
915
+
916
+ return {
917
+ run,
918
+ };
919
+ }
920
+
921
+ module.exports = {
922
+ EXPECTED_MCP_TOOL_COUNT,
923
+ createDoctor,
924
+ };