Skip to content

Commit 47fe0b4

Browse files
authored
feat(runtime): admit cross-epoch graph results (#2992)
- persist typed selected_result_inputs with the source graph identity for scheduled work - admit only committed results selected by a finished earlier epoch of the same root Session - revalidate historical inputs during recovery and hydrate them with explicit graph provenance - page historical result discovery without reusing old operators, claims, routes, or control state Historical results cross the epoch boundary as immutable data only. Runtime Host authorization verifies the root, epoch ordering, finished schedule selection, and exact RuntimeEvent-backed record before the schedule is committed; recovery performs the same resolution again. Input counts and discovery pages are bounded, and ambiguous current/historical record identities fail closed. Generated-by: Maka
1 parent 47ea767 commit 47fe0b4

42 files changed

Lines changed: 3046 additions & 180 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/desktop/src/main/__tests__/agent-graph-panel.test.ts

Lines changed: 215 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,33 @@ import { parseHTML } from 'linkedom';
44
import { act, createElement } from 'react';
55
import { createRoot, type Root } from 'react-dom/client';
66
import type { AgentGraphClientSnapshot } from '@maka/runtime/stream-graph-read-model';
7+
import type { AgentGraphEpochSummary } from '@maka/runtime-host/protocol';
78
import { AgentGraphPanel } from '../../renderer/agent-graph-panel.js';
89

910
type GraphListener = () => void;
1011

12+
interface DeferredRead {
13+
started: Promise<void>;
14+
release(): void;
15+
}
16+
17+
interface DeferredReadGate extends DeferredRead {
18+
markStarted(): void;
19+
waitForRelease: Promise<void>;
20+
}
21+
22+
function deferredReadGate(): DeferredReadGate {
23+
let markStarted = () => {};
24+
let release = () => {};
25+
const started = new Promise<void>((resolve) => {
26+
markStarted = resolve;
27+
});
28+
const waitForRelease = new Promise<void>((resolve) => {
29+
release = resolve;
30+
});
31+
return { started, markStarted, release, waitForRelease };
32+
}
33+
1134
const originalGlobals = {
1235
document: globalThis.document,
1336
window: globalThis.window,
@@ -59,11 +82,18 @@ function snapshot(
5982
};
6083
}
6184

62-
function installGraphRenderer(initial: AgentGraphClientSnapshot): {
85+
function installGraphRenderer(
86+
initial: AgentGraphClientSnapshot,
87+
historical: readonly AgentGraphClientSnapshot[] = [],
88+
): {
6389
container: Element;
6490
root: Root;
6591
setSnapshot(next: AgentGraphClientSnapshot): Promise<void>;
92+
evict(graphId: string): void;
6693
renderSession(sessionId: string): Promise<void>;
94+
holdNextEpochList(sessionId: string): DeferredRead;
95+
holdNextSnapshot(graphId: string): DeferredRead;
96+
stopCalls: string[];
6797
} {
6898
const { document, window } = parseHTML('<div id="root"></div>');
6999
const matchMedia = (media: string) => ({
@@ -88,15 +118,54 @@ function installGraphRenderer(initial: AgentGraphClientSnapshot): {
88118
IS_REACT_ACT_ENVIRONMENT: true,
89119
});
90120

91-
const snapshots = new Map<string, AgentGraphClientSnapshot>([
92-
[initial.rootSessionId, initial],
93-
]);
121+
const snapshots = new Map(
122+
[initial, ...historical].map((entry) => [entry.graphId, entry] as const),
123+
);
124+
const currentGraphIds = new Map<string, string>();
125+
for (const entry of [initial, ...historical]) {
126+
if (!currentGraphIds.has(entry.rootSessionId)) {
127+
currentGraphIds.set(entry.rootSessionId, entry.graphId);
128+
}
129+
}
94130
const listeners = new Set<GraphListener>();
131+
const epochListGates = new Map<string, DeferredReadGate>();
132+
const snapshotGates = new Map<string, DeferredReadGate>();
133+
const stopCalls: string[] = [];
95134
(window as unknown as { maka: unknown }).maka = {
96135
graphs: {
97-
getSnapshot: async (sessionId: string) => {
98-
const next = snapshots.get(sessionId);
99-
if (!next) throw new Error(`missing graph snapshot for ${sessionId}`);
136+
listEpochs: async (sessionId: string) => {
137+
const gate = epochListGates.get(sessionId);
138+
if (gate) {
139+
epochListGates.delete(sessionId);
140+
gate.markStarted();
141+
await gate.waitForRelease;
142+
}
143+
const currentGraphId = currentGraphIds.get(sessionId);
144+
const entries = [...snapshots.values()]
145+
.filter((entry) => entry.rootSessionId === sessionId)
146+
.sort((left, right) => Number(right.graphId === currentGraphId) - Number(left.graphId === currentGraphId));
147+
return {
148+
epochs: entries.map((entry, index) => ({
149+
epoch: entries.length - index,
150+
graphId: entry.graphId,
151+
createdAt: index + 1,
152+
current: currentGraphIds.get(sessionId) === entry.graphId,
153+
})),
154+
truncated: false,
155+
};
156+
},
157+
getSnapshot: async (sessionId: string, options?: { graphId?: string }) => {
158+
const graphId = options?.graphId ?? currentGraphIds.get(sessionId);
159+
const gate = graphId ? snapshotGates.get(graphId) : undefined;
160+
if (gate && graphId) {
161+
snapshotGates.delete(graphId);
162+
gate.markStarted();
163+
await gate.waitForRelease;
164+
}
165+
const next = graphId ? snapshots.get(graphId) : undefined;
166+
if (!next || next.rootSessionId !== sessionId) {
167+
throw new Error(`missing graph snapshot for ${sessionId}`);
168+
}
100169
return next;
101170
},
102171
inspectOperator: async () => {
@@ -108,7 +177,9 @@ function installGraphRenderer(initial: AgentGraphClientSnapshot): {
108177
listeners.delete(listener);
109178
};
110179
},
111-
stop: async () => undefined,
180+
stop: async (sessionId: string) => {
181+
stopCalls.push(sessionId);
182+
},
112183
},
113184
};
114185

@@ -119,12 +190,16 @@ function installGraphRenderer(initial: AgentGraphClientSnapshot): {
119190
container,
120191
root,
121192
async setSnapshot(next) {
122-
snapshots.set(next.rootSessionId, next);
193+
snapshots.set(next.graphId, next);
194+
currentGraphIds.set(next.rootSessionId, next.graphId);
123195
await act(async () => {
124196
for (const listener of [...listeners]) listener();
125197
await Promise.resolve();
126198
});
127199
},
200+
evict(graphId) {
201+
snapshots.delete(graphId);
202+
},
128203
async renderSession(sessionId) {
129204
await act(async () => {
130205
root.render(
@@ -138,6 +213,17 @@ function installGraphRenderer(initial: AgentGraphClientSnapshot): {
138213
await Promise.resolve();
139214
});
140215
},
216+
holdNextEpochList(sessionId) {
217+
const gate = deferredReadGate();
218+
epochListGates.set(sessionId, gate);
219+
return gate;
220+
},
221+
holdNextSnapshot(graphId) {
222+
const gate = deferredReadGate();
223+
snapshotGates.set(graphId, gate);
224+
return gate;
225+
},
226+
stopCalls,
141227
};
142228
}
143229

@@ -160,6 +246,126 @@ async function renderPanel(
160246
}
161247

162248
describe('AgentGraphPanel dismiss', () => {
249+
it('keeps the new session loading when a disposed read settles later', async () => {
250+
const sessionA = snapshot({ graphId: 'graph-a', status: 'active' });
251+
const sessionB = snapshot({
252+
graphId: 'graph-b',
253+
status: 'active',
254+
rootSessionId: 'session-2',
255+
});
256+
const harness = installGraphRenderer(sessionA, [sessionB]);
257+
const readA = harness.holdNextEpochList('session-1');
258+
await harness.renderSession('session-1');
259+
await readA.started;
260+
261+
const readB = harness.holdNextEpochList('session-2');
262+
await harness.renderSession('session-2');
263+
await readB.started;
264+
assert.match(harness.container.textContent ?? '', /Loading graph state/);
265+
266+
await act(async () => {
267+
readA.release();
268+
await Promise.resolve();
269+
});
270+
assert.match(harness.container.textContent ?? '', /Loading graph state/);
271+
272+
await act(async () => {
273+
readB.release();
274+
await Promise.resolve();
275+
});
276+
assert.match(harness.container.textContent ?? '', /Agent Graph/);
277+
await act(async () => harness.root.unmount());
278+
});
279+
280+
it('switches to a historical epoch without exposing current-graph controls', async () => {
281+
const current = snapshot({ graphId: 'graph-2', status: 'active' });
282+
const previous = snapshot({ graphId: 'graph-1', status: 'completed' });
283+
const harness = installGraphRenderer(current, [previous]);
284+
await act(async () => {
285+
harness.root.render(
286+
createElement(AgentGraphPanel, {
287+
rootSessionId: 'session-1',
288+
enabled: true,
289+
locale: 'en',
290+
onOpenSession: () => undefined,
291+
}),
292+
);
293+
await Promise.resolve();
294+
});
295+
296+
const selector = harness.container.querySelector('[role="combobox"]');
297+
assert.ok(selector);
298+
assert.match(harness.container.textContent ?? '', /Stop graph/);
299+
await act(async () => {
300+
(selector as HTMLElement).click();
301+
await Promise.resolve();
302+
});
303+
const historyOption = [...document.querySelectorAll('[role="option"]')].find((option) =>
304+
option.textContent?.includes('History'),
305+
);
306+
assert.ok(historyOption);
307+
const historyRead = harness.holdNextSnapshot('graph-1');
308+
await act(async () => {
309+
(historyOption as HTMLElement).click();
310+
await historyRead.started;
311+
});
312+
313+
assert.match(harness.container.textContent ?? '', /#1 · History \(read-only\)/);
314+
assert.doesNotMatch(harness.container.textContent ?? '', /Stop graph/);
315+
assert.equal(harness.container.querySelector('.maka-agent-graph-dismiss'), null);
316+
assert.deepEqual(harness.stopCalls, []);
317+
await act(async () => {
318+
historyRead.release();
319+
await Promise.resolve();
320+
});
321+
await act(async () => harness.root.unmount());
322+
});
323+
324+
it('resumes following the current epoch after the selected history expires', async () => {
325+
const current = snapshot({ graphId: 'graph-2', status: 'active' });
326+
const previous = snapshot({ graphId: 'graph-1', status: 'completed' });
327+
const harness = installGraphRenderer(current, [previous]);
328+
await act(async () => {
329+
harness.root.render(
330+
createElement(AgentGraphPanel, {
331+
rootSessionId: 'session-1',
332+
enabled: true,
333+
locale: 'en',
334+
onOpenSession: () => undefined,
335+
}),
336+
);
337+
await Promise.resolve();
338+
});
339+
340+
const selector = harness.container.querySelector('[role="combobox"]');
341+
assert.ok(selector);
342+
await act(async () => {
343+
(selector as HTMLElement).click();
344+
await Promise.resolve();
345+
});
346+
const historyOption = [...document.querySelectorAll('[role="option"]')].find((option) =>
347+
option.textContent?.includes('History'),
348+
);
349+
assert.ok(historyOption);
350+
await act(async () => {
351+
(historyOption as HTMLElement).click();
352+
await Promise.resolve();
353+
});
354+
const combobox = () => harness.container.querySelector('[role="combobox"]')?.textContent ?? '';
355+
assert.match(combobox(), /#1 · History \(read-only\)/);
356+
357+
// The selected epoch leaves the bounded directory while the graph rolls over.
358+
harness.evict('graph-1');
359+
await harness.setSnapshot(snapshot({ graphId: 'graph-3', status: 'active' }));
360+
assert.match(combobox(), /#2 · Current/);
361+
362+
// Follow restored: the next rollover refreshes the panel instead of
363+
// staying pinned on the fallback graph.
364+
await harness.setSnapshot(snapshot({ graphId: 'graph-4', status: 'waiting' }));
365+
assert.match(combobox(), /#3 · Current/);
366+
await act(async () => harness.root.unmount());
367+
});
368+
163369
it('shows dismiss only after the graph has settled', async () => {
164370
const active = await renderPanel(snapshot({ graphId: 'graph-1', status: 'active' }));
165371
assert.ok(active.container.querySelector('.maka-agent-graph-panel'));

0 commit comments

Comments
 (0)