blob: e7ed035644ae3ee77457c9bb2bbeed869f187051 [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.
*/
import { createHash } from 'node:crypto';
import { open, realpath, stat } from 'node:fs/promises';
import { isAbsolute, resolve } from 'node:path';
import { MAX_ATTACHMENT_BYTES } from '@maka/core/attachments';
import {
createToolResultArchiveCapability,
type ToolResultArchiveCapability,
type ToolResultArchiveRecorder,
} from '@maka/runtime/tool-result-archive-capability';
import { isPathInside } from '@maka/runtime/path-containment';
import {
createLedgerArchivePreparer,
createLedgerArchiveResourceReader,
} from '@maka/runtime/ledger-tool-result-archive-reader';
import type { ToolResultArchiveEvidenceReader } from '@maka/core/tool-result-archive-evidence';
import { type ToolArtifactRecorderInput } from '@maka/runtime/tool-artifacts';
import {
type ToolResultArchiveReaderInput,
type ToolResultArchiveReadResult,
} from '@maka/runtime/context-budget';
import { type ToolResultArchiveResourceReadInput } from '@maka/runtime/tool-result-archive-resource';
import type { InteractiveArtifactStoreWriter } from '@maka/storage/artifact-stores';
import type { SessionManagerDeps } from '@maka/runtime/session-manager';
import type { SessionAdmissionGate } from './session-admission-gate.js';
import type { SessionPresenceReader } from './session-presence.js';
export interface HostExecutionArtifactServices {
recordToolArtifacts(event: ToolArtifactRecorderInput): Promise<void>;
publishChildWorkspacePatch: NonNullable<SessionManagerDeps['publishChildWorkspacePatch']>;
/**
* New archives use the Session ledger. Legacy Artifact refs retain their
* scoped reader; the writer never falls back to publishing Artifact bytes.
*/
toolResultArchive: ToolResultArchiveCapability;
}
export function createHostExecutionArtifactServices(input: {
artifacts: InteractiveArtifactStoreWriter;
requestDrain: () => void;
sessionAdmission: SessionAdmissionGate;
sessions: SessionPresenceReader;
archiveEvidence?: ToolResultArchiveEvidenceReader;
}): HostExecutionArtifactServices {
const runWrite = async <T>(operation: () => Promise<T>): Promise<T> => {
try {
return await operation();
} catch (error) {
input.requestDrain();
throw error;
}
};
const publish = (artifact: Parameters<InteractiveArtifactStoreWriter['create']>[0]) =>
input.sessionAdmission.runOrJoin(artifact.sessionId, async () => {
if ((await input.sessions.probeSessionRemoval(artifact.sessionId)).kind !== 'present')
return null;
return runWrite(() => input.artifacts.create(artifact));
});
const recordToolArtifacts = async (event: ToolArtifactRecorderInput): Promise<void> => {
for (const candidate of event.candidates) {
let content = candidate.content;
if (content === undefined && candidate.sourcePath) {
content = (await readBoundedSourceFile(event.cwd, candidate.sourcePath)) ?? undefined;
}
if (content === undefined || contentBytes(content) > MAX_ATTACHMENT_BYTES) continue;
await publish({
sessionId: event.sessionId,
turnId: event.turnId,
name: candidate.name,
kind: candidate.kind,
content,
...(candidate.mimeType ? { mimeType: candidate.mimeType } : {}),
source: candidate.source ?? 'tool_result',
...(candidate.summary ? { summary: candidate.summary } : {}),
});
}
};
const prepareLedger = input.archiveEvidence
? createLedgerArchivePreparer(input.archiveEvidence)
: undefined;
const readLedgerResource = input.archiveEvidence
? createLedgerArchiveResourceReader(input.archiveEvidence)
: undefined;
const prepareLedgerForCommit: ToolResultArchiveRecorder = async (event) => {
const accepted = { ...event };
if (!prepareLedger || !(await prepareLedger(accepted))) return;
return {
ledger: true,
commitTransition: (transition, persist) =>
input.sessionAdmission.runOrJoin(accepted.sessionId, async () => {
if (
(await input.sessions.probeSessionRemoval(accepted.sessionId)).kind !== 'present' ||
!(await prepareLedger(accepted))
)
return false;
await persist(transition);
return true;
}),
};
};
const services: HostExecutionArtifactServices = {
recordToolArtifacts,
publishChildWorkspacePatch: async ({ sessionId, turnId, binding, patch }) => {
const artifact = await publish({
id: subagentWritebackArtifactId(sessionId, turnId),
sessionId,
turnId,
name: 'workspace.patch',
kind: 'diff',
content: patch,
mimeType: 'text/x-diff; charset=utf-8',
source: 'subagent_writeback',
summary: `Workspace changes relative to ${binding.baseCommit}.`,
});
if (!artifact)
throw new Error(`Child Session ${sessionId} was retired before patch publication`);
return artifact;
},
toolResultArchive: createToolResultArchiveCapability({
archiveToolResult: prepareLedgerForCommit,
readArchivedToolResultResource: (event: ToolResultArchiveResourceReadInput) =>
event.storage === 'ledger' || event.storage === 'event'
? (readLedgerResource?.(event) ?? { ok: false, reason: 'read_failed' })
: readArchive(input.artifacts, event),
}),
};
return Object.freeze(services);
}
function subagentWritebackArtifactId(sessionId: string, turnId: string): string {
const digest = createHash('sha256')
.update('maka-subagent-writeback-v1\0')
.update(sessionId)
.update('\0')
.update(turnId)
.digest('hex')
.slice(0, 32);
return `subagent_writeback_${digest}`;
}
async function readBoundedSourceFile(cwd: string, sourcePath: string): Promise<Buffer | null> {
const candidate = isAbsolute(sourcePath) ? sourcePath : resolve(cwd, sourcePath);
let root: string;
let target: string;
try {
[root, target] = await Promise.all([realpath(cwd), realpath(candidate)]);
} catch {
return null;
}
if (!isPathInside(root, target)) return null;
let handle: Awaited<ReturnType<typeof open>> | undefined;
try {
handle = await open(target, 'r');
const opened = await handle.stat();
if (!opened.isFile() || opened.size > MAX_ATTACHMENT_BYTES) return null;
const currentTarget = await realpath(target);
if (!isPathInside(root, currentTarget)) return null;
const current = await stat(currentTarget);
if (current.dev !== opened.dev || current.ino !== opened.ino) return null;
const bytes = Buffer.allocUnsafe(opened.size);
let offset = 0;
while (offset < bytes.byteLength) {
const read = await handle.read(bytes, offset, bytes.byteLength - offset, offset);
if (read.bytesRead === 0) break;
offset += read.bytesRead;
}
return offset === bytes.byteLength ? bytes : bytes.subarray(0, offset);
} catch {
return null;
} finally {
await handle?.close().catch(() => undefined);
}
}
function contentBytes(content: string | Uint8Array): number {
return typeof content === 'string' ? Buffer.byteLength(content, 'utf8') : content.byteLength;
}
async function readArchive(
artifacts: InteractiveArtifactStoreWriter,
event: Pick<
Extract<ToolResultArchiveReaderInput, { rewriteVersion: 1 }>,
'artifactId' | 'sessionId' | 'bodySha256' | 'originalBytes' | 'maxBytes'
>,
): Promise<ToolResultArchiveReadResult> {
const entry = await artifacts.getInSession(event.sessionId, event.artifactId);
const record = entry.record;
if (!record) return { ok: false, reason: 'not_found' };
if (record.source !== 'tool_result_archive') return { ok: false, reason: 'source_mismatch' };
if (record.sizeBytes !== event.originalBytes) return { ok: false, reason: 'size_mismatch' };
const read = await artifacts.readTextInSession(event.sessionId, event.artifactId, {
maxBytes: event.maxBytes ?? event.originalBytes,
});
if (!read.ok) return read;
if (sha256(read.text) !== event.bodySha256) return { ok: false, reason: 'corrupt' };
return { ok: true, serializedResult: read.text };
}
function sha256(text: string): string {
return createHash('sha256').update(text).digest('hex');
}