diff --git a/packages/agent/src/context-graph-subscription-policy.ts b/packages/agent/src/context-graph-subscription-policy.ts new file mode 100644 index 0000000000..25681b0e31 --- /dev/null +++ b/packages/agent/src/context-graph-subscription-policy.ts @@ -0,0 +1,79 @@ +import type { + ContextGraphSub, + ContextGraphSubInput, + ContextGraphSubscriptionRecord, + ContextGraphSyncMode, +} from './dkg-agent-types.js'; + +export function normalizeLegacyContextGraphSubscriptionInput( + previous: ContextGraphSub | undefined, + next: ContextGraphSubInput, +): ContextGraphSub { + return { + ...next, + syncMode: next.syncMode ?? previous?.syncMode ?? 'always-on', + }; +} + +export function resolveContextGraphSyncMode(input: { + existing?: Pick; + requested?: ContextGraphSyncMode; + hasDormantDurableIntent: boolean; +}): ContextGraphSyncMode { + if ( + input.hasDormantDurableIntent + || (input.existing?.subscribed === true && input.existing.syncMode === 'always-on') + ) { + return 'always-on'; + } + return input.requested ?? input.existing?.syncMode ?? 'always-on'; +} + +export type ContextGraphSubscriptionPersistenceProjection = + | { action: 'skip'; persistMemberIntent: false } + | { action: 'delete'; persistMemberIntent: true } + | { + action: 'save'; + persistMemberIntent: boolean; + record: ContextGraphSubscriptionRecord; + }; + +/** + * Canonical durable projection for live Context Graph subscription state. + * + * On-demand member intent remains process-local. A Core hosting obligation is + * independently durable and therefore projects to a host-only row. Always-on + * member intent projects the complete live readiness state. + */ +export function projectContextGraphSubscriptionPersistence(input: { + contextGraphId: string; + subscription: ContextGraphSub | undefined; + syncScoped: boolean; +}): ContextGraphSubscriptionPersistenceProjection { + const sub = input.subscription; + if (sub?.syncMode === 'on-demand' && sub.coreHosted !== true) { + return { action: 'skip', persistMemberIntent: false }; + } + if (!sub?.subscribed && !sub?.coreHosted) { + return { action: 'delete', persistMemberIntent: true }; + } + + const persistMemberIntent = sub.syncMode !== 'on-demand'; + return { + action: 'save', + persistMemberIntent, + record: { + id: input.contextGraphId, + name: sub.name, + subscribed: persistMemberIntent && sub.subscribed, + synced: persistMemberIntent && sub.synced, + sharedMemorySynced: persistMemberIntent ? sub.sharedMemorySynced : false, + metaSynced: persistMemberIntent ? sub.metaSynced : false, + onChainId: sub.onChainId, + onChainHash: sub.onChainHash, + lastReconciledOrdinal: sub.lastReconciledOrdinal, + coreHosted: sub.coreHosted, + syncScoped: persistMemberIntent && input.syncScoped, + }, + }; +} diff --git a/packages/agent/src/dkg-agent-cg-registry.ts b/packages/agent/src/dkg-agent-cg-registry.ts index 115602f5b7..cd6a2c2619 100644 --- a/packages/agent/src/dkg-agent-cg-registry.ts +++ b/packages/agent/src/dkg-agent-cg-registry.ts @@ -1048,9 +1048,10 @@ export class ContextGraphRegistryMethods extends DKGAgentBase { // one curator triple per node and `getContextGraphOwner`'s // `LIMIT 1` made ownership nondeterministic — any subscriber could // win the unordered query and look like the curator. - this.subscribeToContextGraph(opts.id); + this.subscribeToContextGraph(opts.id, { syncMode: 'always-on' }); this.setContextGraphSubscription(opts.id, { name: opts.name, + syncMode: 'always-on', subscribed: true, synced: true, metaSynced: true, @@ -1108,9 +1109,10 @@ export class ContextGraphRegistryMethods extends DKGAgentBase { this.contextGraphMetaProjection.markDirtyFromQuads(quads); await gm.ensureNewContextGraph(opts.id); - this.subscribeToContextGraph(opts.id); + this.subscribeToContextGraph(opts.id, { syncMode: 'always-on' }); this.setContextGraphSubscription(opts.id, { name: opts.name, + syncMode: 'always-on', subscribed: true, synced: true, metaSynced: true, diff --git a/packages/agent/src/dkg-agent-context-graph.ts b/packages/agent/src/dkg-agent-context-graph.ts index 4317e4ba1b..d41a804774 100644 --- a/packages/agent/src/dkg-agent-context-graph.ts +++ b/packages/agent/src/dkg-agent-context-graph.ts @@ -696,6 +696,7 @@ export class ContextGraphMethods extends DKGAgentBase { this.setContextGraphSubscription(opts.id, { name: opts.name, + syncMode: 'always-on', subscribed: !opts.private, synced: true, metaSynced: true, @@ -817,7 +818,7 @@ export class ContextGraphMethods extends DKGAgentBase { } if (!opts.private) { - this.subscribeToContextGraph(opts.id); + this.subscribeToContextGraph(opts.id, { syncMode: 'always-on' }); // Curated CGs: definition lives in _meta, NOT in ONTOLOGY. Do not // broadcast to the network — only invited nodes will discover it via @@ -1483,8 +1484,11 @@ export class ContextGraphMethods extends DKGAgentBase { const next = { ...sub, onChainHash: nameHash }; this.bindSubscriptionOnChainId(id, next, onChainId); this.setContextGraphSubscription(id, next, { persist: false }); + this.subscribeToContextGraph(id, { + trackSyncScope: true, + syncMode: 'always-on', + }); if (!next.subscribed) { - this.subscribeToContextGraph(id, { trackSyncScope: true }); this.log.info(ctx, `Subscribed to newly registered context graph "${id}"`); } this.persistContextGraphSubscription(id); diff --git a/packages/agent/src/dkg-agent-lifecycle.ts b/packages/agent/src/dkg-agent-lifecycle.ts index 966fa1d961..e2f518f66e 100644 --- a/packages/agent/src/dkg-agent-lifecycle.ts +++ b/packages/agent/src/dkg-agent-lifecycle.ts @@ -505,6 +505,7 @@ import { type SyncReconcilerProbe, type SyncReconcilerBackoff, } from './dkg-agent-types.js'; +import { projectContextGraphSubscriptionPersistence } from './context-graph-subscription-policy.js'; import { authoritativeSyncPeerId, resolveCuratorSyncPeer, @@ -2545,6 +2546,7 @@ export class LifecycleSyncMethods extends DKGAgentBase { // upsert a minimal stub first. if (!this.subscribedContextGraphs.has(localId)) { this.setContextGraphSubscription(localId, { + syncMode: 'always-on', subscribed: false, synced: false, onChainHash: hashLower, @@ -2881,6 +2883,7 @@ export class LifecycleSyncMethods extends DKGAgentBase { } const approvedSubscription: ContextGraphSub = { ...this.subscribedContextGraphs.get(contextGraphId), + syncMode: 'always-on', subscribed: true, pendingMeta: true, metaSynced: false, @@ -2949,6 +2952,7 @@ export class LifecycleSyncMethods extends DKGAgentBase { // update/finalization) wire up immediately as before. this.subscribeToContextGraph(contextGraphId, { deferSharedMemoryGossipSubscribe: true, + syncMode: 'always-on', // The exact approval snapshot was committed above. Scheduling // the ordinary background persistence here would reintroduce // untracked writes around the compensating transaction. @@ -3238,7 +3242,7 @@ export class LifecycleSyncMethods extends DKGAgentBase { // Subscribe to both system context graph GossipSub topics for (const systemContextGraph of [SYSTEM_CONTEXT_GRAPHS.AGENTS, SYSTEM_CONTEXT_GRAPHS.ONTOLOGY]) { - this.subscribeToContextGraph(systemContextGraph); + this.subscribeToContextGraph(systemContextGraph, { syncMode: 'always-on' }); } // Connect to bootstrap peers @@ -7139,9 +7143,10 @@ export class LifecycleSyncMethods extends DKGAgentBase { ? this.contextGraphWireId(next.onChainHash) : undefined; const nextWireId = nextOnChainHash ?? localWireId; - const canonicalNext = next.onChainHash === nextOnChainHash - ? next - : { ...next, onChainHash: nextOnChainHash }; + const canonicalNext: ContextGraphSub = { + ...next, + ...(next.onChainHash === nextOnChainHash ? {} : { onChainHash: nextOnChainHash }), + }; if ( previousWireId !== nextWireId && this.wireIdToLocalCgId.get(previousWireId) === contextGraphId @@ -7153,7 +7158,16 @@ export class LifecycleSyncMethods extends DKGAgentBase { if (!canonicalNext.subscribed && !canonicalNext.coreHosted) { this.clearVmReconcileStateForContextGraph(contextGraphId); } - if (options?.persist !== false) { + // On-demand member subscriptions deliberately keep their live state and + // readiness process-local. A Core's independent hosting obligation is + // still durable, though: persistContextGraphSubscription writes a + // host-only snapshot without converting the member intent to always-on. + const persistence = projectContextGraphSubscriptionPersistence({ + contextGraphId, + subscription: canonicalNext, + syncScoped: (this.config.syncContextGraphs ?? []).includes(contextGraphId), + }); + if (options?.persist !== false && persistence.action !== 'skip') { if (this.config.contextGraphSubscriptionStore) { const revision = this.nextContextGraphSubscriptionPersistRevision(contextGraphId); this.persistContextGraphSubscription( @@ -7164,9 +7178,9 @@ export class LifecycleSyncMethods extends DKGAgentBase { }, ); } - if (canonicalNext.subscribed) { + if (persistence.persistMemberIntent && canonicalNext.subscribed) { this.persistLocalNodeMembership(contextGraphId); - } else { + } else if (persistence.persistMemberIntent) { this.deleteContextGraphMember(contextGraphId, 'node', this.peerId); } } @@ -7263,7 +7277,7 @@ export class LifecycleSyncMethods extends DKGAgentBase { updateContextGraphSubscriptionRehydrationStatusAfterPersist(this: DKGAgent, contextGraphId: string, - next?: ContextGraphSub, + next?: Pick, ): void { const status = this.contextGraphSubscriptionRehydrationStatus; if (!status) return; @@ -7403,12 +7417,24 @@ export class LifecycleSyncMethods extends DKGAgentBase { return Promise.resolve(); } const sub = this.subscribedContextGraphs.get(contextGraphId); + const persistence = projectContextGraphSubscriptionPersistence({ + contextGraphId, + subscription: sub, + syncScoped: (this.config.syncContextGraphs ?? []).includes(contextGraphId), + }); + if (persistence.action === 'skip') { + // Some lifecycle paths persist reconciliation watermarks directly + // instead of going through setContextGraphSubscription. Preserve the + // process-local lifetime at this lowest shared write boundary too. + this.clearContextGraphSubscriptionPersistRevisionStateIfIdle(contextGraphId); + return Promise.resolve(); + } // Persist member subscriptions AND (Phase D) public CGs this Core hosts — // the host-only record MUST survive restart so a Core that was offline // during a publish remembers it hosts the CG and fills its gap. Drop the // row only when the node neither subscribes to nor hosts the CG. this.beginContextGraphSubscriptionPersistRevision(contextGraphId, options?.revision); - if (!sub?.subscribed && !sub?.coreHosted) { + if (persistence.action === 'delete') { return this.enqueueContextGraphSubscriptionPersistWrite(contextGraphId, () => store.delete(contextGraphId)) .then(() => { if ( @@ -7428,26 +7454,14 @@ export class LifecycleSyncMethods extends DKGAgentBase { this.finishContextGraphSubscriptionPersistRevision(contextGraphId, options?.revision); }); } - const record = { - id: contextGraphId, - name: sub.name, - subscribed: sub.subscribed, - synced: sub.synced, - sharedMemorySynced: sub.sharedMemorySynced, - metaSynced: sub.metaSynced, - onChainId: sub.onChainId, - onChainHash: sub.onChainHash, - lastReconciledOrdinal: sub.lastReconciledOrdinal, - coreHosted: sub.coreHosted, - syncScoped: (this.config.syncContextGraphs ?? []).includes(contextGraphId), - }; + const record = persistence.record; return this.enqueueContextGraphSubscriptionPersistWrite(contextGraphId, () => store.save(record)) .then(() => { if ( options?.updateRehydrationStatus === true && this.claimContextGraphSubscriptionPersistRevision(contextGraphId, options.revision) ) { - this.updateContextGraphSubscriptionRehydrationStatusAfterPersist(contextGraphId, sub); + this.updateContextGraphSubscriptionRehydrationStatusAfterPersist(contextGraphId, record); } }).catch((err) => { this.log.warn( @@ -7483,19 +7497,17 @@ export class LifecycleSyncMethods extends DKGAgentBase { `Cannot persist context graph "${contextGraphId}": active subscription or host state is missing`, ); } - const record = { - id: contextGraphId, - name: sub.name, - subscribed: sub.subscribed, - synced: sub.synced, - sharedMemorySynced: sub.sharedMemorySynced, - metaSynced: sub.metaSynced, - onChainId: sub.onChainId, - onChainHash: sub.onChainHash, - lastReconciledOrdinal: sub.lastReconciledOrdinal, - coreHosted: sub.coreHosted, + const persistence = projectContextGraphSubscriptionPersistence({ + contextGraphId, + subscription: sub, syncScoped: syncScoped ?? (this.config.syncContextGraphs ?? []).includes(contextGraphId), - }; + }); + if (persistence.action !== 'save' || !persistence.persistMemberIntent) { + throw new Error( + `Cannot acknowledge join approval for "${contextGraphId}": durable subscription intent is missing`, + ); + } + const record = persistence.record; // Queue behind any fire-and-forget writes scheduled by subscribe/mark so // this final authoritative snapshot is the last write before the ACK. await this.enqueueContextGraphSubscriptionPersistWrite(contextGraphId, async () => { @@ -7935,6 +7947,9 @@ export class LifecycleSyncMethods extends DKGAgentBase { const restorePendingMeta = hasJoinApproval && !approvedAgentAuthorized; this.setContextGraphSubscription(row.id, { name: row.name, + // Every row in the durable store predates or represents explicit + // restart persistence, so absence of a mode is always-on. + syncMode: 'always-on', subscribed: row.subscribed, synced: restorePendingMeta ? false : row.synced, sharedMemorySynced: restorePendingMeta ? false : row.sharedMemorySynced, @@ -7951,7 +7966,11 @@ export class LifecycleSyncMethods extends DKGAgentBase { this.trackSyncContextGraph(row.id); } if (row.subscribed) { - this.subscribeToContextGraph(row.id, { trackSyncScope: false, persist: false }); + this.subscribeToContextGraph(row.id, { + trackSyncScope: false, + persist: false, + syncMode: 'always-on', + }); this.persistLocalNodeMembership(row.id, 'rehydrated-subscription'); } // Upgrade/self-heal path for late private-CG members whose payload and diff --git a/packages/agent/src/dkg-agent-publish.ts b/packages/agent/src/dkg-agent-publish.ts index f0a5feac5a..7967c1a23a 100644 --- a/packages/agent/src/dkg-agent-publish.ts +++ b/packages/agent/src/dkg-agent-publish.ts @@ -2495,9 +2495,9 @@ export class PublishMethods extends DKGAgentBase { this.contextGraphMetaProjection.markDirtyFromQuads(quads); await gm.ensureContextGraph(contextGraphId); await this.store.flush?.(); - this.subscribeToContextGraph(contextGraphId); + const promotedSub = this.subscribeToContextGraph(contextGraphId, { syncMode: 'always-on' }); this.setContextGraphSubscription(contextGraphId, { - ...existingSub, + ...promotedSub, name, subscribed: true, synced: true, diff --git a/packages/agent/src/dkg-agent-swm-host.ts b/packages/agent/src/dkg-agent-swm-host.ts index 09d5c66214..b0b6b7b1d8 100644 --- a/packages/agent/src/dkg-agent-swm-host.ts +++ b/packages/agent/src/dkg-agent-swm-host.ts @@ -1236,6 +1236,7 @@ export class SwmHostModeMethods extends DKGAgentBase { // translate either direction without an extra RPC. if (storageCgId !== contextGraphId) { const storageSubscription = this.subscribedContextGraphs.get(storageCgId) ?? { + syncMode: 'always-on' as const, subscribed: false, synced: false, pendingMeta: true, @@ -1804,6 +1805,7 @@ export class SwmHostModeMethods extends DKGAgentBase { // that did not create or join the CG. if (!this.subscribedContextGraphs.has(wireId)) { this.setContextGraphSubscription(wireId, { + syncMode: 'always-on', subscribed: false, synced: false, onChainHash: wireId, @@ -2578,7 +2580,13 @@ export class SwmHostModeMethods extends DKGAgentBase { existing.coreHosted = true; next = existing; } else { - next = { subscribed: false, synced: false, onChainId: numericStr, coreHosted: true }; + next = { + syncMode: 'always-on', + subscribed: false, + synced: false, + onChainId: numericStr, + coreHosted: true, + }; } this.setContextGraphSubscription(localCgId, next); this.log.info( diff --git a/packages/agent/src/dkg-agent-swm-substrate.ts b/packages/agent/src/dkg-agent-swm-substrate.ts index 36d4de0f7c..7617c558f5 100644 --- a/packages/agent/src/dkg-agent-swm-substrate.ts +++ b/packages/agent/src/dkg-agent-swm-substrate.ts @@ -349,6 +349,7 @@ import { type DKGAgentConfig, type ReplicationEvent, } from './dkg-agent-types.js'; +import { resolveContextGraphSyncMode } from './context-graph-subscription-policy.js'; import { normalizePublishContextGraphId, isPublishAsyncQuadEnvelope, @@ -384,11 +385,29 @@ import { DKGAgentBase } from './dkg-agent-base.js'; import type { DKGAgent } from './dkg-agent.js'; export class SwmSubstrateMethods extends DKGAgentBase { - subscribeToContextGraph(this: DKGAgent, contextGraphId: string, options?: { trackSyncScope?: boolean; persist?: boolean; deferSharedMemoryGossipSubscribe?: boolean }): void { + subscribeToContextGraph(this: DKGAgent, contextGraphId: string, options?: { + trackSyncScope?: boolean; + persist?: boolean; + deferSharedMemoryGossipSubscribe?: boolean; + syncMode?: 'on-demand' | 'always-on'; + }): ContextGraphSub { if (options?.trackSyncScope !== false) { this.trackSyncContextGraph(contextGraphId); } + const existing = this.subscribedContextGraphs.get(contextGraphId); + // Opening an already durable graph must never silently downgrade it to a + // process-local subscription. An explicit always-on request may promote an + // existing on-demand subscription, while an omitted mode preserves the + // current lifetime (or the legacy always-on default for a new graph). + const syncMode = resolveContextGraphSyncMode({ + existing, + requested: options?.syncMode, + hasDormantDurableIntent: + this.contextGraphSubscriptionRehydrationStatus?.dormantIds.includes(contextGraphId) === true, + }); + const persist = syncMode === 'on-demand' ? false : options?.persist; + // SWM gossip subscribe runs `canReadContextGraph` against the local // `_meta` graph. On a fresh `join-approved` notification the curator // has just written the allowlist into ITS _meta, but the requesting @@ -407,15 +426,19 @@ export class SwmSubstrateMethods extends DKGAgentBase { if (!deferSwmGossip) { this.queueSharedMemoryGossipSubscription(contextGraphId); } - const existing = this.subscribedContextGraphs.get(contextGraphId); - if (!existing?.subscribed) { - this.setContextGraphSubscription( + if (!existing?.subscribed || existing.syncMode !== syncMode) { + return this.setContextGraphSubscription( contextGraphId, - { ...existing, subscribed: true, synced: existing?.synced ?? false }, - { persist: options?.persist }, + { + ...existing, + subscribed: true, + synced: existing?.synced ?? false, + syncMode, + }, + { persist }, ); } - return; + return existing; } this.gossipRegistered.add(contextGraphId); @@ -425,11 +448,15 @@ export class SwmSubstrateMethods extends DKGAgentBase { this.gossip.subscribe(publishTopic); this.gossip.subscribe(appTopic); - const existing = this.subscribedContextGraphs.get(contextGraphId); - this.setContextGraphSubscription( + const subscription = this.setContextGraphSubscription( contextGraphId, - { ...existing, subscribed: true, synced: existing?.synced ?? false }, - { persist: options?.persist }, + { + ...existing, + subscribed: true, + synced: existing?.synced ?? false, + syncMode, + }, + { persist }, ); this.gossip.onMessage(publishTopic, async (_topic, data, from) => { @@ -454,6 +481,8 @@ export class SwmSubstrateMethods extends DKGAgentBase { const fh = this.getOrCreateFinalizationHandler(); await fh.handleFinalizationMessage(data, contextGraphId, from); }); + + return subscription; } /** diff --git a/packages/agent/src/dkg-agent-types.ts b/packages/agent/src/dkg-agent-types.ts index 39bc7c053a..861059c521 100644 --- a/packages/agent/src/dkg-agent-types.ts +++ b/packages/agent/src/dkg-agent-types.ts @@ -605,9 +605,21 @@ export interface ChatSendResult { // ── Context-graph surface ─────────────────────────────────────────── +/** + * Lifetime of an edge node's active Context Graph synchronization intent. + * + * `on-demand` remains active only for the current process. `always-on` is + * restart-durable through the configured subscription store. Persisted rows + * written before this distinction existed are therefore implicitly + * `always-on` for backward compatibility. + */ +export type ContextGraphSyncMode = 'on-demand' | 'always-on'; + /** Tracks the subscription and sync state of a context graph. */ export interface ContextGraphSub { name?: string; + /** Requested synchronization lifetime, normalized before entering live state. */ + syncMode: ContextGraphSyncMode; /** GossipSub topics are active for this context graph. */ subscribed: boolean; /** Definition triples exist in the local triple store. */ @@ -685,6 +697,17 @@ export interface ContextGraphSub { pendingMeta?: boolean; } +/** + * Legacy compatibility input for the standalone gossip handler only. + * + * Agent-owned live-state mutations use normalized {@link ContextGraphSub} + * values and must choose a synchronization lifetime explicitly. This shape is + * retained solely for older standalone handler callbacks that predate modes. + */ +export type ContextGraphSubInput = Omit & { + syncMode?: ContextGraphSyncMode; +}; + /** * Metadata that passive discovery is allowed to contribute to the local * Context Graph catalogue. diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 498f079ecb..f141506721 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -337,6 +337,7 @@ import { type PeerDiagnostics, type ChatSendResult, type ContextGraphSub, + type ContextGraphSyncMode, type ContextGraphDiscoveryMetadata, type ContextGraphDiscoveryOptions, type ContextGraphSubscriptionRecord, @@ -451,6 +452,7 @@ export type { PeerDiagnostics, ChatSendResult, ContextGraphSub, + ContextGraphSyncMode, ContextGraphDiscoveryMetadata, ContextGraphDiscoveryOptions, ContextGraphSubscriptionRecord, @@ -1228,6 +1230,7 @@ export class DKGAgent extends DKGAgentBase { const existing = this.subscribedContextGraphs.get(contextGraphId); const next: ContextGraphSub = { ...existing, + syncMode: existing?.syncMode ?? 'always-on', name: metadata.name ?? existing?.name, subscribed: existing?.subscribed === true, synced: existing?.synced === true, @@ -1254,6 +1257,7 @@ export class DKGAgent extends DKGAgentBase { if (!existing && (this.config.nodeRole ?? 'edge') === 'core') { this.subscribeToContextGraph(contextGraphId, { trackSyncScope: options.trackSyncScope, + syncMode: 'always-on', }); } diff --git a/packages/agent/src/gossip-publish-handler.ts b/packages/agent/src/gossip-publish-handler.ts index 69db636e4e..9a7c5c9589 100644 --- a/packages/agent/src/gossip-publish-handler.ts +++ b/packages/agent/src/gossip-publish-handler.ts @@ -31,7 +31,12 @@ import { } from '@origintrail-official/dkg-publisher'; import { ethers } from 'ethers'; import type { ContextGraphMetaRecord } from './context-graph-meta-projection.js'; -import type { ContextGraphDiscoveryMetadata, ContextGraphSub } from './dkg-agent-types.js'; +import type { + ContextGraphDiscoveryMetadata, + ContextGraphSub, + ContextGraphSubInput, +} from './dkg-agent-types.js'; +import { normalizeLegacyContextGraphSubscriptionInput } from './context-graph-subscription-policy.js'; import { protobufScalarToBigInt, protobufScalarToNumber } from './protobuf-scalars.js'; export type GossipPhaseCallback = (phase: string, status: 'start' | 'end') => void; @@ -195,15 +200,17 @@ export class GossipPublishHandler { private setContextGraphSubscription( id: string, - next: ContextGraphSub, + next: ContextGraphSubInput, options?: { persist?: boolean }, ): void { + const previous = this.subscribedContextGraphs.get(id); + const normalized = normalizeLegacyContextGraphSubscriptionInput(previous, next); const setter = this.callbacks.setContextGraphSubscription; if (setter) { - setter(id, next, options); + setter(id, normalized, options); return; } - this.subscribedContextGraphs.set(id, next); + this.subscribedContextGraphs.set(id, normalized); } private recordDiscoveredContextGraph(id: string, metadata: ContextGraphDiscoveryMetadata): void { diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 04bab01885..2c6267402f 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -205,6 +205,7 @@ export { type Rfc64CatalogAccessPolicyAuthorityConfigV1, type DKGAgentACKTransportOptions, type ContextGraphSub, + type ContextGraphSyncMode, type ContextGraphDiscoveryMetadata, type ContextGraphDiscoveryOptions, type PublishOpts, diff --git a/packages/agent/test/agent.part-15.test.ts b/packages/agent/test/agent.part-15.test.ts index 500c28ca79..e5b3f23693 100644 --- a/packages/agent/test/agent.part-15.test.ts +++ b/packages/agent/test/agent.part-15.test.ts @@ -3,6 +3,46 @@ import { describe, it, expect, beforeAll, afterAll, vi, DKGAgentWallet, buildAge let _fileSnapshot: string; + +function createContextGraphPersistenceFixture() { + const persisted = new Map(); + const persistedMembers = new Map(); + const subscriptionStore = { + loadAll: async () => [...persisted.values()], + save: async (record: any) => { + persisted.set(record.id, { ...record }); + }, + delete: async (contextGraphId: string) => { + persisted.delete(contextGraphId); + }, + }; + const membershipStore = { + upsert: async (record: any) => { + persistedMembers.set( + `${record.contextGraphId}|${record.principalType}|${record.principalId}`, + { ...record }, + ); + }, + delete: async (contextGraphId: string, principalType: string, principalId: string) => { + persistedMembers.delete(`${contextGraphId}|${principalType}|${principalId}`); + }, + }; + return { persisted, persistedMembers, subscriptionStore, membershipStore }; +} + +async function createAgentWithContextGraphPersistence( + name: string, + fixture: ReturnType, +) { + return DKGAgent.create({ + name, + listenHost: '127.0.0.1', + chainAdapter: createEVMAdapter(HARDHAT_KEYS.CORE_OP), + contextGraphSubscriptionStore: fixture.subscriptionStore, + contextGraphMembershipStore: fixture.membershipStore, + }); +} + beforeAll(async () => { _fileSnapshot = await takeSnapshot(); const { hubAddress } = getSharedContext(); @@ -68,33 +108,9 @@ describe('DKGAgent config — syncContextGraphs and queryAccess warning', () => it('persists runtime subscriptions and rehydrates them on restart', async () => { - const persisted = new Map(); - const persistedMembers = new Map(); - const subscriptionStore = { - loadAll: async () => [...persisted.values()], - save: async (record: any) => { - persisted.set(record.id, { ...record }); - }, - delete: async (contextGraphId: string) => { - persisted.delete(contextGraphId); - }, - }; - const membershipStore = { - upsert: async (record: any) => { - persistedMembers.set(`${record.contextGraphId}|${record.principalType}|${record.principalId}`, { ...record }); - }, - delete: async (contextGraphId: string, principalType: string, principalId: string) => { - persistedMembers.delete(`${contextGraphId}|${principalType}|${principalId}`); - }, - }; - - const agentA = await DKGAgent.create({ - name: 'PersistedSubscriptionsA', - listenHost: '127.0.0.1', - chainAdapter: createEVMAdapter(HARDHAT_KEYS.CORE_OP), - contextGraphSubscriptionStore: subscriptionStore, - contextGraphMembershipStore: membershipStore, - }); + const fixture = createContextGraphPersistenceFixture(); + const { persisted, persistedMembers } = fixture; + const agentA = await createAgentWithContextGraphPersistence('PersistedSubscriptionsA', fixture); let agentAPeerId = ''; try { @@ -130,18 +146,13 @@ describe('DKGAgent config — syncContextGraphs and queryAccess warning', () => source: 'subscription', }); - const agentB = await DKGAgent.create({ - name: 'PersistedSubscriptionsB', - listenHost: '127.0.0.1', - chainAdapter: createEVMAdapter(HARDHAT_KEYS.CORE_OP), - contextGraphSubscriptionStore: subscriptionStore, - contextGraphMembershipStore: membershipStore, - }); + const agentB = await createAgentWithContextGraphPersistence('PersistedSubscriptionsB', fixture); try { await agentB.start(); expect(agentB.getSubscribedContextGraphs().get('persisted-cg')).toMatchObject({ subscribed: true, + syncMode: 'always-on', synced: true, sharedMemorySynced: true, metaSynced: true, @@ -162,6 +173,278 @@ describe('DKGAgent config — syncContextGraphs and queryAccess warning', () => }); + it('keeps on-demand subscriptions process-local until explicitly promoted', async () => { + const fixture = createContextGraphPersistenceFixture(); + const { persisted, persistedMembers } = fixture; + const agentA = await createAgentWithContextGraphPersistence('OnDemandSubscriptionLifetimeA', fixture); + + try { + await agentA.start(); + agentA.subscribeToContextGraph('selected-cg', { syncMode: 'on-demand' }); + agentA.markContextGraphSubscriptionState('selected-cg', { + synced: true, + sharedMemorySynced: true, + metaSynced: true, + }); + (agentA as any).persistContextGraphSubscription('selected-cg'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(agentA.getSubscribedContextGraphs().get('selected-cg')).toMatchObject({ + subscribed: true, + syncMode: 'on-demand', + synced: true, + }); + expect(persisted.has('selected-cg')).toBe(false); + expect(persistedMembers.has(`selected-cg|node|${agentA.peerId}`)).toBe(false); + } finally { + await agentA.stop().catch(() => {}); + } + + const agentB = await createAgentWithContextGraphPersistence('OnDemandSubscriptionLifetimeB', fixture); + try { + await agentB.start(); + expect(agentB.getSubscribedContextGraphs().get('selected-cg')).toBeUndefined(); + + agentB.subscribeToContextGraph('selected-cg', { syncMode: 'on-demand' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(persistedMembers.has(`selected-cg|node|${agentB.peerId}`)).toBe(false); + + agentB.subscribeToContextGraph('selected-cg', { syncMode: 'always-on' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(persisted.get('selected-cg')).toMatchObject({ + id: 'selected-cg', + subscribed: true, + synced: false, + }); + expect(agentB.getSubscribedContextGraphs().get('selected-cg')?.syncMode).toBe('always-on'); + expect(persistedMembers.get(`selected-cg|node|${agentB.peerId}`)).toMatchObject({ + contextGraphId: 'selected-cg', + principalType: 'node', + principalId: agentB.peerId, + status: 'active', + source: 'subscription', + }); + + // A later UI open is on-demand, but must not silently downgrade an + // operator's explicit always-on choice. + agentB.subscribeToContextGraph('selected-cg', { syncMode: 'on-demand' }); + expect(agentB.getSubscribedContextGraphs().get('selected-cg')?.syncMode).toBe('always-on'); + } finally { + await agentB.stop().catch(() => {}); + } + }); + + + it('preserves dormant durable intent when an edge opens the graph on demand', async () => { + const fixture = createContextGraphPersistenceFixture(); + const { persisted, persistedMembers } = fixture; + const activeId = 'durable-a-active'; + const dormantId = 'durable-z-dormant'; + for (const id of [activeId, dormantId]) { + persisted.set(id, { + id, + name: id, + subscribed: true, + synced: false, + sharedMemorySynced: false, + metaSynced: false, + syncScoped: true, + }); + } + + const agentA = await DKGAgent.create({ + name: 'DormantDurableIntentA', + listenHost: '127.0.0.1', + chainAdapter: createEVMAdapter(HARDHAT_KEYS.CORE_OP), + contextGraphSubscriptionStore: fixture.subscriptionStore, + contextGraphMembershipStore: fixture.membershipStore, + maxRehydratedContextGraphSubscriptions: 1, + }); + + try { + await agentA.start(); + expect(agentA.getSubscribedContextGraphs().get(activeId)?.syncMode).toBe('always-on'); + expect(agentA.getSubscribedContextGraphs().get(dormantId)).toBeUndefined(); + expect(agentA.getContextGraphSubscriptionRehydrationStatus()).toMatchObject({ + activated: 1, + dormant: 1, + dormantIds: [dormantId], + }); + + // Opening a capped durable row is expressed by the UI as on-demand. + // The agent must recover the stored always-on intent instead of + // downgrading it and later deleting it from the durable store. + const selected = agentA.subscribeToContextGraph(dormantId, { syncMode: 'on-demand' }); + expect(selected.syncMode).toBe('always-on'); + expect(agentA.getSubscribedContextGraphs().get(dormantId)).toMatchObject({ + subscribed: true, + syncMode: 'always-on', + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(persisted.get(dormantId)).toMatchObject({ + id: dormantId, + subscribed: true, + syncScoped: true, + }); + expect(persistedMembers.get(`${dormantId}|node|${agentA.peerId}`)).toMatchObject({ + contextGraphId: dormantId, + status: 'active', + source: 'subscription', + }); + expect(agentA.getContextGraphSubscriptionRehydrationStatus()).toMatchObject({ + activated: 2, + dormant: 0, + dormantIds: [], + }); + } finally { + await agentA.stop().catch(() => {}); + } + + const agentB = await DKGAgent.create({ + name: 'DormantDurableIntentB', + listenHost: '127.0.0.1', + chainAdapter: createEVMAdapter(HARDHAT_KEYS.CORE_OP), + contextGraphSubscriptionStore: fixture.subscriptionStore, + contextGraphMembershipStore: fixture.membershipStore, + maxRehydratedContextGraphSubscriptions: 0, + }); + try { + await agentB.start(); + expect(agentB.getSubscribedContextGraphs().get(dormantId)).toMatchObject({ + subscribed: true, + syncMode: 'always-on', + }); + } finally { + await agentB.stop().catch(() => {}); + } + }); + + + it('promotes an existing on-demand selection when the node creates the graph', async () => { + const fixture = createContextGraphPersistenceFixture(); + const { persisted, persistedMembers } = fixture; + const contextGraphId = 'selected-then-created-cg'; + const agent = await createAgentWithContextGraphPersistence('OnDemandCreatePromotion', fixture); + + try { + await agent.start(); + agent.subscribeToContextGraph(contextGraphId, { syncMode: 'on-demand' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(persisted.has(contextGraphId)).toBe(false); + + await agent.createContextGraph({ + id: contextGraphId, + name: 'Selected then created', + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(agent.getSubscribedContextGraphs().get(contextGraphId)).toMatchObject({ + subscribed: true, + syncMode: 'always-on', + synced: true, + metaSynced: true, + }); + expect(persisted.get(contextGraphId)).toMatchObject({ + id: contextGraphId, + subscribed: true, + synced: true, + metaSynced: true, + syncScoped: true, + }); + expect(persistedMembers.get(`${contextGraphId}|node|${agent.peerId}`)).toMatchObject({ + contextGraphId, + principalType: 'node', + principalId: agent.peerId, + status: 'active', + source: 'subscription', + }); + } finally { + await agent.stop().catch(() => {}); + } + }); + + + it('persists a Core hosting obligation without persisting its on-demand member intent', async () => { + const persisted = new Map(); + const subscriptionStore = { + loadAll: async () => [...persisted.values()], + save: async (record: any) => { + persisted.set(record.id, { ...record }); + }, + delete: async (contextGraphId: string) => { + persisted.delete(contextGraphId); + }, + }; + const localCgId = 'selected-hosted-cg'; + + const agentA = await DKGAgent.create({ + name: 'OnDemandCoreHostA', + listenHost: '127.0.0.1', + chainAdapter: createEVMAdapter(HARDHAT_KEYS.CORE_OP), + contextGraphSubscriptionStore: subscriptionStore, + nodeRole: 'core', + }); + try { + await agentA.start(); + agentA.subscribeToContextGraph(localCgId, { syncMode: 'on-demand' }); + agentA.markContextGraphSubscriptionState(localCgId, { + synced: true, + sharedMemorySynced: true, + metaSynced: true, + }); + (agentA as any).chain.getContextGraphAccessPolicy = async () => 0; + (agentA as any).chain.isContextGraphActiveOnChain = async () => true; + + await (agentA as any).recordCoreHostedPublicCg('14', localCgId); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(agentA.getSubscribedContextGraphs().get(localCgId)).toMatchObject({ + subscribed: true, + syncMode: 'on-demand', + synced: true, + coreHosted: true, + onChainId: '14', + }); + expect(persisted.get(localCgId)).toMatchObject({ + id: localCgId, + subscribed: false, + synced: false, + sharedMemorySynced: false, + metaSynced: false, + coreHosted: true, + onChainId: '14', + syncScoped: false, + }); + } finally { + await agentA.stop().catch(() => {}); + } + + const agentB = await DKGAgent.create({ + name: 'OnDemandCoreHostB', + listenHost: '127.0.0.1', + chainAdapter: createEVMAdapter(HARDHAT_KEYS.CORE_OP), + contextGraphSubscriptionStore: subscriptionStore, + nodeRole: 'core', + }); + try { + await agentB.start(); + expect(agentB.getSubscribedContextGraphs().get(localCgId)).toMatchObject({ + subscribed: false, + syncMode: 'always-on', + synced: false, + sharedMemorySynced: false, + metaSynced: false, + coreHosted: true, + onChainId: '14', + }); + expect((agentB as any).config.syncContextGraphs ?? []).not.toContain(localCgId); + } finally { + await agentB.stop().catch(() => {}); + } + }); + + it('rehydrates persisted subscriptions without forcing sync scope', async () => { const subscriptionStore = { loadAll: async () => [{ diff --git a/packages/agent/test/core-fills-gap.test.ts b/packages/agent/test/core-fills-gap.test.ts index eb815271a9..3817dbcb3a 100644 --- a/packages/agent/test/core-fills-gap.test.ts +++ b/packages/agent/test/core-fills-gap.test.ts @@ -98,7 +98,8 @@ interface AgentInternals { watermarkAfter: number; }>; runVmReconcileSweep(): Promise; - subscribedContextGraphs: Map; + subscribedContextGraphs: Map; + gossipRegistered: Set; vmReconcileDispatcher: { triggerLive: (cg: string) => void; triggerPeriodic: (cg: string) => void; @@ -694,7 +695,7 @@ describe('Phase D — recordCoreHostedPublicCg', () => { ).toBe(false); }); - it('clears VM reconcile state when stale inactive on-chain ids are re-registered', async () => { + it('clears VM reconcile state and promotes durable mode when stale inactive on-chain ids are re-registered', async () => { const internals = await boot(); const localCgId = 'stale-register'; const ownerAddr = (internals.chain as unknown as { signerAddress: string }).signerAddress; @@ -711,9 +712,13 @@ describe('Phase D — recordCoreHostedPublicCg', () => { object: '"5"', graph: contextGraphDataGraphUri(SYSTEM_CONTEXT_GRAPHS.ONTOLOGY), }]); + // This low-level fixture intentionally has no gossip runtime. Model a + // valid already-live on-demand member, including the handler-registration + // invariant that makes subscribeToContextGraph's promotion path idempotent. internals.subscribedContextGraphs.set(localCgId, { - subscribed: true, onChainId: '5', lastReconciledOrdinal: 4, + syncMode: 'on-demand', subscribed: true, onChainId: '5', lastReconciledOrdinal: 4, }); + internals.gossipRegistered.add(localCgId); internals.chain.isContextGraphActiveOnChain = async (id) => id !== 5n; const storageAddr = await internals.chain.getDKGKnowledgeAssetsAddress(); @@ -748,6 +753,7 @@ describe('Phase D — recordCoreHostedPublicCg', () => { const sub = internals.subscribedContextGraphs.get(localCgId); expect(sub?.onChainId).not.toBe('5'); + expect(sub?.syncMode).toBe('always-on'); expect(sub?.lastReconciledOrdinal).toBe(0); expect(recent.has(recentKey)).toBe(false); expect(negativeCache.has(recentKey)).toBe(false); diff --git a/packages/agent/test/discovery-subscription-boundary.test.ts b/packages/agent/test/discovery-subscription-boundary.test.ts index 3ec65ed1e1..daf3ed071c 100644 --- a/packages/agent/test/discovery-subscription-boundary.test.ts +++ b/packages/agent/test/discovery-subscription-boundary.test.ts @@ -652,11 +652,15 @@ describe('Context Graph discovery/subscription boundary', () => { id: 'explicit-local-create', name: 'Explicit Local Create', }); + agent.subscribeToContextGraph('implicit-local-write', { syncMode: 'on-demand' }); + expect(agent.getSubscribedContextGraphs().get('implicit-local-write')?.syncMode).toBe('on-demand'); + expect(persisted.has('implicit-local-write')).toBe(false); await agent.ensureImplicitSharedMemoryContextGraph('implicit-local-write'); await new Promise((resolve) => setTimeout(resolve, 0)); for (const id of ['explicit-local-create', 'implicit-local-write']) { expect(agent.getSubscribedContextGraphs().get(id)?.subscribed).toBe(true); + expect(agent.getSubscribedContextGraphs().get(id)?.syncMode).toBe('always-on'); expect((agent as any).config.syncContextGraphs ?? []).toContain(id); expect((agent as any).gossipRegistered.has(id)).toBe(true); expect(persisted.get(id)).toMatchObject({ subscribed: true, syncScoped: true }); diff --git a/packages/cli/src/api-client.ts b/packages/cli/src/api-client.ts index c06e4a3e48..d271945b42 100644 --- a/packages/cli/src/api-client.ts +++ b/packages/cli/src/api-client.ts @@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises'; import { basename } from 'node:path'; import type { ContextGraphReconcileResult, + ContextGraphSyncMode, RandomSamplingDisabledReason, } from '@origintrail-official/dkg-agent'; import type { JournalReadResult } from '@origintrail-official/dkg-publisher'; @@ -1489,8 +1490,12 @@ export class ApiClient { return this.post('/api/query-remote', { peerId, ...request }); } - async subscribeToContextGraph(contextGraphId: string, options?: { includeSharedMemory?: boolean }): Promise<{ + async subscribeToContextGraph(contextGraphId: string, options: { + includeSharedMemory?: boolean; + syncMode: ContextGraphSyncMode; + }): Promise<{ subscribed: string; + syncMode: ContextGraphSyncMode; catchup?: | { connectedPeers: number; @@ -1552,7 +1557,11 @@ export class ApiClient { jobId: string; }; }> { - return this.post('/api/context-graph/subscribe', { contextGraphId, includeWorkspace: options?.includeSharedMemory }); + return this.post('/api/context-graph/subscribe', { + contextGraphId, + includeWorkspace: options.includeSharedMemory, + syncMode: options.syncMode, + }); } /** @@ -1566,6 +1575,7 @@ export class ApiClient { /** @deprecated Use subscribeToContextGraph */ async subscribe(contextGraphId: string, options?: { includeWorkspace?: boolean }): Promise<{ subscribed: string; + syncMode: ContextGraphSyncMode; catchup?: | { connectedPeers: number; @@ -1627,7 +1637,10 @@ export class ApiClient { jobId: string; }; }> { - return this.subscribeToContextGraph(contextGraphId, { includeSharedMemory: options?.includeWorkspace }); + return this.subscribeToContextGraph(contextGraphId, { + includeSharedMemory: options?.includeWorkspace, + syncMode: 'always-on', + }); } async catchupStatus(contextGraphId: string): Promise<{ diff --git a/packages/cli/src/commands/knowledge.ts b/packages/cli/src/commands/knowledge.ts index 5d1a353e7b..c3177ad917 100644 --- a/packages/cli/src/commands/knowledge.ts +++ b/packages/cli/src/commands/knowledge.ts @@ -342,8 +342,15 @@ program .action(async (contextGraph: string, opts: ActionOpts) => { try { const client = await ApiClient.connect(); - const result = await client.subscribeToContextGraph(contextGraph); + const result = await client.subscribeToContextGraph(contextGraph, { + syncMode: opts.save ? 'always-on' : 'on-demand', + }); console.log(`Subscribed to context graph: ${contextGraph}`); + console.log( + result.syncMode === 'always-on' + ? 'Synchronization mode: always on (restored after restart).' + : 'Synchronization mode: on demand (current node process only).', + ); const catchup = result.catchup; if (catchup) { if ('peersTried' in catchup) { diff --git a/packages/cli/src/daemon/lifecycle.ts b/packages/cli/src/daemon/lifecycle.ts index 88a5c7c1c0..9d6026b1d3 100644 --- a/packages/cli/src/daemon/lifecycle.ts +++ b/packages/cli/src/daemon/lifecycle.ts @@ -1080,13 +1080,13 @@ export async function bootstrapConfiguredContextGraphs(input: { input.log( `Context graph "${contextGraphId}" setup failed: ${err instanceof Error ? err.message : String(err)} — will discover via sync/gossip`, ); - input.agent.subscribeToContextGraph(contextGraphId); + input.agent.subscribeToContextGraph(contextGraphId, { syncMode: 'always-on' }); } continue; } const existing = input.agent.getSubscribedContextGraphs().get(contextGraphId); - input.agent.subscribeToContextGraph(contextGraphId); + input.agent.subscribeToContextGraph(contextGraphId, { syncMode: 'always-on' }); let hasAuthoritativeMetadata = false; let locallyCurated = false; diff --git a/packages/cli/src/daemon/routes/context-graph.ts b/packages/cli/src/daemon/routes/context-graph.ts index 41959da907..760adac6b7 100644 --- a/packages/cli/src/daemon/routes/context-graph.ts +++ b/packages/cli/src/daemon/routes/context-graph.ts @@ -61,6 +61,7 @@ import { ContextGraphOnChainIdUnresolvedError, DKGAgent, loadOpWallets, + type ContextGraphSyncMode, VmReconcileQueueClosedError, VmReconcileQueueFullError, VmReconcileUnavailableError, @@ -1734,6 +1735,17 @@ export async function handleContextGraphRoutes(ctx: RequestContext): Promise { }); describe('POST endpoints', () => { + it('subscribeToContextGraph() forwards explicit sync lifetime', async () => { + const { fetch, calls } = createTrackingFetch({ + ok: true, + status: 200, + body: { subscribed: 'cg-selected', syncMode: 'on-demand' }, + }); + globalThis.fetch = fetch; + + await client.subscribeToContextGraph('cg-selected', { + includeSharedMemory: true, + syncMode: 'on-demand', + }); + + expect(calls[0].url).toBe(`http://127.0.0.1:${PORT}/api/context-graph/subscribe`); + expect(JSON.parse(calls[0].opts.body as string)).toEqual({ + contextGraphId: 'cg-selected', + includeWorkspace: true, + syncMode: 'on-demand', + }); + }); + + it('subscribe() keeps the legacy restart-durable lifetime explicit', async () => { + const { fetch, calls } = createTrackingFetch({ + ok: true, + status: 200, + body: { subscribed: 'cg-legacy', syncMode: 'always-on' }, + }); + globalThis.fetch = fetch; + + await client.subscribe('cg-legacy', { includeWorkspace: true }); + + expect(JSON.parse(calls[0].opts.body as string)).toEqual({ + contextGraphId: 'cg-legacy', + includeWorkspace: true, + syncMode: 'always-on', + }); + }); + it('sendChat() sends correct body', async () => { const { fetch, calls } = createTrackingFetch({ ok: true, status: 200, body: { delivered: true } }); globalThis.fetch = fetch; diff --git a/packages/cli/test/context-graph-subscribe-readiness.test.ts b/packages/cli/test/context-graph-subscribe-readiness.test.ts index ad47313dd5..af30857287 100644 --- a/packages/cli/test/context-graph-subscribe-readiness.test.ts +++ b/packages/cli/test/context-graph-subscribe-readiness.test.ts @@ -145,6 +145,7 @@ describe('context graph subscribe readiness requires authoritative metadata', () callerAddress?: string; result?: CatchupJobResult; includeSharedMemory?: boolean; + syncMode?: unknown; readiness?: { version: number; durableVerified: boolean; @@ -153,9 +154,14 @@ describe('context graph subscribe readiness requires authoritative metadata', () }; }): Promise<{ response: any; + responseStatus: number; job: any; runCalls: number; runRequests: CatchupRunRequest[]; + subscribeCalls: Array<{ + id: string; + options: { syncMode?: 'on-demand' | 'always-on' } | undefined; + }>; state: Record; patches: Array>; readiness: Record | undefined; @@ -171,6 +177,10 @@ describe('context graph subscribe readiness requires authoritative metadata', () }; let runCalls = 0; const runRequests: CatchupRunRequest[] = []; + const subscribeCalls: Array<{ + id: string; + options: { syncMode?: 'on-demand' | 'always-on' } | undefined; + }> = []; let readiness = opts.readiness ? { ...opts.readiness, updatedAt: opts.readiness.updatedAt ?? Date.now() } : undefined; @@ -187,11 +197,23 @@ describe('context graph subscribe readiness requires authoritative metadata', () const agent = { getContextGraphAllowedAgents: async () => opts.allowedAgents ?? [], getSubscribedContextGraphs: () => state, - subscribeToContextGraph: (id: string) => { - state.set(id, { - ...state.get(id), + subscribeToContextGraph: ( + id: string, + options?: { syncMode?: 'on-demand' | 'always-on' }, + ) => { + subscribeCalls.push({ id, options }); + const previous = state.get(id); + const effectiveSyncMode = previous?.subscribed && previous.syncMode === 'always-on' + ? 'always-on' + : options?.syncMode ?? previous?.syncMode ?? 'always-on'; + const applied = { + ...previous, subscribed: true, - }); + synced: previous?.synced ?? false, + syncMode: effectiveSyncMode, + }; + state.set(id, applied); + return applied; }, markContextGraphSubscriptionState: (id: string, patch: Record) => { patches.push({ ...patch }); @@ -263,26 +285,30 @@ describe('context graph subscribe readiness requires authoritative metadata', () body: JSON.stringify({ contextGraphId, includeSharedMemory: opts.includeSharedMemory ?? true, + ...(opts.syncMode !== undefined ? { syncMode: opts.syncMode } : {}), }), }); const response = await httpResponse.json() as any; - const jobId = response.catchup.jobId as string; + const jobId = response.catchup?.jobId as string | undefined; - for (let i = 0; i < 50; i++) { + for (let i = 0; jobId && i < 50; i++) { if (catchupTracker.jobs.get(jobId)?.finishedAt) break; await new Promise((resolve) => setTimeout(resolve, 5)); } - const statusHttpResponse = await fetch( - `http://127.0.0.1:${address.port}/api/sync/catchup-status?jobId=${encodeURIComponent(jobId)}`, - ); - const statusResponse = await statusHttpResponse.json() as any; + const statusResponse = jobId + ? await fetch( + `http://127.0.0.1:${address.port}/api/sync/catchup-status?jobId=${encodeURIComponent(jobId)}`, + ).then((result) => result.json()) + : null; return { response, - job: catchupTracker.jobs.get(jobId), + responseStatus: httpResponse.status, + job: jobId ? catchupTracker.jobs.get(jobId) : undefined, runCalls, runRequests, + subscribeCalls, state: state.get(contextGraphId) ?? {}, patches, readiness, @@ -290,6 +316,61 @@ describe('context graph subscribe readiness requires authoritative metadata', () }; } + it('keeps omitted sync mode backward-compatible as always-on', async () => { + const result = await subscribe({ + hasConfirmedMeta: false, + }); + + expect(result.response.syncMode).toBe('always-on'); + expect(result.subscribeCalls).toEqual([ + { id: expect.any(String), options: { syncMode: 'always-on' } }, + ]); + expect(result.state.syncMode).toBe('always-on'); + }); + + it('forwards explicit on-demand edge intent without making it always-on', async () => { + const result = await subscribe({ + hasConfirmedMeta: false, + syncMode: 'on-demand', + }); + + expect(result.response.syncMode).toBe('on-demand'); + expect(result.subscribeCalls).toEqual([ + { id: expect.any(String), options: { syncMode: 'on-demand' } }, + ]); + expect(result.state.syncMode).toBe('on-demand'); + }); + + it('reports the agent-applied mode when an on-demand open cannot downgrade always-on', async () => { + const result = await subscribe({ + hasConfirmedMeta: false, + syncMode: 'on-demand', + initial: { + subscribed: true, + syncMode: 'always-on', + synced: false, + }, + }); + + expect(result.subscribeCalls).toEqual([ + { id: expect.any(String), options: { syncMode: 'on-demand' } }, + ]); + expect(result.response.syncMode).toBe('always-on'); + expect(result.state.syncMode).toBe('always-on'); + }); + + it('rejects unknown sync modes before changing subscription state', async () => { + const result = await subscribe({ + hasConfirmedMeta: false, + syncMode: 'sometimes', + }); + + expect(result.responseStatus).toBe(400); + expect(result.response.error).toContain('Invalid "syncMode"'); + expect(result.subscribeCalls).toEqual([]); + expect(result.runCalls).toBe(0); + }); + it('does not turn a clean empty response with no authoritative metadata into ready state', async () => { const result = await subscribe({ hasConfirmedMeta: false, diff --git a/packages/cli/test/daemon-catchup-telemetry-shutdown.test.ts b/packages/cli/test/daemon-catchup-telemetry-shutdown.test.ts index 6ee7196014..594505a0ba 100644 --- a/packages/cli/test/daemon-catchup-telemetry-shutdown.test.ts +++ b/packages/cli/test/daemon-catchup-telemetry-shutdown.test.ts @@ -218,14 +218,27 @@ async function createHarness(opts: HarnessOptions = {}) { jobs: new Map(), latestByContextGraph: new Map(), }; - const subscribeCalls: string[] = []; + const subscribeCalls: Array<{ + id: string; + syncMode: 'on-demand' | 'always-on'; + }> = []; const agent = { getContextGraphAllowedAgents: async () => opts.allowedAgents ?? [], getSubscribedContextGraphs: () => subscriptions, - subscribeToContextGraph: (id: string) => { - subscribeCalls.push(id); - subscriptions.set(id, { ...subscriptions.get(id), subscribed: true }); + subscribeToContextGraph: ( + id: string, + options?: { syncMode?: 'on-demand' | 'always-on' }, + ) => { + const previous = subscriptions.get(id); + const requestedSyncMode = options?.syncMode ?? 'always-on'; + const syncMode = previous?.subscribed === true && previous.syncMode === 'always-on' + ? 'always-on' + : requestedSyncMode; + subscribeCalls.push({ id, syncMode: requestedSyncMode }); + const applied = { ...previous, subscribed: true, syncMode }; + subscriptions.set(id, applied); + return applied; }, markContextGraphSubscriptionState: (id: string, patch: Record) => { subscriptions.set(id, { ...subscriptions.get(id), ...patch }); @@ -623,6 +636,40 @@ describe('A21 — a subscribe crossing shutdown never names a job that does not await harness.close(); } }); + + it('refuses an on-demand-to-always-on promotion while admission is closed', async () => { + daemonState.catchupRunner = createCatchupRunner({} as unknown as DKGAgent); + const harness = await createHarness({ + subscriptions: { + 'cg-running': { subscribed: true, syncMode: 'on-demand' }, + }, + }); + try { + const queued = await harness.subscribe({ + contextGraphId: 'cg-running', + syncMode: 'on-demand', + }); + await waitForDispatchedRun(); + expect(queued.body.syncMode).toBe('on-demand'); + + daemonState.catchupAcceptingJobs = false; + const rejectedPromotion = await harness.subscribe({ + contextGraphId: 'cg-running', + syncMode: 'always-on', + }); + + expect(rejectedPromotion.status).toBe(503); + expect(rejectedPromotion.body.code).toBe('CATCHUP_SHUTTING_DOWN'); + expect(harness.subscribeCalls).toEqual([ + { id: 'cg-running', syncMode: 'on-demand' }, + ]); + expect(harness.subscriptions.get('cg-running')?.syncMode).toBe('on-demand'); + expect(metrics.results()).toEqual(['queued', 'shutting_down']); + } finally { + await (daemonState.catchupRunner as ReturnType).close(); + await harness.close(); + } + }); }); describe('A23/A13 — the grace drain runs while the catch-up worker is alive', () => { diff --git a/packages/cli/test/daemon-context-graph-bootstrap.test.ts b/packages/cli/test/daemon-context-graph-bootstrap.test.ts index fe9583e47d..04cff4a1e1 100644 --- a/packages/cli/test/daemon-context-graph-bootstrap.test.ts +++ b/packages/cli/test/daemon-context-graph-bootstrap.test.ts @@ -84,6 +84,7 @@ describe('configured context graph daemon bootstrap', () => { expect(fixture.ensureContextGraphLocal).not.toHaveBeenCalled(); expect(fixture.subscribeToContextGraph).toHaveBeenCalledWith( '0x1234567890123456789012345678901234567890/private-cg', + { syncMode: 'always-on' }, ); expect( fixture.subscriptions.get('0x1234567890123456789012345678901234567890/private-cg'), @@ -148,6 +149,7 @@ describe('configured context graph daemon bootstrap', () => { expect(fixture.subscribeToContextGraph).toHaveBeenCalledWith( '0x1234567890123456789012345678901234567890/local-cg', + { syncMode: 'always-on' }, ); expect(fixture.markContextGraphSubscriptionState).not.toHaveBeenCalled(); expect( @@ -434,6 +436,7 @@ describe('configured context graph daemon bootstrap', () => { expect(fixture.subscribeToContextGraph).toHaveBeenCalledTimes(1); expect(fixture.subscribeToContextGraph).toHaveBeenCalledWith( '0x1234567890123456789012345678901234567890/remote-cg', + { syncMode: 'always-on' }, ); expect(fixture.subscriptions.has(SYSTEM_CONTEXT_GRAPHS.AGENTS)).toBe(false); expect(fixture.subscriptions.has(SYSTEM_CONTEXT_GRAPHS.ONTOLOGY)).toBe(false); diff --git a/packages/cli/test/daemon-http-behavior-extra.test.ts b/packages/cli/test/daemon-http-behavior-extra.test.ts index 951b3925e0..2a721bcd7e 100644 --- a/packages/cli/test/daemon-http-behavior-extra.test.ts +++ b/packages/cli/test/daemon-http-behavior-extra.test.ts @@ -1051,7 +1051,11 @@ describe('CLI-7 — SPARQL endpoint 4xx matrix', () => { const agent = { getContextGraphAllowedAgents: async () => [], getSubscribedContextGraphs: () => new Map(), - subscribeToContextGraph: () => {}, + subscribeToContextGraph: () => ({ + subscribed: true, + synced: false, + syncMode: 'always-on' as const, + }), contextGraphHasLocalContent: async () => false, markContextGraphSubscriptionState: () => { throw new Error('timeout-only catchup must not mark subscription synced'); @@ -1166,7 +1170,11 @@ describe('CLI-7 — SPARQL endpoint 4xx matrix', () => { const agent = { getContextGraphAllowedAgents: async () => [], getSubscribedContextGraphs: () => new Map(), - subscribeToContextGraph: () => {}, + subscribeToContextGraph: () => ({ + subscribed: true, + synced: false, + syncMode: 'always-on' as const, + }), contextGraphHasLocalContent: async () => true, markContextGraphSubscriptionState: () => { markedSynced = true; }, resolveAgentByToken: () => undefined, @@ -1239,6 +1247,136 @@ describe('CLI-7 — SPARQL endpoint 4xx matrix', () => { } }); + it('applies syncMode through the legacy /api/subscribe alias and reports the agent-normalized mode', async () => { + const contextGraphId = 'legacy-subscribe-mode-' + Math.random().toString(36).slice(2, 8); + const catchupTracker = { + jobs: new Map(), + latestByContextGraph: new Map(), + }; + let requestedMode: string | undefined; + let runnerCalls = 0; + const previousCatchupRunner = daemonState.catchupRunner; + daemonState.catchupRunner = { + run: async () => { + runnerCalls += 1; + return { + connectedPeers: 0, + syncCapablePeers: 0, + peersTried: 0, + peersResponded: 0, + peersSucceeded: 0, + deferredBackpressure: 1, + dataSynced: 0, + sharedMemorySynced: 0, + denied: false, + deniedPeers: 0, + }; + }, + close: async () => {}, + } as any; + let routeServer: Server | null = null; + + try { + routeServer = createServer(async (req, res) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + const agent = { + getContextGraphAllowedAgents: async () => [], + getSubscribedContextGraphs: () => new Map(), + subscribeToContextGraph: (_id: string, opts: { syncMode: string }) => { + requestedMode = opts.syncMode; + return { + subscribed: true, + synced: false, + // The route must report the normalized mode returned by the + // agent, rather than independently reconstructing it. + syncMode: 'always-on' as const, + }; + }, + markContextGraphSubscriptionState: () => {}, + resolveAgentByToken: () => undefined, + getDefaultAgentAddress: () => '0x0000000000000000000000000000000000000001', + }; + + await handleContextGraphRoutes({ + req, + res, + agent, + publisherControl: {}, + publisherRuntime: null, + config: {}, + startedAt: Date.now(), + dashDb: {}, + opWallets: {}, + network: {}, + tracker: {}, + memoryManager: {}, + bridgeAuthToken: undefined, + nodeVersion: 'test', + nodeCommit: 'test', + catchupTracker, + extractionRegistry: {}, + fileStore: {}, + extractionStatus: new Map(), + assertionImportLocks: new Map(), + vectorStore: {}, + embeddingProvider: null, + validTokens: new Set(), + apiHost: '127.0.0.1', + apiPortRef: { value: 0 }, + routePlugins: [], + url, + path: url.pathname, + requestToken: undefined, + requestAgentAddress: '0x0000000000000000000000000000000000000001', + } as any); + if (!res.writableEnded) { + res.statusCode = 404; + res.end(); + } + }); + + await new Promise((resolve) => routeServer!.listen(0, '127.0.0.1', resolve)); + const address = routeServer.address(); + if (!address || typeof address === 'string') { + throw new Error('legacy subscribe route test server did not bind to a TCP port'); + } + + const invalidResponse = await fetch(`http://127.0.0.1:${address.port}/api/subscribe`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ contextGraphId, syncMode: null }), + }); + expect(invalidResponse.status).toBe(400); + expect(await invalidResponse.json()).toMatchObject({ + error: 'Invalid "syncMode" (expected "on-demand" or "always-on")', + }); + expect(requestedMode).toBeUndefined(); + expect(runnerCalls).toBe(0); + expect(catchupTracker.jobs.size).toBe(0); + expect(catchupTracker.latestByContextGraph.size).toBe(0); + + const response = await fetch(`http://127.0.0.1:${address.port}/api/subscribe`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ contextGraphId, syncMode: 'on-demand' }), + }); + expect(response.status).toBe(200); + expect(requestedMode).toBe('on-demand'); + expect(await response.json()).toMatchObject({ + subscribed: contextGraphId, + syncMode: 'always-on', + catchup: { status: 'queued' }, + }); + } finally { + daemonState.catchupRunner = previousCatchupRunner; + if (routeServer) { + await new Promise((resolve, reject) => { + routeServer!.close((err) => (err ? reject(err) : resolve())); + }); + } + } + }); + // SPEC_CG_MEMORY_MODEL / Codex PR #595 round-4: per-CG hosting // committees and per-CG quorum overrides were removed end-to-end. // The on-chain contract no longer accepts those args, so silently @@ -2300,6 +2438,11 @@ describe('#1596 — subscribe allowlist gate respects explicit public accessPoli getSubscribedContextGraphs: () => new Map(), subscribeToContextGraph: () => { subscribeCalled = true; + return { + subscribed: true, + synced: false, + syncMode: 'always-on' as const, + }; }, contextGraphHasLocalContent: async () => false, markContextGraphSubscriptionState: () => {}, diff --git a/packages/cli/test/knowledge-subscribe-command.test.ts b/packages/cli/test/knowledge-subscribe-command.test.ts new file mode 100644 index 0000000000..52c865ad84 --- /dev/null +++ b/packages/cli/test/knowledge-subscribe-command.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Command } from 'commander'; + +const configMocks = vi.hoisted(() => ({ + loadConfig: vi.fn(async () => ({ contextGraphs: [] as string[] })), + saveConfig: vi.fn(async () => undefined), + resolveContextGraphs: vi.fn((config: { contextGraphs?: string[] }) => config.contextGraphs ?? []), +})); + +vi.mock('../src/config.js', async (importOriginal) => ({ + ...await importOriginal(), + loadConfig: configMocks.loadConfig, + saveConfig: configMocks.saveConfig, + resolveContextGraphs: configMocks.resolveContextGraphs, +})); + +import { ApiClient } from '../src/api-client.js'; +import { registerKnowledgeCommands } from '../src/commands/knowledge.js'; + +function commandProgram(): Command { + const program = new Command().name('dkg'); + program.exitOverride(); + registerKnowledgeCommands(program); + return program; +} + +describe('knowledge subscribe CLI sync lifetime', () => { + const logLines: string[] = []; + + beforeEach(() => { + logLines.length = 0; + configMocks.loadConfig.mockClear(); + configMocks.saveConfig.mockClear(); + configMocks.resolveContextGraphs.mockClear(); + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logLines.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('requests process-local on-demand synchronization by default', async () => { + const subscribeToContextGraph = vi.fn().mockResolvedValue({ + subscribed: 'selected-cg', + syncMode: 'on-demand', + }); + vi.spyOn(ApiClient, 'connect').mockResolvedValue({ subscribeToContextGraph } as unknown as ApiClient); + + await commandProgram().parseAsync(['node', 'dkg', 'subscribe', 'selected-cg']); + + expect(subscribeToContextGraph).toHaveBeenCalledWith('selected-cg', { + syncMode: 'on-demand', + }); + expect(configMocks.saveConfig).not.toHaveBeenCalled(); + expect(logLines.join('\n')).toContain('Synchronization mode: on demand'); + }); + + it('requests restart-durable synchronization with --save', async () => { + const subscribeToContextGraph = vi.fn().mockResolvedValue({ + subscribed: 'selected-cg', + syncMode: 'always-on', + }); + vi.spyOn(ApiClient, 'connect').mockResolvedValue({ subscribeToContextGraph } as unknown as ApiClient); + + await commandProgram().parseAsync(['node', 'dkg', 'subscribe', 'selected-cg', '--save']); + + expect(subscribeToContextGraph).toHaveBeenCalledWith('selected-cg', { + syncMode: 'always-on', + }); + expect(configMocks.saveConfig).toHaveBeenCalledWith(expect.objectContaining({ + contextGraphs: ['selected-cg'], + })); + expect(logLines.join('\n')).toContain('Synchronization mode: always on'); + }); + + it('reports the server-normalized mode when an on-demand request stays always-on', async () => { + const subscribeToContextGraph = vi.fn().mockResolvedValue({ + subscribed: 'selected-cg', + syncMode: 'always-on', + }); + vi.spyOn(ApiClient, 'connect').mockResolvedValue({ subscribeToContextGraph } as unknown as ApiClient); + + await commandProgram().parseAsync(['node', 'dkg', 'subscribe', 'selected-cg']); + + expect(subscribeToContextGraph).toHaveBeenCalledWith('selected-cg', { + syncMode: 'on-demand', + }); + expect(configMocks.saveConfig).not.toHaveBeenCalled(); + expect(logLines.join('\n')).toContain('Synchronization mode: always on'); + expect(logLines.join('\n')).not.toContain('Synchronization mode: on demand'); + }); +}); diff --git a/packages/node-ui/src/ui/api.ts b/packages/node-ui/src/ui/api.ts index f2057bf6be..520c60479d 100644 --- a/packages/node-ui/src/ui/api.ts +++ b/packages/node-ui/src/ui/api.ts @@ -2861,8 +2861,18 @@ export const shutdownNode = () => post<{ ok: boolean }>('/api/shutdown', {}); // --- Integrations --- -export const subscribeToContextGraph = (contextGraphId: string) => - post<{ subscribed: string; catchup?: { status: string; jobId: string } }>('/api/subscribe', { contextGraphId }); +export const subscribeToContextGraph = ( + contextGraphId: string, + options?: { syncMode?: 'on-demand' | 'always-on' }, +) => + post<{ + subscribed: string; + syncMode: 'on-demand' | 'always-on'; + catchup?: { status: string; jobId: string }; + }>('/api/subscribe', { + contextGraphId, + syncMode: options?.syncMode ?? 'on-demand', + }); // --- Notifications (scoped pane wire contract — implementation-plan §3) --- // diff --git a/packages/node-ui/src/ui/components/Modals/JoinProjectModal.tsx b/packages/node-ui/src/ui/components/Modals/JoinProjectModal.tsx index 33fd71c301..bf6e8c88d2 100644 --- a/packages/node-ui/src/ui/components/Modals/JoinProjectModal.tsx +++ b/packages/node-ui/src/ui/components/Modals/JoinProjectModal.tsx @@ -325,6 +325,7 @@ export function JoinProjectModal({ open, onClose, initialContextGraphId }: JoinP const [phase, setPhase] = useState('idle'); const [error, setError] = useState(null); const [pendingCgId, setPendingCgId] = useState(null); + const [keepSynced, setKeepSynced] = useState(false); // Phase 8: after approval we transition into a wire-workspace step so // the joiner can populate a local Cursor workspace from the project's // manifest. `wiredCgId` flips the modal into the WireWorkspacePanel; @@ -345,6 +346,7 @@ export function JoinProjectModal({ open, onClose, initialContextGraphId }: JoinP setError(null); setPhase('idle'); setPendingCgId(null); + setKeepSynced(false); } }, [open, initialContextGraphId]); @@ -436,7 +438,9 @@ export function JoinProjectModal({ open, onClose, initialContextGraphId }: JoinP // connection before the daemon snapshots its preferred/fallback peer // cohort for the catch-up job. Dial failure remains best-effort. await warmInviteCuratorConnection(intent.invite); - await subscribeToContextGraph(cgId); + await subscribeToContextGraph(cgId, { + syncMode: keepSynced ? 'always-on' : 'on-demand', + }); await refreshAndOpenContextGraph(cgId, existing); onClose(); } catch (err: unknown) { @@ -636,6 +640,27 @@ export function JoinProjectModal({ open, onClose, initialContextGraphId }: JoinP spellCheck={false} /> + + {intent.kind === 'publicSubscribe' && ( + + )}
diff --git a/packages/node-ui/test/join-project-modal.interaction.test.ts b/packages/node-ui/test/join-project-modal.interaction.test.ts index 3a61ef0e74..6207724147 100644 --- a/packages/node-ui/test/join-project-modal.interaction.test.ts +++ b/packages/node-ui/test/join-project-modal.interaction.test.ts @@ -83,6 +83,7 @@ describe('JoinProjectModal public subscription interaction', () => { subscribeToContextGraphMock.mockResolvedValue({ subscribed: 'open-project', + syncMode: 'on-demand', catchup: { status: 'queued', jobId: 'catchup-1' }, }); fetchContextGraphsMock.mockResolvedValue({ @@ -148,7 +149,9 @@ describe('JoinProjectModal public subscription interaction', () => { await act(async () => { primary.click(); }); await flush(); - expect(subscribeToContextGraphMock).toHaveBeenCalledWith('open-project'); + expect(subscribeToContextGraphMock).toHaveBeenCalledWith('open-project', { + syncMode: 'on-demand', + }); expect(fetchContextGraphsMock).toHaveBeenCalled(); expect(signJoinRequestMock).not.toHaveBeenCalled(); expect(submitJoinRequestMock).not.toHaveBeenCalled(); @@ -167,11 +170,30 @@ describe('JoinProjectModal public subscription interaction', () => { await flush(); expect(connectToPeerIdWithTimeoutMock).toHaveBeenCalledWith(curatorPeerId); - expect(subscribeToContextGraphMock).toHaveBeenCalledWith('open-project'); + expect(subscribeToContextGraphMock).toHaveBeenCalledWith('open-project', { + syncMode: 'on-demand', + }); expect(connectToPeerIdWithTimeoutMock.mock.invocationCallOrder[0]).toBeLessThan( subscribeToContextGraphMock.mock.invocationCallOrder[0], ); expect(signJoinRequestMock).not.toHaveBeenCalled(); expect(submitJoinRequestMock).not.toHaveBeenCalled(); }); + + it('makes restart-durable synchronization an explicit public-graph choice', async () => { + const { container } = await renderModal('open-project'); + const keepSynced = container.querySelector( + 'input[aria-label="Keep this Context Graph synchronized after restart"]', + ) as HTMLInputElement; + expect(keepSynced.checked).toBe(false); + + await act(async () => { keepSynced.click(); }); + const primary = container.querySelector('.v10-modal-btn.primary') as HTMLButtonElement; + await act(async () => { primary.click(); }); + await flush(); + + expect(subscribeToContextGraphMock).toHaveBeenCalledWith('open-project', { + syncMode: 'always-on', + }); + }); }); diff --git a/packages/node-ui/test/ui-api-pure.test.ts b/packages/node-ui/test/ui-api-pure.test.ts index 0a00cf73a1..237a62a8df 100644 --- a/packages/node-ui/test/ui-api-pure.test.ts +++ b/packages/node-ui/test/ui-api-pure.test.ts @@ -750,6 +750,14 @@ describe('UI API tests', () => { const call = requestLog.find(r => r.method === 'POST' && r.url.includes('/api/subscribe')); const body = JSON.parse(call?.body ?? '{}'); expect(body.contextGraphId).toBe('cg-1'); + expect(body.syncMode).toBe('on-demand'); + }); + + it('subscribeToContextGraph forwards an explicit always-on choice', async () => { + await subscribeToContextGraph('cg-1', { syncMode: 'always-on' }); + const call = requestLog.find(r => r.method === 'POST' && r.url.includes('/api/subscribe')); + const body = JSON.parse(call?.body ?? '{}'); + expect(body).toEqual({ contextGraphId: 'cg-1', syncMode: 'always-on' }); }); });