blob: dd76eccef43ebcf5692c74f5d3a7f5c056059d60 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* The invocation index is a query over the canonical events, not a second
* record. This test writes real turns through the production seams, then checks
* that what the index answers is exactly what rebuilding from those events
* alone produces.
*/
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { test } from 'node:test';
import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event';
import { runtimeInvocationsFromSessionEvents } from '@maka/core/runtime-invocation';
import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store';
import { OPERATIONAL_STATE_DATABASE_NAME } from '@maka/storage/operational-state-store';
import { createWorkspaceRuntimeStore } from '@maka/storage/runtime-event-persistence';
import { createSessionStore } from '@maka/storage/session-store';
import type { SessionEvent } from '@maka/core/events';
import type { BackendSendInput } from '@maka/core/backend-types';
import { BackendRegistry, SessionManager } from '../session-manager.js';
test('the invocation index returns the same inventory as a rebuild from events alone', async () => {
const root = await mkdtemp(join(tmpdir(), 'maka-invocation-index-'));
try {
const sessionStore = createSessionStore(root);
const runStore = createSqliteAgentRunStore(root);
const runtimeEventStore = createWorkspaceRuntimeStore(root);
const backends = new BackendRegistry();
backends.register('ai-sdk', (ctx) => ({
kind: 'ai-sdk' as const,
sessionId: ctx.sessionId,
async *send(input: BackendSendInput): AsyncIterable<SessionEvent> {
yield {
type: 'complete',
id: `${input.turnId}-complete`,
turnId: input.turnId,
ts: 2,
stopReason: 'end_turn',
};
},
async stop() {},
async respondToSandboxBoundary() {},
async dispose() {},
}));
let ids = 0;
let clock = 1_000;
const manager = new SessionManager({
store: sessionStore,
runStore,
runtimeEventStore,
backends,
newId: () => `index-${++ids}`,
now: () => (clock += 1),
});
const session = await manager.createSession({
cwd: root,
llmConnectionSlug: 'fake',
permissionMode: 'bypass',
});
for (const turnId of ['turn-1', 'turn-2', 'turn-3']) {
for await (const _event of manager.sendMessage(session.id, { turnId, text: turnId })) {
// Drain the turn so its run reaches the durable ledger.
}
}
const invocations = await runtimeEventStore.listSessionInvocations(session.id);
const rebuilt = runtimeInvocationsFromSessionEvents(
session.id,
await runtimeEventStore.readSessionRuntimeEvents(session.id),
);
assert.equal(invocations.length, 3);
assert.deepStrictEqual(
invocations,
rebuilt,
'the index must return exactly what a rebuild from events alone produces',
);
for (const invocation of invocations) {
assert.equal(
invocation.terminalEvent?.status,
'completed',
'a finished invocation must expose its terminal event through the index',
);
}
// A ledger written before the store sealed runs can carry a straggler after
// the terminal event: stop sealed the run while the stream was still
// draining, and nothing has ever removed those. Recovery, the read model and
// continuation resume all read such a run as ended, so the index must too —
// reading it as active is what makes one reader disagree with the rest.
const straggler = invocations[0]!;
runtimeEventStore.close();
const db = new DatabaseSync(join(root, OPERATIONAL_STATE_DATABASE_NAME));
try {
const { json } = encodeCanonicalRuntimeEvent({
id: 'post-terminal-straggler',
invocationId: straggler.invocationId,
runId: straggler.runId,
sessionId: session.id,
turnId: straggler.turnId,
ts: 9_999,
partial: false,
role: 'model',
author: 'agent',
modelVisibility: 'visible',
content: { kind: 'text', text: 'arrived after the run was sealed' },
});
db.prepare(`
INSERT INTO runtime_events (
event_id, session_id, invocation_id, run_id, turn_id, event_seq,
event_kind, payload_json, committed_at
) VALUES (?, ?, ?, ?, ?, (
SELECT MAX(event_seq) + 1 FROM runtime_events WHERE invocation_id = ?
), 'text', ?, 9999)
`).run(
'post-terminal-straggler',
session.id,
straggler.invocationId,
straggler.runId,
straggler.turnId,
straggler.invocationId,
json,
);
} finally {
db.close();
}
const reopened = createWorkspaceRuntimeStore(root);
try {
const afterStraggler = await reopened.listSessionInvocations(session.id);
assert.deepStrictEqual(
afterStraggler,
runtimeInvocationsFromSessionEvents(
session.id,
await reopened.readSessionRuntimeEvents(session.id),
),
'the index and a rebuild must still agree once a straggler follows the terminal',
);
assert.equal(
afterStraggler.find((invocation) => invocation.invocationId === straggler.invocationId)
?.terminalEvent?.status,
'completed',
'a run that ended stays ended when an unsealed-era straggler follows it',
);
} finally {
reopened.close();
}
runStore.close?.();
sessionStore.close?.();
} finally {
await rm(root, { recursive: true, force: true });
}
});