Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/agent/src/dkg-agent-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,7 @@ type ContextGraphDiscoveryDisposition =
interface PeerSyncScope {
readonly effectiveBatchSize: number;
readonly automaticContextGraphIds: readonly string[];
readonly initialBootstrapContextGraphIds: readonly string[];
readonly initialDurableContextGraphIds: readonly string[];
contextGraphIdsAfterDiscovery(): string[];
}
Expand Down Expand Up @@ -1758,6 +1759,10 @@ export class DKGAgentBase {
return {
effectiveBatchSize,
automaticContextGraphIds,
initialBootstrapContextGraphIds: [
SYSTEM_CONTEXT_GRAPHS.AGENTS,
SYSTEM_CONTEXT_GRAPHS.ONTOLOGY,
],
initialDurableContextGraphIds,
contextGraphIdsAfterDiscovery: () => [...new Set([
...(this.config.syncContextGraphs ?? []),
Expand Down
16 changes: 7 additions & 9 deletions packages/agent/src/dkg-agent-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -935,13 +935,13 @@ type SyncReconcilerAttemptOutcome = SyncOnConnectOutcome | 'not-started' | 'defe
interface LifecycleSyncScopePlan {
readonly effectiveBatchSize: number;
readonly automaticContextGraphIds: readonly string[];
readonly initialBootstrapContextGraphIds: readonly string[];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Make the peer-round scope contract canonical instead of cloning it across layers

What's wrong
This change improves the boolean flag by making bootstrap graphs explicit, but it does so by adding the same new concept to multiple duplicated scope interfaces. That spreads the invariant across layers instead of making the scope model the canonical abstraction, which makes the next scope change harder to reason about and easier to implement inconsistently.

Example
A future change that adds another peer-round scope dimension would need to update PeerSyncScope, LifecycleSyncScopePlan, SyncOnConnectScopePlan, and the legacy adapter shape separately. Missing one would still compile in some paths because these are structurally typed aliases, leaving the drift to show up as control-flow special casing later.

Suggested direction
Define the durable/on-connect scope shape once, then have lifecycle/base plans compose or extend that shape for their extra evidence fields. The legacy adapter can still normalize optional/deprecated input into the canonical plan at the boundary, but the main orchestration should not carry several near-identical interfaces.

Confidence note
This is a structural maintainability finding based on the changed scope model; I did not have an origin/main ref locally, so the before/after comparison uses the supplied PR diff plus surrounding file reads.

For Agents
Look at packages/agent/src/sync/on-connect/sync-on-connect.ts, packages/agent/src/dkg-agent-base.ts, and packages/agent/src/dkg-agent-lifecycle.ts. Preserve the current behavior: normal sync includes Agents/Ontology in the first durable request, periodic Edge scoped resume can pass an empty bootstrap list, and the legacy adapter keeps its compatibility default. Extract one canonical peer-round scope type or a small shared base type plus lifecycle-only evidence fields, and centralize the default bootstrap graph constant so adding scope fields is not a multi-file structural edit.

readonly initialDurableContextGraphIds: readonly string[];
contextGraphIdsAfterDiscovery(): string[];
}

interface LifecycleSyncInvocationPolicy {
readonly canStart: boolean;
readonly includeSystemContextGraphs: boolean;
readonly syncSharedMemory: boolean;
buildScopePlan(defaultPlan: LifecycleSyncScopePlan): LifecycleSyncScopePlan;
requestedSharedMemoryContextGraphIds(
Expand All @@ -964,19 +964,19 @@ function createLifecycleSyncInvocationPolicy(input: {
readonly trigger: SyncCoverageEvidenceTrigger | undefined;
getLiveRehydratedAlwaysOnContextGraphIds(): string[];
}): LifecycleSyncInvocationPolicy {
const periodicEdgeRehydration = input.nodeRole === 'edge'
&& input.trigger === 'periodic-reconciler';
if (periodicEdgeRehydration) {
const periodicEdgeScopedResume = input.nodeRole === 'edge'
&& input.trigger === 'periodic-reconciler'
&& !input.syncOnConnect;
if (periodicEdgeScopedResume) {
return {
canStart: input.syncOnConnect
|| input.initialRehydratedAlwaysOnContextGraphIds.length > 0,
includeSystemContextGraphs: false,
canStart: input.initialRehydratedAlwaysOnContextGraphIds.length > 0,
syncSharedMemory: true,
buildScopePlan: (defaultPlan) => {
const live = input.getLiveRehydratedAlwaysOnContextGraphIds();
return {
effectiveBatchSize: Math.min(defaultPlan.effectiveBatchSize, live.length),
automaticContextGraphIds: [],
initialBootstrapContextGraphIds: [],
initialDurableContextGraphIds: [...live],
contextGraphIdsAfterDiscovery: input.getLiveRehydratedAlwaysOnContextGraphIds,
};
Expand All @@ -996,7 +996,6 @@ function createLifecycleSyncInvocationPolicy(input: {
}
return {
canStart: input.syncOnConnect,
includeSystemContextGraphs: true,
syncSharedMemory: input.syncOnConnect && input.syncSharedMemoryOnConnect,
buildScopePlan: (defaultPlan) => defaultPlan,
requestedSharedMemoryContextGraphIds: (contextGraphIdsAfterDiscovery) =>
Expand Down Expand Up @@ -4092,7 +4091,6 @@ export class LifecycleSyncMethods extends DKGAgentBase {
return result;
},
syncSharedMemoryOnConnect: invocationPolicy.syncSharedMemory,
includeSystemContextGraphs: invocationPolicy.includeSystemContextGraphs,
logInfo: (ctx, message) => this.log.info(ctx, message),
onPeerSkippedNoSync: (peerId) => {
this.skippedNoSyncPeers.add(peerId);
Expand Down
57 changes: 37 additions & 20 deletions packages/agent/src/sync/on-connect/sync-on-connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ type SyncProgressSummary = DurableProgressSummary & { insertedTriples: number };
type SyncFromPeerResult = number | SyncProgressSummary;

export interface SyncOnConnectScopePlan {
/** Bootstrap graphs frozen into the first durable request. */
initialBootstrapContextGraphIds: readonly string[];
/** Explicit plus automatic CGs frozen for the first durable request. */
initialDurableContextGraphIds: readonly string[];
/** Explicit intent re-read after discovery, merged with the frozen automatic tail. */
Expand All @@ -35,12 +37,6 @@ interface SyncOnConnectCommonContext {
discoverContextGraphsFromStore: () => Promise<number>;
syncSharedMemoryFromPeer: (peerId: string, contextGraphIds: string[]) => Promise<SyncFromPeerResult>;
syncSharedMemoryOnConnect?: boolean;
/**
* Keep the legacy Agents/Ontology bootstrap in the durable request by
* default. A narrowly scoped Edge reconciler may disable it when resuming
* only persisted, explicit always-on subscriptions.
*/
includeSystemContextGraphs?: boolean;
logInfo: (ctx: OperationContext, message: string) => void;
/**
* Optional. Called when the peer is reachable but does not currently
Expand All @@ -65,11 +61,27 @@ interface SyncOnConnectCommonContext {
onPeerSynced?: (peerId: string, outcome?: SyncOnConnectPeerOutcome) => void;
}

interface LegacySyncOnConnectScopePlan {
/**
* New callers should make the bootstrap scope explicit. This stays optional
* only at the legacy adapter boundary so existing deep imports keep their
* historical system-graph default.
*/
initialBootstrapContextGraphIds?: readonly string[];
initialDurableContextGraphIds: readonly string[];
contextGraphIdsAfterDiscovery: () => string[];
}

interface SyncOnConnectContext extends SyncOnConnectCommonContext {
/** Legacy dynamic scope callback retained at the compatibility boundary. */
getSyncContextGraphs?: () => string[];
/** Legacy two-phase scope retained at the compatibility boundary. */
contextGraphScope?: SyncOnConnectScopePlan;
contextGraphScope?: LegacySyncOnConnectScopePlan;
/**
* @deprecated Use contextGraphScope.initialBootstrapContextGraphIds. Kept so
* existing deep-import callers can still opt out of Agents/Ontology.
*/
includeSystemContextGraphs?: boolean;
}

interface PlannedSyncOnConnectContext extends SyncOnConnectCommonContext {
Expand Down Expand Up @@ -138,15 +150,25 @@ export function runSyncOnConnect(context: SyncOnConnectContext): Promise<SyncOnC
const {
getSyncContextGraphs = () => [],
contextGraphScope,
includeSystemContextGraphs = true,
syncFromPeer,
...common
} = context;
const defaultBootstrapContextGraphIds = includeSystemContextGraphs
? [SYSTEM_CONTEXT_GRAPHS.AGENTS, SYSTEM_CONTEXT_GRAPHS.ONTOLOGY]
: [];
let initialCall = true;
return runSyncOnConnectWithScopePlan({
...common,
createScopePlan: () => contextGraphScope ?? (() => {
createScopePlan: () => contextGraphScope ? {
initialBootstrapContextGraphIds: contextGraphScope.initialBootstrapContextGraphIds
?? defaultBootstrapContextGraphIds,
initialDurableContextGraphIds: contextGraphScope.initialDurableContextGraphIds,
contextGraphIdsAfterDiscovery: contextGraphScope.contextGraphIdsAfterDiscovery,
} : (() => {
const initialDurableContextGraphIds = [...(getSyncContextGraphs() ?? [])];
return {
initialBootstrapContextGraphIds: defaultBootstrapContextGraphIds,
initialDurableContextGraphIds,
contextGraphIdsAfterDiscovery: () => getSyncContextGraphs() ?? [],
};
Expand Down Expand Up @@ -181,7 +203,6 @@ export async function runSyncOnConnectWithScopePlan(
discoverContextGraphsFromStore,
syncSharedMemoryFromPeer,
syncSharedMemoryOnConnect = true,
includeSystemContextGraphs = true,
logInfo,
} = context;

Expand Down Expand Up @@ -284,18 +305,17 @@ export async function runSyncOnConnectWithScopePlan(
}

const scopePlan = createScopePlan();
const bootstrapContextGraphIds = scopePlan.initialBootstrapContextGraphIds;
const initialDurableContextGraphIds = [...scopePlan.initialDurableContextGraphIds];
const systemContextGraphIds = includeSystemContextGraphs
? [SYSTEM_CONTEXT_GRAPHS.AGENTS, SYSTEM_CONTEXT_GRAPHS.ONTOLOGY]
: [];
const initialSyncContextGraphIds = [...new Set([
...bootstrapContextGraphIds,
...initialDurableContextGraphIds,
])];
logInfo(ctx, `Syncing from peer ${shortPeer}...`);
const knownCgsBefore = new Set(initialDurableContextGraphIds);
const synced = await syncFromPeer(
remotePeer,
[
...systemContextGraphIds,
...initialDurableContextGraphIds,
],
initialSyncContextGraphIds,
);
const syncedAccounting = recordSyncAccounting(synced, 'durable');
logInfo(ctx, `Synced ${syncedAccounting.insertedTriples} data triples from peer ${shortPeer}`);
Expand All @@ -308,10 +328,7 @@ export async function runSyncOnConnectWithScopePlan(
return finishSyncAccounting();
}

const syncScope = new Set<string>([
...systemContextGraphIds,
...initialDurableContextGraphIds,
]);
const syncScope = new Set<string>(initialSyncContextGraphIds);
await runNonTransportStep(() => refreshMetaSyncedFlags(syncScope));

await runNonTransportStep(() => discoverContextGraphsFromStore());
Expand Down
177 changes: 177 additions & 0 deletions packages/agent/test/sync-coverage-evidence-edge-periodic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { describe, expect, it } from 'vitest';
import { PROTOCOL_SYNC, SYSTEM_CONTEXT_GRAPHS } from '@origintrail-official/dkg-core';
import { MockChainAdapter } from '@origintrail-official/dkg-chain';
import {
DKGAgent,
type ContextGraphSubscriptionRecord,
type ContextGraphSubscriptionStore,
} from '../src/index.js';

const PEER = '12D3KooWSmU3owJvB9sFw8uApDgKrv2VBMecsGGvgAc4Gq6hB57M';

async function waitFor(predicate: () => boolean): Promise<void> {
for (let attempt = 0; attempt < 100; attempt += 1) {
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 0));
}
throw new Error('condition did not become true');
}

function cleanDurableSyncResult() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Avoid duplicating the coverage-evidence agent test harness

What's wrong
The new file copies a large, fragile setup pattern rather than reusing the existing coverage-evidence harness. These fixtures contain many detailed counter fields and private as any stubs, so duplicating them increases the maintenance surface for every future sync-result or agent-lifecycle test change.

Example
If a durable progress summary field is added or renamed, both sync-coverage-evidence-runtime.test.ts and this new file now need the same long fixture object updated. The same applies to the private-agent stubbing around rehydrateContextGraphSubscriptions, planSharedMemorySyncContextGraphs, and detailed sync methods.

Suggested direction
Extract the common evidence-agent fixtures/result factories into a test helper for this sync coverage area, or fold this scenario into the existing runtime coverage-evidence suite. The new test should only supply the differing config and assertions.

For Agents
Look at packages/agent/test/sync-coverage-evidence-runtime.test.ts and this new periodic test. Preserve the asserted durable/shared-memory scopes. Move the shared clean result factories and evidence-agent setup into a small local test helper, or place this scenario in the existing coverage-evidence runtime suite so it can reuse the current helpers. Keep the test assertion focused on the new periodic Edge scope behavior.

return {
insertedTriples: 0,
insertedDataTriples: 0,
insertedMetaTriples: 0,
fetchedDataTriples: 0,
fetchedMetaTriples: 0,
bytesReceived: 0,
resumedPhases: 0,
timedOutPhases: 0,
completedPhases: 10,
checkpointAdvances: 0,
emptyResponses: 1,
metaOnlyResponses: 0,
verifiedPrivateOnlyResponses: 0,
dataRejectedMissingMeta: 0,
rejectedKcs: 0,
failedPeers: 0,
failedPhases: 0,
deniedPhases: 0,
backoffWorthyFailures: 0,
deferredBackpressure: 0,
complete: true,
};
}

function cleanSharedMemorySyncResult() {
return {
insertedTriples: 0,
insertedDataTriples: 0,
insertedMetaTriples: 0,
fetchedDataTriples: 0,
fetchedMetaTriples: 0,
bytesReceived: 0,
resumedPhases: 0,
timedOutPhases: 0,
completedPhases: 1,
checkpointAdvances: 0,
emptyResponses: 1,
droppedDataTriples: 0,
failedPeers: 0,
failedPhases: 0,
deniedPhases: 0,
backoffWorthyFailures: 0,
deferredBackpressure: 0,
};
}

async function createRehydratedEdgeEvidenceAgent(contextGraphId: string): Promise<DKGAgent> {
const persisted = new Map<string, ContextGraphSubscriptionRecord>([[contextGraphId, {
id: contextGraphId,
subscribed: true,
synced: false,
sharedMemorySynced: false,
metaSynced: false,
syncAdmission: 'explicit',
syncScoped: true,
}]]);
const contextGraphSubscriptionStore: ContextGraphSubscriptionStore = {
loadAll: async () => [...persisted.values()],
save: async (record) => { persisted.set(record.id, { ...record }); },
delete: async (id) => { persisted.delete(id); },
};
const agent = await DKGAgent.create({
name: 'SyncEvidenceEdgePeriodic',
listenHost: '127.0.0.1',
nodeRole: 'edge',
syncContextGraphs: [],
chainAdapter: new MockChainAdapter(),
contextGraphSubscriptionStore,
});
(agent as any).started = true;
(agent as any).networkAdmissionCoordinator.isAcceptedPeer = () => true;
(agent as any).getPeerProtocols = async () => [PROTOCOL_SYNC];
(agent as any).discoverContextGraphsFromStore = async () => 0;
(agent as any).planSharedMemorySyncContextGraphs = async (
_peerId: string,
contextGraphIds: string[],
) => ({
publicContextGraphIds: [...contextGraphIds],
privateRecoverFromCurator: [],
eligibleContextGraphIds: [...contextGraphIds],
});
(agent as any).refreshMetaSyncedFlags = async () => new Set<string>();
(agent as any).hasConfirmedMetaState = async () => true;
(agent as any).gossip = {
subscribe: () => undefined,
unsubscribe: () => undefined,
onMessage: () => undefined,
offMessage: () => undefined,
};
(agent.node as any).node = {
peerId: { toString: () => '12D3KooWLocalEvidencePeer' },
};
await (agent as any).rehydrateContextGraphSubscriptions();
(agent.node as any).node = {
getPeers: () => [{ toString: () => PEER }],
getConnections: () => [],
};
(agent as any).getSyncReconcilerProbe = async () => ({
protocolsKey: PROTOCOL_SYNC,
connectionKey: PEER,
});
return agent;
}

describe('Edge periodic sync scope evidence', () => {
it('keeps the normal Edge periodic scope when broad sync-on-connect is enabled', async () => {
const rehydrated = 'cg-rehydrated-normal-periodic';
const runtimeSelected = 'cg-runtime-normal-periodic';
const agent = await createRehydratedEdgeEvidenceAgent(rehydrated);
(agent as any).config.syncOnConnectEnabled = true;
(agent as any).config.syncSharedMemoryOnConnect = true;
(agent as any).config.syncContextGraphs.push(runtimeSelected);
(agent as any).subscribedContextGraphs.set(runtimeSelected, {
subscribed: true,
syncMode: 'always-on',
syncAdmission: 'explicit',
metaSynced: false,
});
const durableScopes: string[][] = [];
const sharedMemoryScopes: string[][] = [];
(agent as any).syncFromPeerDetailed = async (
_peerId: string,
contextGraphIds: string[],
) => {
durableScopes.push([...contextGraphIds]);
return cleanDurableSyncResult();
};
(agent as any).syncSharedMemoryFromPeerDetailed = async (
_peerId: string,
contextGraphIds: string[],
) => {
sharedMemoryScopes.push([...contextGraphIds]);
const summary = cleanSharedMemorySyncResult();
return {
...summary,
contextGraphTerminals: contextGraphIds.map((id) => ({
contextGraphId: id,
lane: 'shared_memory' as const,
disposition: 'settled' as const,
result: { ...summary },
})),
};
};

await (agent as any).reconcileSyncFromConnectedPeers();
await waitFor(() => (agent as any).lastSuccessfulSyncAt.has(PEER));

expect(durableScopes).toEqual([[
SYSTEM_CONTEXT_GRAPHS.AGENTS,
SYSTEM_CONTEXT_GRAPHS.ONTOLOGY,
rehydrated,
runtimeSelected,
]]);
expect(sharedMemoryScopes).toEqual([[rehydrated, runtimeSelected]]);
});
});
Loading
Loading