1- import { execFile } from 'child_process' ;
2- import fs from 'fs-extra' ;
3- import path from 'path' ;
4- import { promisify } from 'util' ;
5- import { fileURLToPath } from 'url' ;
1+ import { execFile } from 'child_process' ;
2+ import fs from 'fs-extra' ;
3+ import path from 'path' ;
4+ import { promisify } from 'util' ;
5+ import { fileURLToPath } from 'url' ;
66
77// Neo namespace bootstrap (entry-point invariant) for the operator-runnable backup driver.
88// `Neo` + `core/_export` populate
99// `globalThis.Neo`; `InstanceManager` binds Neo.find/findFirst/get aliases +
1010// consumes pre-singleton `Neo.idMap`.
11- import Neo from '../../../src/Neo.mjs' ;
12- import * as core from '../../../src/core/_export.mjs' ;
13- import InstanceManager from '../../../src/manager/Instance.mjs' ;
14- import AiConfig from '../../config.mjs' ;
15- import kbConfig from '../../mcp/server/knowledge-base/config.mjs' ;
16- import mcConfig from '../../mcp/server/memory-core/config.mjs' ;
11+ import Neo from '../../../src/Neo.mjs' ;
12+ import * as core from '../../../src/core/_export.mjs' ;
13+ import InstanceManager from '../../../src/manager/Instance.mjs' ;
14+ import AiConfig from '../../config.mjs' ;
15+ import kbConfig from '../../mcp/server/knowledge-base/config.mjs' ;
16+ import mcConfig from '../../mcp/server/memory-core/config.mjs' ;
1717
1818import {
1919 KB_DatabaseService ,
@@ -157,7 +157,7 @@ export async function runBackup({
157157 sentToCullSourceFile = DEFAULT_SENT_TO_CULL_FILE ,
158158 logger = console
159159} = { } ) {
160- const timestamp = new Date ( ) . toISOString ( ) . replace ( / : / g, '-' ) ;
160+ const timestamp = new Date ( ) . toISOString ( ) . replace ( / : / g, '-' ) ;
161161 const resolvedRoot = bundleRoot ?? path . join ( DEFAULT_BACKUP_ROOT , `backup-${ timestamp } ` ) ;
162162
163163 const layout = {
@@ -214,7 +214,7 @@ export async function runBackup({
214214 await cleanOldBackups ( DEFAULT_BACKUP_ROOT , logger , resolveBackupRetention ( ) ) ;
215215
216216 logger . log ( 'Verifying bundle integrity (row-count parity)...' ) ;
217- const integrity = await verifyBundleIntegrity ( layout , subsystems ) ;
217+ const integrity = await verifyBundleIntegrity ( layout , subsystems ) ;
218218 const failedChecks = integrity . filter ( check => check . status === 'fail' ) ;
219219 if ( failedChecks . length > 0 ) {
220220 throw new Error (
@@ -223,10 +223,23 @@ export async function runBackup({
223223 ) ;
224224 }
225225
226+ const emptyChecks = integrity . filter ( check => check . status === 'empty' ) ;
227+ if ( emptyChecks . length > 0 ) {
228+ // Non-fatal (a fresh environment legitimately backs up empty subsystems), but loud: a zero-row
229+ // export from a normally-populated deployment is the gutted-store signature, and a backup of an
230+ // empty store is a false recovery source. The 'empty' status is carried in bundle-meta.integrity
231+ // for a downstream canary/alert to escalate on.
232+ logger . warn ?. (
233+ `[Backup] ${ emptyChecks . length } subsystem(s) exported ZERO rows — empty backup is not a usable ` +
234+ `recovery source: ${ emptyChecks . map ( c => c . subsystem ) . join ( ', ' ) } . Legitimate for a fresh ` +
235+ `environment; corruption-suspicious for a populated deployment.`
236+ ) ;
237+ }
238+
226239 const completedAt = new Date ( ) . toISOString ( ) ;
227240 const topology = buildTopologyDescriptor ( ) ;
228241 const versionInfo = await buildVersionDescriptor ( PROJECT_ROOT , logger ) ;
229- const meta = {
242+ const meta = {
230243 bundleVersion : 1 ,
231244 timestamp,
232245 completedAt,
@@ -246,15 +259,19 @@ export async function runBackup({
246259 * bundle. For subsystems whose `manageDatabaseBackup({action: 'export'})` SDK call returns a
247260 * numeric count (KB, MC memories+summaries, MC graph), this function counts non-empty lines
248261 * in the bundle's JSONL files and compares — mismatch indicates a partial/torn write that the
249- * caller treats as a fail-the-bundle condition.
262+ * caller treats as a fail-the-bundle condition. Zero-zero parity (source and bundle both empty) is
263+ * reported as `empty`, not `pass`: a backup of an empty/gutted store is surfaced non-fatally because
264+ * it is not a usable recovery source (a fresh environment is legitimately empty; a populated
265+ * deployment reporting zero rows is the gutted-store signature). `runBackup` warns on `empty`
266+ * subsystems and persists the status into `bundle-meta.integrity` for a downstream canary/alert.
250267 *
251268 * For file-copy subsystems (concepts, trajectories, mailbox) the source side has no
252269 * authoritative count to compare against — `copyJsonlSource`'s reported `copied` field
253270 * already covers the file-presence check, so these are skipped with `status: 'skipped'`.
254271 *
255272 * @param {Object } layout The bundle's per-subsystem destination directory map.
256273 * @param {Object } subsystems The runBackup `subsystems` map of SDK return values.
257- * @returns {Promise<Array<{subsystem: String, status: String, sourceCount: Number, bundleCount: Number, reason: String}>> } `status` is `pass` / `fail` / `skipped`; count + reason fields present per status.
274+ * @returns {Promise<Array<{subsystem: String, status: String, sourceCount: Number, bundleCount: Number, reason: String}>> } `status` is `pass` (positive row-count parity) / `empty` (source and bundle both zero — non-fatal, not a usable recovery source) / ` fail` (row-count mismatch) / `skipped` (non-numeric source count) ; count + reason fields present per status.
258275 */
259276export async function verifyBundleIntegrity ( layout , subsystems ) {
260277 const verifiable = [ 'kb' , 'mc' , 'graph' ] ;
@@ -276,16 +293,29 @@ export async function verifyBundleIntegrity(layout, subsystems) {
276293 continue ;
277294 }
278295
279- const files = ( await fs . readdir ( dir ) ) . filter ( f => f . endsWith ( '.jsonl' ) ) ;
280- let bundleCount = 0 ;
296+ const files = ( await fs . readdir ( dir ) ) . filter ( f => f . endsWith ( '.jsonl' ) ) ;
297+ let bundleCount = 0 ;
281298
282299 for ( const file of files ) {
283300 const content = await fs . readFile ( path . join ( dir , file ) , 'utf8' ) ;
284301 bundleCount += content . split ( '\n' ) . filter ( line => line . trim ( ) ) . length ;
285302 }
286303
287304 if ( bundleCount === sourceCount ) {
288- checks . push ( { subsystem, status : 'pass' , sourceCount, bundleCount} ) ;
305+ // Empty-parity is NOT a healthy pass: a backup whose source AND bundle are both empty is
306+ // not a usable recovery source. Legitimate for a fresh environment, but for a normally-
307+ // populated deployment a zero-row export is the gutted-store signature (the corruption a
308+ // backup is supposed to survive) — surface it as 'empty' (visible, non-fatal) rather than
309+ // a silent 'pass' so a downstream canary/alert can act on bundle-meta.integrity.
310+ const status = sourceCount === 0 ? 'empty' : 'pass' ;
311+ const entry = { subsystem, status, sourceCount, bundleCount} ;
312+
313+ if ( status === 'empty' ) {
314+ entry . reason = 'source and bundle both report zero rows — empty backup is not a usable ' +
315+ 'recovery source (fresh-env legitimate; populated-deployment corruption-suspicious)' ;
316+ }
317+
318+ checks . push ( entry ) ;
289319 } else {
290320 checks . push ( {
291321 subsystem,
@@ -314,7 +344,7 @@ export async function verifyBundleIntegrity(layout, subsystems) {
314344function buildTopologyDescriptor ( ) {
315345 return {
316346 shared_topology : true ,
317- kbChromaCoords : {
347+ kbChromaCoords : {
318348 host : kbConfig . host ?? null ,
319349 port : kbConfig . port ?? null ,
320350 path : kbConfig . path ?? null
@@ -396,9 +426,9 @@ export async function cleanOldBackups(backupRoot, logger, retention = {}) {
396426 const tsMatch = entry . name . match ( / ^ b a c k u p - ( .+ ) $ / ) ;
397427 if ( ! tsMatch ) continue ;
398428
399- const rawTs = tsMatch [ 1 ] ;
429+ const rawTs = tsMatch [ 1 ] ;
400430 const isoTime = rawTs . replace ( / T ( \d { 2 } ) - ( \d { 2 } ) - ( \d { 2 } ) / , 'T$1:$2:$3' ) ;
401- const date = new Date ( isoTime ) ;
431+ const date = new Date ( isoTime ) ;
402432
403433 if ( ! isNaN ( date . getTime ( ) ) ) {
404434 backups . push ( {
@@ -412,16 +442,16 @@ export async function cleanOldBackups(backupRoot, logger, retention = {}) {
412442
413443 backups . sort ( ( a , b ) => b . time - a . time ) ;
414444
415- const K = keepMinimum ;
416- const N_DAYS = maxDays ;
417- const now = Date . now ( ) ;
445+ const K = keepMinimum ;
446+ const N_DAYS = maxDays ;
447+ const now = Date . now ( ) ;
418448 const thresholdMs = N_DAYS * 24 * 60 * 60 * 1000 ;
419449
420450 let deletedCount = 0 ;
421451
422452 for ( let i = K ; i < backups . length ; i ++ ) {
423453 const backup = backups [ i ] ;
424- const ageMs = now - backup . time ;
454+ const ageMs = now - backup . time ;
425455 if ( ageMs > thresholdMs ) {
426456 try {
427457 logger . log ( `[Retention] Deleting old backup: ${ backup . name } (age: ${ Math . round ( ageMs / 86400000 ) } days)` ) ;
0 commit comments