Skip to content

Commit afe0b56

Browse files
authored
feat(ai): clean generic named-peer wake routes (#13744) (#13789)
1 parent 53e2b91 commit afe0b56

2 files changed

Lines changed: 396 additions & 92 deletions

File tree

ai/scripts/migrations/migrateWakeSubscriptions.mjs

Lines changed: 220 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
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
*/
182355
export 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+
236429
async 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

Comments
 (0)