1212 * **Usage**:
1313 * node ai/scripts/migrations/migrateWakeSubscriptions.mjs # dry-run (default)
1414 * node ai/scripts/migrations/migrateWakeSubscriptions.mjs --apply # commit the migration
15+ * node ai/scripts/migrations/migrateWakeSubscriptions.mjs --skip-generic-cleanup
1516 * node ai/scripts/migrations/migrateWakeSubscriptions.mjs --db <path> # override SQLite path
1617 * node ai/scripts/migrations/migrateWakeSubscriptions.mjs --help # print usage
1718 */
@@ -30,6 +31,7 @@ program
3031 . description ( 'Patch legacy bridge-daemon wake subscriptions onto identity-template route metadata (dry-run by default).' )
3132 . option ( '--apply' , 'Commit the migration atomically in a single transaction' )
3233 . option ( '--audit' , 'Read-only audit — report instance-addressing-unsafe wake routes (no changes)' )
34+ . option ( '--skip-generic-cleanup' , 'Skip unresolved generic named-peer default-instance cleanup planning/apply' )
3335 . option ( '--db <path>' , 'Override the SQLite file path (default: .neo-ai-data/sqlite/memory-core-graph.sqlite)' )
3436 . addHelpText ( 'after' , '\n (no flags) Dry-run — print the migration plan without committing.' ) ;
3537
@@ -50,10 +52,17 @@ function resolveSubscriptionTemplate(agentId, agentData) {
5052 return sourceTemplate || agentData . properties ?. subscriptionTemplate ;
5153}
5254
53- export function runMigration ( db , apply ) {
55+ export function runMigration ( db , applyOrOptions = false ) {
56+ const options = typeof applyOrOptions === 'boolean' ? { apply : applyOrOptions } : applyOrOptions ,
57+ apply = options . apply === true ,
58+ now = options . now || new Date ( ) . toISOString ( ) ;
59+
5460 const stats = {
55- subscriptionsPatched : 0 ,
56- subscriptionsSkipped : 0
61+ subscriptionsPatched : 0 ,
62+ subscriptionsSkipped : 0 ,
63+ genericDefaultMarked : 0 ,
64+ genericDuplicatesRetired : 0 ,
65+ genericNamedPeerUnresolved : 0
5766 } ;
5867
5968 const work = ( ) => {
@@ -135,6 +144,13 @@ export function runMigration(db, apply) {
135144 stats . subscriptionsSkipped ++ ;
136145 }
137146 }
147+
148+ if ( options . cleanupGenericNamedPeer !== false ) {
149+ const cleanup = cleanupGenericNamedPeerRoutes ( db , { apply, now} ) ;
150+ stats . genericDefaultMarked += cleanup . defaultMarked ;
151+ stats . genericDuplicatesRetired += cleanup . duplicatesRetired ;
152+ stats . genericNamedPeerUnresolved += cleanup . unresolved ;
153+ }
138154 } ;
139155
140156 if ( apply ) {
@@ -147,6 +163,163 @@ export function runMigration(db, apply) {
147163 return stats ;
148164}
149165
166+ /**
167+ * @summary Cleans active app-only named-peer wake routes without writing operator-local paths.
168+ *
169+ * A generic app-only Shape C route can be valid only as the default app instance. This maintenance
170+ * pass makes that intent durable (`defaultInstance: true`) and retires duplicate active rows that
171+ * share the same owner/trigger/filter/app tuple. Instance-addressed rows remain untouched.
172+ *
173+ * @param {Object } db Open better-sqlite3 connection.
174+ * @param {Object } [options]
175+ * @param {Boolean } [options.apply=false] Whether to commit planned mutations.
176+ * @param {String } [options.now] ISO timestamp used for durable mutation metadata.
177+ * @returns {{defaultMarked: Number, duplicatesRetired: Number, unresolved: Number} }
178+ */
179+ export function cleanupGenericNamedPeerRoutes ( db , { apply = false , now = new Date ( ) . toISOString ( ) } = { } ) {
180+ const groups = new Map ( ) ;
181+
182+ for ( const record of readBridgeSubscriptionRecords ( db ) ) {
183+ const props = record . data . properties || { } ;
184+
185+ if ( ! isActiveSubscription ( props ) || ! isUnresolvedGenericNamedPeer ( record ) ) continue ;
186+
187+ const key = buildGenericNamedPeerCleanupKey ( record ) ;
188+ if ( ! groups . has ( key ) ) groups . set ( key , [ ] ) ;
189+ groups . get ( key ) . push ( record ) ;
190+ }
191+
192+ const stats = { defaultMarked : 0 , duplicatesRetired : 0 , unresolved : 0 } ;
193+
194+ for ( const group of groups . values ( ) ) {
195+ const ordered = group . sort ( compareNewestSubscriptionFirst ) ,
196+ keeper = ordered [ 0 ] ,
197+ retired = ordered . slice ( 1 ) ;
198+
199+ markDefaultInstance ( keeper , { apply, db, now} ) ;
200+ stats . defaultMarked ++ ;
201+
202+ for ( const record of retired ) {
203+ retireDuplicateGenericRoute ( record , { apply, db, now} ) ;
204+ stats . duplicatesRetired ++ ;
205+ }
206+ }
207+
208+ stats . unresolved = auditWakeRoutes ( db ) . genericNamedPeer . length ;
209+
210+ return stats ;
211+ }
212+
213+ /**
214+ * @summary Reads parsed bridge-daemon WAKE_SUBSCRIPTION rows from the graph.
215+ * @param {Object } db Open better-sqlite3 connection.
216+ * @returns {Object[] } Parsed subscription records.
217+ */
218+ function readBridgeSubscriptionRecords ( db ) {
219+ const rows = db . prepare ( `SELECT id, data FROM Nodes WHERE json_extract(data, '$.label') = ?` ) . all ( 'WAKE_SUBSCRIPTION' ) ,
220+ out = [ ] ;
221+
222+ for ( const row of rows ) {
223+ let data ;
224+ try {
225+ data = JSON . parse ( row . data ) ;
226+ } catch ( e ) {
227+ continue ;
228+ }
229+
230+ const props = data . properties || { } ;
231+ if ( props . harnessTarget !== 'bridge-daemon' ) continue ;
232+
233+ out . push ( { id : row . id , data} ) ;
234+ }
235+
236+ return out ;
237+ }
238+
239+ function isActiveSubscription ( props = { } ) {
240+ return ( props . status || 'active' ) === 'active' ;
241+ }
242+
243+ function isDefaultInstanceRoute ( meta = { } ) {
244+ return meta . defaultInstance === true || meta . routeResolution === 'default-instance' ;
245+ }
246+
247+ function isUnresolvedGenericNamedPeer ( record ) {
248+ const namedIds = new Set ( IDENTITIES . map ( identity => identity . id ) ) ,
249+ props = record . data . properties || { } ,
250+ meta = props . harnessTargetMetadata || { } ,
251+ { addressType} = resolveRouteAddress ( meta ) ;
252+
253+ return ! addressType && meta . appName && namedIds . has ( props . agentIdentity ) && ! isDefaultInstanceRoute ( meta ) ;
254+ }
255+
256+ function buildGenericNamedPeerCleanupKey ( record ) {
257+ const props = record . data . properties || { } ,
258+ meta = props . harnessTargetMetadata || { } ;
259+
260+ return stableStringify ( {
261+ agentIdentity : props . agentIdentity ,
262+ trigger : props . trigger ,
263+ filters : props . filters || { } ,
264+ appName : meta . appName
265+ } ) ;
266+ }
267+
268+ function compareNewestSubscriptionFirst ( a , b ) {
269+ const timeDelta = readSubscriptionTime ( b ) - readSubscriptionTime ( a ) ;
270+ if ( timeDelta !== 0 ) return timeDelta ;
271+
272+ return a . id . localeCompare ( b . id ) ;
273+ }
274+
275+ function readSubscriptionTime ( record ) {
276+ const props = record . data . properties || { } ,
277+ time = Date . parse ( props . updatedAt || props . createdAt || '' ) ;
278+
279+ return Number . isFinite ( time ) ? time : 0 ;
280+ }
281+
282+ function markDefaultInstance ( record , { apply, db, now} ) {
283+ const props = record . data . properties || { } ,
284+ meta = props . harnessTargetMetadata || { } ;
285+
286+ console . log ( ` [MARK-DEFAULT] Subscription ${ record . id } (Owner: ${ props . agentIdentity } ) | defaultInstance: ${ meta . defaultInstance === true ? 'true' : 'none' } → true, routeResolution: ${ meta . routeResolution || 'none' } → default-instance` ) ;
287+
288+ if ( ! apply ) return ;
289+
290+ props . harnessTargetMetadata = {
291+ ...meta ,
292+ defaultInstance : true ,
293+ routeResolution : 'default-instance' ,
294+ routeResolvedAt : now ,
295+ routeResolvedBy : 'migrateWakeSubscriptions#genericNamedPeer'
296+ } ;
297+ props . updatedAt = now ;
298+ record . data . properties = props ;
299+
300+ writeSubscriptionRecord ( db , record ) ;
301+ }
302+
303+ function retireDuplicateGenericRoute ( record , { apply, db, now} ) {
304+ const props = record . data . properties || { } ;
305+
306+ console . log ( ` [RETIRE-GENERIC] Subscription ${ record . id } (Owner: ${ props . agentIdentity } ) | status: ${ props . status || 'active' } → inactive duplicate generic default-instance route` ) ;
307+
308+ if ( ! apply ) return ;
309+
310+ props . status = 'inactive' ;
311+ props . retiredAt = now ;
312+ props . retiredReason = 'duplicate generic named-peer default-instance route (#13744)' ;
313+ props . updatedAt = now ;
314+ record . data . properties = props ;
315+
316+ writeSubscriptionRecord ( db , record ) ;
317+ }
318+
319+ function writeSubscriptionRecord ( db , record ) {
320+ db . prepare ( 'UPDATE Nodes SET data = ? WHERE id = ?' ) . run ( JSON . stringify ( record . data ) , record . id ) ;
321+ }
322+
150323/**
151324 * @summary Resolve a wake route's effective instance address, mirroring
152325 * `WakeSubscriptionService._resolvePresenceAddress` / the wake daemon's resolver: an explicit
@@ -177,47 +350,43 @@ export function resolveRouteAddress(meta = {}) {
177350 * which blocks NEW unsafe rows; this audit surfaces pre-existing ones for cleanup.
178351 *
179352 * @param {Object } db Open better-sqlite3 connection.
180- * @returns {{emptyAddress: Object[], genericNamedPeer: Object[], scanned: Number} }
353+ * @returns {{emptyAddress: Object[], genericNamedPeer: Object[], defaultInstance: Object[], scanned: Number} }
181354 */
182355export function auditWakeRoutes ( db ) {
183356 const namedIds = new Set ( IDENTITIES . map ( identity => identity . id ) ) ,
184- subs = db . prepare ( `SELECT id, data FROM Nodes WHERE json_extract(data, '$.label') = ?` ) . all ( 'WAKE_SUBSCRIPTION' ) ,
357+ subs = readBridgeSubscriptionRecords ( db ) ,
185358 emptyAddress = [ ] ,
186- genericNamedPeer = [ ] ;
359+ genericNamedPeer = [ ] ,
360+ defaultInstance = [ ] ;
187361
188362 let scanned = 0 ;
189363
190364 for ( const sub of subs ) {
191- let data ;
192- try {
193- data = JSON . parse ( sub . data ) ;
194- } catch ( e ) {
195- continue ;
196- }
197-
198- const props = data . properties || { } ;
199- if ( props . harnessTarget !== 'bridge-daemon' ) continue ;
365+ const props = sub . data . properties || { } ;
366+ if ( ! isActiveSubscription ( props ) ) continue ;
200367 scanned ++ ;
201368
202369 const meta = props . harnessTargetMetadata || { } ,
203370 { addressType, instanceAddress} = resolveRouteAddress ( meta ) ;
204371
205372 if ( addressType && ! instanceAddress ) {
206373 emptyAddress . push ( { id : sub . id , agentIdentity : props . agentIdentity || null , addressType, appName : meta . appName || null } ) ;
374+ } else if ( ! addressType && meta . appName && namedIds . has ( props . agentIdentity ) && isDefaultInstanceRoute ( meta ) ) {
375+ defaultInstance . push ( { id : sub . id , agentIdentity : props . agentIdentity , appName : meta . appName } ) ;
207376 } else if ( ! addressType && meta . appName && namedIds . has ( props . agentIdentity ) ) {
208377 genericNamedPeer . push ( { id : sub . id , agentIdentity : props . agentIdentity , appName : meta . appName } ) ;
209378 }
210379 }
211380
212- return { emptyAddress, genericNamedPeer, scanned} ;
381+ return { emptyAddress, genericNamedPeer, defaultInstance , scanned} ;
213382}
214383
215384/**
216385 * @summary Print an {@link auditWakeRoutes} result to stdout.
217- * @param {{emptyAddress: Object[], genericNamedPeer: Object[], scanned: Number} } audit
386+ * @param {{emptyAddress: Object[], genericNamedPeer: Object[], defaultInstance: Object[], scanned: Number} } audit
218387 * @returns {void }
219388 */
220- function reportAudit ( { emptyAddress, genericNamedPeer, scanned} ) {
389+ function reportAudit ( { emptyAddress, genericNamedPeer, defaultInstance = [ ] , scanned} ) {
221390 console . log ( `[migrateWakeSubscriptions] AUDIT — scanned ${ scanned } bridge-daemon wake route(s)` ) ;
222391 console . log ( ) ;
223392 console . log ( ` empty-address (addressType set, no resolvable instance address → fails closed at delivery, peer misses wakes): ${ emptyAddress . length } ` ) ;
@@ -230,9 +399,33 @@ function reportAudit({emptyAddress, genericNamedPeer, scanned}) {
230399 console . log ( ` [GENERIC] ${ r . id } owner=${ r . agentIdentity } app=${ r . appName } ` ) ;
231400 }
232401 console . log ( ) ;
402+ console . log ( ` default-instance (named identity on an appName-only route explicitly marked as intentional default instance): ${ defaultInstance . length } ` ) ;
403+ for ( const r of defaultInstance ) {
404+ console . log ( ` [DEFAULT] ${ r . id } owner=${ r . agentIdentity } app=${ r . appName } ` ) ;
405+ }
406+ console . log ( ) ;
233407 console . log ( `[migrateWakeSubscriptions] AUDIT complete (read-only; no changes).` ) ;
234408}
235409
410+ function stableStringify ( value ) {
411+ return JSON . stringify ( stableNormalize ( value ) ) ;
412+ }
413+
414+ function stableNormalize ( value ) {
415+ if ( Array . isArray ( value ) ) {
416+ return value . map ( stableNormalize ) ;
417+ }
418+
419+ if ( value && typeof value === 'object' ) {
420+ return Object . keys ( value ) . sort ( ) . reduce ( ( out , key ) => {
421+ out [ key ] = stableNormalize ( value [ key ] ) ;
422+ return out ;
423+ } , { } ) ;
424+ }
425+
426+ return value ;
427+ }
428+
236429async function main ( ) {
237430 program . parse ( process . argv ) ;
238431 const args = program . opts ( ) ;
@@ -251,12 +444,18 @@ async function main() {
251444 return ;
252445 }
253446
254- const stats = runMigration ( db , args . apply ) ;
447+ const stats = runMigration ( db , {
448+ apply : args . apply ,
449+ cleanupGenericNamedPeer : ! args . skipGenericCleanup
450+ } ) ;
255451
256452 console . log ( ) ;
257453 console . log ( `[migrateWakeSubscriptions] summary:` ) ;
258- console . log ( ` subscriptions patched: ${ stats . subscriptionsPatched } ` ) ;
259- console . log ( ` subscriptions skipped: ${ stats . subscriptionsSkipped } ` ) ;
454+ console . log ( ` subscriptions patched : ${ stats . subscriptionsPatched } ` ) ;
455+ console . log ( ` subscriptions skipped : ${ stats . subscriptionsSkipped } ` ) ;
456+ console . log ( ` generic default marked : ${ stats . genericDefaultMarked } ` ) ;
457+ console . log ( ` generic duplicates retired : ${ stats . genericDuplicatesRetired } ` ) ;
458+ console . log ( ` generic unresolved remaining: ${ stats . genericNamedPeerUnresolved } ` ) ;
260459
261460 if ( ! args . apply ) {
262461 console . log ( ) ;
0 commit comments