From fd74dfcbf25e6b347e1c4d07b599832c9d625a60 Mon Sep 17 00:00:00 2001 From: xu75 <92104817+xu75@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:00:46 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(limb):=20add=20SqliteLimbPersistence?= =?UTF-8?q?=20=E2=80=94=20schema=20+=20CRUD=20for=20pairings=20and=20acces?= =?UTF-8?q?s=20policies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the SQLite persistence layer for #331. Two tables: limb_pairings and limb_access_policies, with WAL mode and versioned migrations following the SqliteEvidenceStore pattern. [宪宪/Opus-46🐾] Co-Authored-By: Claude Opus 4.6 --- .../src/domains/limb/SqliteLimbPersistence.ts | 146 ++++++++++++++++++ .../api/test/sqlite-limb-persistence.test.js | 96 ++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 packages/api/src/domains/limb/SqliteLimbPersistence.ts create mode 100644 packages/api/test/sqlite-limb-persistence.test.js diff --git a/packages/api/src/domains/limb/SqliteLimbPersistence.ts b/packages/api/src/domains/limb/SqliteLimbPersistence.ts new file mode 100644 index 000000000..b569d6f90 --- /dev/null +++ b/packages/api/src/domains/limb/SqliteLimbPersistence.ts @@ -0,0 +1,146 @@ +/** + * SqliteLimbPersistence — #331 Limb state persistence + * + * Write-through SQLite backing for LimbPairingStore and LimbAccessPolicy. + * Follows SqliteEvidenceStore pattern: lazy init, WAL mode, versioned migrations. + */ + +import Database from 'better-sqlite3'; +import type { LimbAccessEntry } from '@cat-cafe/shared'; +import type { PairingRequest } from './LimbPairingStore.js'; + +const SCHEMA_V1 = ` +CREATE TABLE IF NOT EXISTS limb_schema_version ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS limb_pairings ( + requestId TEXT PRIMARY KEY, + nodeId TEXT NOT NULL, + displayName TEXT NOT NULL, + platform TEXT NOT NULL, + endpointUrl TEXT NOT NULL, + capabilities TEXT NOT NULL, + status TEXT NOT NULL, + createdAt INTEGER NOT NULL, + decidedAt INTEGER, + apiKey TEXT NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS limb_access_policies ( + catId TEXT NOT NULL, + nodeId TEXT NOT NULL, + capability TEXT NOT NULL, + authLevel TEXT NOT NULL, + PRIMARY KEY (catId, nodeId, capability) +); +`; + +export class SqliteLimbPersistence { + private db: Database.Database | null = null; + private readonly dbPath: string; + + constructor(dbPath: string) { + this.dbPath = dbPath; + } + + initialize(): void { + this.db = new Database(this.dbPath); + this.db.pragma('journal_mode = WAL'); + this.db.pragma('foreign_keys = ON'); + this.db.pragma('busy_timeout = 5000'); + this.applyMigrations(); + } + + private applyMigrations(): void { + const db = this.getDb(); + const version = + (() => { + try { + return (db.prepare('SELECT MAX(version) as v FROM limb_schema_version').get() as { v: number | null })?.v ?? 0; + } catch { + return 0; + } + })(); + + if (version < 1) { + db.exec(SCHEMA_V1); + db.prepare('INSERT INTO limb_schema_version (version, applied_at) VALUES (?, ?)').run(1, new Date().toISOString()); + } + } + + private getDb(): Database.Database { + if (!this.db) throw new Error('SqliteLimbPersistence not initialized — call initialize() first'); + return this.db; + } + + upsertPairing(p: PairingRequest): void { + this.getDb() + .prepare( + `INSERT INTO limb_pairings (requestId, nodeId, displayName, platform, endpointUrl, capabilities, status, createdAt, decidedAt, apiKey) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(requestId) DO UPDATE SET + nodeId=excluded.nodeId, displayName=excluded.displayName, platform=excluded.platform, + endpointUrl=excluded.endpointUrl, capabilities=excluded.capabilities, status=excluded.status, + decidedAt=excluded.decidedAt, apiKey=excluded.apiKey`, + ) + .run( + p.requestId, + p.nodeId, + p.displayName, + p.platform, + p.endpointUrl, + JSON.stringify(p.capabilities), + p.status, + p.createdAt, + p.decidedAt ?? null, + p.apiKey, + ); + } + + deletePairing(requestId: string): void { + this.getDb().prepare('DELETE FROM limb_pairings WHERE requestId = ?').run(requestId); + } + + loadPairings(): PairingRequest[] { + const rows = this.getDb().prepare('SELECT * FROM limb_pairings').all() as Array>; + return rows.map((r) => ({ + requestId: r.requestId as string, + nodeId: r.nodeId as string, + displayName: r.displayName as string, + platform: r.platform as string, + endpointUrl: r.endpointUrl as string, + capabilities: JSON.parse(r.capabilities as string), + status: r.status as PairingRequest['status'], + createdAt: r.createdAt as number, + decidedAt: (r.decidedAt as number | null) ?? undefined, + apiKey: r.apiKey as string, + })); + } + + upsertAccessPolicy(e: LimbAccessEntry): void { + this.getDb() + .prepare( + `INSERT INTO limb_access_policies (catId, nodeId, capability, authLevel) + VALUES (?, ?, ?, ?) + ON CONFLICT(catId, nodeId, capability) DO UPDATE SET authLevel=excluded.authLevel`, + ) + .run(e.catId, e.nodeId, e.capability, e.authLevel); + } + + loadAccessPolicies(): LimbAccessEntry[] { + const rows = this.getDb().prepare('SELECT * FROM limb_access_policies').all() as Array>; + return rows.map((r) => ({ + catId: r.catId as string, + nodeId: r.nodeId as string, + capability: r.capability as string, + authLevel: r.authLevel as LimbAccessEntry['authLevel'], + })); + } + + close(): void { + this.db?.close(); + this.db = null; + } +} diff --git a/packages/api/test/sqlite-limb-persistence.test.js b/packages/api/test/sqlite-limb-persistence.test.js new file mode 100644 index 000000000..f4bb3ff40 --- /dev/null +++ b/packages/api/test/sqlite-limb-persistence.test.js @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, it } from 'node:test'; + +const { SqliteLimbPersistence } = await import('../dist/domains/limb/SqliteLimbPersistence.js'); + +describe('SqliteLimbPersistence', () => { + let tempDir; + let dbPath; + let persistence; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'limb-persist-')); + dbPath = join(tempDir, 'limb.sqlite'); + persistence = new SqliteLimbPersistence(dbPath); + persistence.initialize(); + }); + + afterEach(() => { + persistence.close(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('pairing CRUD', () => { + const samplePairing = { + requestId: 'req-1', + nodeId: 'node-1', + displayName: 'Test Node', + platform: 'linux', + endpointUrl: 'http://localhost:9090', + capabilities: [{ cap: 'shell', commands: ['exec'], authLevel: 'free' }], + status: 'pending', + createdAt: Date.now(), + apiKey: 'key-abc-123', + }; + + it('upsert + load round-trips a pairing', () => { + persistence.upsertPairing(samplePairing); + const loaded = persistence.loadPairings(); + assert.equal(loaded.length, 1); + assert.equal(loaded[0].requestId, 'req-1'); + assert.equal(loaded[0].nodeId, 'node-1'); + assert.equal(loaded[0].apiKey, 'key-abc-123'); + assert.deepStrictEqual(loaded[0].capabilities, samplePairing.capabilities); + }); + + it('upsert updates existing pairing on conflict', () => { + persistence.upsertPairing(samplePairing); + persistence.upsertPairing({ ...samplePairing, status: 'approved', decidedAt: Date.now() }); + const loaded = persistence.loadPairings(); + assert.equal(loaded.length, 1); + assert.equal(loaded[0].status, 'approved'); + assert.ok(loaded[0].decidedAt); + }); + + it('delete removes a pairing', () => { + persistence.upsertPairing(samplePairing); + persistence.deletePairing('req-1'); + assert.equal(persistence.loadPairings().length, 0); + }); + + it('survives close + reopen', () => { + persistence.upsertPairing(samplePairing); + persistence.close(); + + const p2 = new SqliteLimbPersistence(dbPath); + p2.initialize(); + const loaded = p2.loadPairings(); + assert.equal(loaded.length, 1); + assert.equal(loaded[0].requestId, 'req-1'); + p2.close(); + }); + }); + + describe('access policy CRUD', () => { + const samplePolicy = { catId: 'cat-1', nodeId: 'node-1', capability: 'shell', authLevel: 'leased' }; + + it('upsert + load round-trips an access policy', () => { + persistence.upsertAccessPolicy(samplePolicy); + const loaded = persistence.loadAccessPolicies(); + assert.equal(loaded.length, 1); + assert.equal(loaded[0].catId, 'cat-1'); + assert.equal(loaded[0].authLevel, 'leased'); + }); + + it('upsert updates on composite key conflict', () => { + persistence.upsertAccessPolicy(samplePolicy); + persistence.upsertAccessPolicy({ ...samplePolicy, authLevel: 'gated' }); + const loaded = persistence.loadAccessPolicies(); + assert.equal(loaded.length, 1); + assert.equal(loaded[0].authLevel, 'gated'); + }); + }); +}); From 690150f3788fda22bc680840bf33bb5114353859 Mon Sep 17 00:00:00 2001 From: xu75 <92104817+xu75@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:02:39 +0800 Subject: [PATCH 2/4] feat(limb): wire SQLite persistence into PairingStore and AccessPolicy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Write-through to SqliteLimbPersistence on every mutation. Optional constructor param — no persistence = pure in-memory (backward compat). initialize() loads persisted state into Map on startup. [宪宪/Opus-46🐾] Co-Authored-By: Claude Opus 4.6 --- .../api/src/domains/limb/LimbAccessPolicy.ts | 15 ++++ .../api/src/domains/limb/LimbPairingStore.ts | 17 ++++ .../limb-access-policy-persistence.test.js | 41 ++++++++++ .../limb-pairing-store-persistence.test.js | 78 +++++++++++++++++++ 4 files changed, 151 insertions(+) create mode 100644 packages/api/test/limb-access-policy-persistence.test.js create mode 100644 packages/api/test/limb-pairing-store-persistence.test.js diff --git a/packages/api/src/domains/limb/LimbAccessPolicy.ts b/packages/api/src/domains/limb/LimbAccessPolicy.ts index 6a4c143c4..e1b8a6558 100644 --- a/packages/api/src/domains/limb/LimbAccessPolicy.ts +++ b/packages/api/src/domains/limb/LimbAccessPolicy.ts @@ -6,9 +6,23 @@ */ import type { LimbAccessEntry, LimbAuthLevel, LimbCapability } from '@cat-cafe/shared'; +import type { SqliteLimbPersistence } from './SqliteLimbPersistence.js'; export class LimbAccessPolicy { private readonly policies = new Map(); + private readonly persistence?: SqliteLimbPersistence; + + constructor(persistence?: SqliteLimbPersistence) { + this.persistence = persistence; + } + + /** Load persisted policies into memory (call after persistence.initialize()) */ + initialize(): void { + if (!this.persistence) return; + for (const e of this.persistence.loadAccessPolicies()) { + this.policies.set(LimbAccessPolicy.key(e.catId, e.nodeId, e.capability), e); + } + } private static key(catId: string, nodeId: string, capability: string): string { return `${catId}:${nodeId}:${capability}`; @@ -17,6 +31,7 @@ export class LimbAccessPolicy { /** 设置权限条目(覆盖已有) */ setPolicy(entry: LimbAccessEntry): void { this.policies.set(LimbAccessPolicy.key(entry.catId, entry.nodeId, entry.capability), entry); + this.persistence?.upsertAccessPolicy(entry); } /** 检查显式策略(未配置返回 null) */ diff --git a/packages/api/src/domains/limb/LimbPairingStore.ts b/packages/api/src/domains/limb/LimbPairingStore.ts index 5b8f28aff..ece78f025 100644 --- a/packages/api/src/domains/limb/LimbPairingStore.ts +++ b/packages/api/src/domains/limb/LimbPairingStore.ts @@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto'; import type { LimbCapability } from '@cat-cafe/shared'; +import type { SqliteLimbPersistence } from './SqliteLimbPersistence.js'; export interface PairingRequest { requestId: string; @@ -32,6 +33,19 @@ export interface CreatePairingParams { export class LimbPairingStore { private readonly requests = new Map(); + private readonly persistence?: SqliteLimbPersistence; + + constructor(persistence?: SqliteLimbPersistence) { + this.persistence = persistence; + } + + /** Load persisted pairings into memory (call after persistence.initialize()) */ + initialize(): void { + if (!this.persistence) return; + for (const p of this.persistence.loadPairings()) { + this.requests.set(p.requestId, p); + } + } createRequest(params: CreatePairingParams): PairingRequest { // Check for duplicate nodeId in pending/approved @@ -50,6 +64,7 @@ export class LimbPairingStore { }; this.requests.set(request.requestId, request); + this.persistence?.upsertPairing(request); return request; } @@ -59,6 +74,7 @@ export class LimbPairingStore { if (req.status === 'approved') return req; // Idempotent req.status = 'approved'; req.decidedAt = Date.now(); + this.persistence?.upsertPairing(req); return req; } @@ -67,6 +83,7 @@ export class LimbPairingStore { if (!req) return false; req.status = 'rejected'; req.decidedAt = Date.now(); + this.persistence?.upsertPairing(req); return true; } diff --git a/packages/api/test/limb-access-policy-persistence.test.js b/packages/api/test/limb-access-policy-persistence.test.js new file mode 100644 index 000000000..37531c951 --- /dev/null +++ b/packages/api/test/limb-access-policy-persistence.test.js @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, it } from 'node:test'; + +const { SqliteLimbPersistence } = await import('../dist/domains/limb/SqliteLimbPersistence.js'); +const { LimbAccessPolicy } = await import('../dist/domains/limb/LimbAccessPolicy.js'); + +describe('LimbAccessPolicy with persistence', () => { + let tempDir; + let dbPath; + let persistence; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'limb-policy-')); + dbPath = join(tempDir, 'limb.sqlite'); + persistence = new SqliteLimbPersistence(dbPath); + persistence.initialize(); + }); + + afterEach(() => { + persistence.close(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('round-trips access policy across instances', () => { + const policy1 = new LimbAccessPolicy(persistence); + policy1.setPolicy({ catId: 'cat-1', nodeId: 'node-1', capability: 'shell', authLevel: 'leased' }); + + const policy2 = new LimbAccessPolicy(persistence); + policy2.initialize(); + assert.equal(policy2.check('cat-1', 'node-1', 'shell'), 'leased'); + }); + + it('works without persistence (backward compat)', () => { + const policy = new LimbAccessPolicy(); + policy.setPolicy({ catId: 'cat-2', nodeId: 'node-2', capability: 'fs', authLevel: 'gated' }); + assert.equal(policy.check('cat-2', 'node-2', 'fs'), 'gated'); + }); +}); diff --git a/packages/api/test/limb-pairing-store-persistence.test.js b/packages/api/test/limb-pairing-store-persistence.test.js new file mode 100644 index 000000000..5e4bdfb80 --- /dev/null +++ b/packages/api/test/limb-pairing-store-persistence.test.js @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, it } from 'node:test'; + +const { SqliteLimbPersistence } = await import('../dist/domains/limb/SqliteLimbPersistence.js'); +const { LimbPairingStore } = await import('../dist/domains/limb/LimbPairingStore.js'); + +describe('LimbPairingStore with persistence', () => { + let tempDir; + let dbPath; + let persistence; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'limb-pairing-')); + dbPath = join(tempDir, 'limb.sqlite'); + persistence = new SqliteLimbPersistence(dbPath); + persistence.initialize(); + }); + + afterEach(() => { + persistence.close(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('round-trips approved pairing across store instances', () => { + const store1 = new LimbPairingStore(persistence); + const req = store1.createRequest({ + nodeId: 'n1', + displayName: 'Node 1', + platform: 'linux', + endpointUrl: 'http://localhost:8080', + capabilities: [{ cap: 'shell', commands: ['exec'], authLevel: 'free' }], + }); + store1.approve(req.requestId); + + // New store from same DB + const store2 = new LimbPairingStore(persistence); + store2.initialize(); + const approved = store2.getApproved(); + assert.equal(approved.length, 1); + assert.equal(approved[0].nodeId, 'n1'); + assert.equal(approved[0].status, 'approved'); + }); + + it('works without persistence (backward compat)', () => { + const store = new LimbPairingStore(); + const req = store.createRequest({ + nodeId: 'n2', + displayName: 'Node 2', + platform: 'darwin', + endpointUrl: 'http://localhost:9090', + capabilities: [], + }); + assert.equal(store.getPending().length, 1); + store.approve(req.requestId); + assert.equal(store.getApproved().length, 1); + }); + + it('rejected pairings also persist', () => { + const store1 = new LimbPairingStore(persistence); + const req = store1.createRequest({ + nodeId: 'n3', + displayName: 'Node 3', + platform: 'linux', + endpointUrl: 'http://localhost:7070', + capabilities: [], + }); + store1.reject(req.requestId); + + const store2 = new LimbPairingStore(persistence); + store2.initialize(); + assert.equal(store2.getPending().length, 0); + assert.equal(store2.getApproved().length, 0); + assert.equal(store2.get(req.requestId)?.status, 'rejected'); + }); +}); From 6043c0af6191784a1c52b51af7aacfc5e6f327e9 Mon Sep 17 00:00:00 2001 From: xu75 <92104817+xu75@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:03:47 +0800 Subject: [PATCH 3/4] feat(limb): wire SQLite persistence in index.ts + startup recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On startup: open limb.sqlite → load approved pairings → create offline RemoteLimbNode stubs. Heartbeat flips them online. Env: LIMB_DB overrides default path. Closes #331 [宪宪/Opus-46🐾] Co-Authored-By: Claude Opus 4.6 --- packages/api/src/index.ts | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 656572566..428392f3f 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1003,9 +1003,19 @@ async function main(): Promise { const { LimbAccessPolicy } = await import('./domains/limb/LimbAccessPolicy.js'); const { LimbLeaseManager } = await import('./domains/limb/LimbLeaseManager.js'); const { LimbActionLog } = await import('./domains/limb/LimbActionLog.js'); + + // #331: SQLite persistence for limb pairings + access policies + const { SqliteLimbPersistence } = await import('./domains/limb/SqliteLimbPersistence.js'); + const limbDbPath = process.env.LIMB_DB ?? resolve(repoRoot, 'limb.sqlite'); + const limbPersistence = new SqliteLimbPersistence(limbDbPath); + limbPersistence.initialize(); + + const limbAccessPolicy = new LimbAccessPolicy(limbPersistence); + limbAccessPolicy.initialize(); + const limbRegistry = new LimbRegistry(); limbRegistry.setDeps({ - accessPolicy: new LimbAccessPolicy(), + accessPolicy: limbAccessPolicy, leaseManager: new LimbLeaseManager(), actionLog: new LimbActionLog(), }); @@ -1013,7 +1023,25 @@ async function main(): Promise { // F126 Phase C: Pairing store + limb node routes for remote devices const { LimbPairingStore } = await import('./domains/limb/LimbPairingStore.js'); const { registerLimbNodeRoutes } = await import('./routes/limb-node-routes.js'); - const limbPairingStore = new LimbPairingStore(); + const limbPairingStore = new LimbPairingStore(limbPersistence); + limbPairingStore.initialize(); + + // #331: Recover approved pairings as offline RemoteLimbNode stubs + const { RemoteLimbNode } = await import('./domains/limb/RemoteLimbNode.js'); + for (const pairing of limbPairingStore.getApproved()) { + if (!limbRegistry.getNode(pairing.nodeId)) { + const stub = new RemoteLimbNode({ + nodeId: pairing.nodeId, + displayName: pairing.displayName, + platform: pairing.platform, + capabilities: pairing.capabilities, + endpointUrl: pairing.endpointUrl, + apiKey: pairing.apiKey, + }); + await limbRegistry.register(stub); + limbRegistry.updateStatus(pairing.nodeId, 'offline'); + } + } registerLimbNodeRoutes(app, { limbRegistry, pairingStore: limbPairingStore }); const callbackOpts = { From e3a8ec9d4f4603774293912e20464a861a0e688b Mon Sep 17 00:00:00 2001 From: xu75 <92104817+xu75@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:04:27 +0800 Subject: [PATCH 4/4] test(limb): add integration test for full persistence lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifies: register → approve → set policy → teardown → reload from SQLite → offline stub → heartbeat → online. Full #331 cycle. [宪宪/Opus-46🐾] Co-Authored-By: Claude Opus 4.6 --- .../test/limb-persistence-integration.test.js | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 packages/api/test/limb-persistence-integration.test.js diff --git a/packages/api/test/limb-persistence-integration.test.js b/packages/api/test/limb-persistence-integration.test.js new file mode 100644 index 000000000..a9622cc61 --- /dev/null +++ b/packages/api/test/limb-persistence-integration.test.js @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, it } from 'node:test'; + +const { SqliteLimbPersistence } = await import('../dist/domains/limb/SqliteLimbPersistence.js'); +const { LimbPairingStore } = await import('../dist/domains/limb/LimbPairingStore.js'); +const { LimbAccessPolicy } = await import('../dist/domains/limb/LimbAccessPolicy.js'); +const { LimbRegistry } = await import('../dist/domains/limb/LimbRegistry.js'); +const { RemoteLimbNode } = await import('../dist/domains/limb/RemoteLimbNode.js'); + +describe('#331 Integration: limb state survives restart', () => { + let tempDir; + let dbPath; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'limb-integration-')); + dbPath = join(tempDir, 'limb.sqlite'); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('approved pairing + access policy survive teardown and reload as offline stub', async () => { + // ── Session 1: register, approve, set policy ── + const p1 = new SqliteLimbPersistence(dbPath); + p1.initialize(); + const store1 = new LimbPairingStore(p1); + const policy1 = new LimbAccessPolicy(p1); + const registry1 = new LimbRegistry(); + registry1.setDeps({ accessPolicy: policy1 }); + + const req = store1.createRequest({ + nodeId: 'gpu-box-1', + displayName: 'GPU Box', + platform: 'linux', + endpointUrl: 'http://192.168.1.100:8080', + capabilities: [{ cap: 'gpu', commands: ['render', 'train'], authLevel: 'leased' }], + }); + store1.approve(req.requestId); + + // Register the node (simulating callback approve flow) + const node1 = new RemoteLimbNode({ + nodeId: req.nodeId, + displayName: req.displayName, + platform: req.platform, + capabilities: req.capabilities, + endpointUrl: req.endpointUrl, + apiKey: req.apiKey, + }); + await registry1.register(node1); + assert.equal(registry1.getNode('gpu-box-1')?.status, 'online'); + + // Set custom access policy + policy1.setPolicy({ catId: 'ragdoll', nodeId: 'gpu-box-1', capability: 'gpu', authLevel: 'free' }); + + // Teardown session 1 + p1.close(); + + // ── Session 2: reload from SQLite ── + const p2 = new SqliteLimbPersistence(dbPath); + p2.initialize(); + const store2 = new LimbPairingStore(p2); + store2.initialize(); + const policy2 = new LimbAccessPolicy(p2); + policy2.initialize(); + const registry2 = new LimbRegistry(); + registry2.setDeps({ accessPolicy: policy2 }); + + // Verify persisted pairing + const approved = store2.getApproved(); + assert.equal(approved.length, 1); + assert.equal(approved[0].nodeId, 'gpu-box-1'); + + // Recovery: create offline stubs from approved pairings + for (const pairing of store2.getApproved()) { + const stub = new RemoteLimbNode({ + nodeId: pairing.nodeId, + displayName: pairing.displayName, + platform: pairing.platform, + capabilities: pairing.capabilities, + endpointUrl: pairing.endpointUrl, + apiKey: pairing.apiKey, + }); + await registry2.register(stub); + registry2.updateStatus(pairing.nodeId, 'offline'); + } + + // Verify offline stub + const record = registry2.getNode('gpu-box-1'); + assert.ok(record); + assert.equal(record.status, 'offline'); + assert.equal(record.capabilities[0].cap, 'gpu'); + + // Verify access policy survived + assert.equal(policy2.check('ragdoll', 'gpu-box-1', 'gpu'), 'free'); + + // Simulate heartbeat → goes online + registry2.recordHeartbeat('gpu-box-1'); + assert.equal(registry2.getNode('gpu-box-1')?.status, 'online'); + + p2.close(); + }); +});