blob: f170a77f0f06d7b777dfdf76ed164cccb2ec82cc [file]
import { randomUUID } from 'node:crypto';
import type { AttachmentRef, QuoteRef, SessionHeader } from '@maka/core';
import type { ArtifactStore } from '@maka/storage';
import { ingestAttachments, resolveIngestItems } from './attachment-ingest.js';
import type { AttachmentApprovalRegistry } from './attachment-approval.js';
export interface SendCommandWithItems {
type: 'send';
turnId?: string;
text: string;
attachmentItems?: unknown;
/** Inline quoted excerpts; already normalized at the IPC boundary. */
quotes?: QuoteRef[];
}
/**
* Run the send readiness check, then resolve + ingest attachment items, in
* that order. Readiness failure throws before any token is consumed or
* artifact created, so the caller can retry with the same approvalId.
*/
export async function resolveSessionSend(input: {
sessionId: string;
senderId: number;
command: SendCommandWithItems;
ensureCanSend: (sessionId: string) => Promise<void>;
readHeader: (sessionId: string) => Promise<SessionHeader | null>;
approvals: AttachmentApprovalRegistry;
stat: (path: string) => Promise<{ size: number }>;
artifactStore: ArtifactStore;
resizeImage: (bytes: Uint8Array) => Promise<Uint8Array>;
}): Promise<{ turnId: string; attachments: AttachmentRef[] }> {
await input.ensureCanSend(input.sessionId);
let attachments: AttachmentRef[] = [];
if (input.command.attachmentItems) {
const header = await input.readHeader(input.sessionId);
if (!header) throw new Error('无法读取会话工作目录。');
const files = await resolveIngestItems({
senderId: input.senderId,
items: input.command.attachmentItems,
approvals: input.approvals,
stat: input.stat,
});
attachments = await ingestAttachments({
files,
cwd: header.cwd,
sessionId: input.sessionId,
artifactStore: input.artifactStore,
resizeImage: input.resizeImage,
});
}
return { turnId: input.command.turnId || randomUUID(), attachments };
}
/**
* What a send does the moment its run goes live.
*
* No SessionEvent marks a turn's START — only its end — and the runtime writes
* `status: 'running'` at the end of `AgentRun.begin`, announcing it to nobody.
* Without this broadcast the earliest a client learns its turn is running is
* the `message-appended` riding the FIRST content event, so the whole backend
* start-up ahead of it looks idle.
*
* The broadcast carries the turn id, which is what makes it an ANSWER to a
* particular send rather than a bare catalog invalidation: a session's status
* reads the same before a turn starts and after it ends, so a client that just
* sent cannot otherwise tell "not yet" from "already over".
*
* It is emitted BEFORE the revision commit. Nothing in the answer depends on
* that write, so it must neither be delayed by it nor lost when it throws.
*/
export function createRunStartedHook(input: {
sessionId: string;
turnId: string;
emitSessionsChanged: (sessionId: string, turnId: string) => void;
commitRevisionVersion: (sessionId: string) => Promise<unknown>;
}): (runId: string, header: { revisionState?: string }) => Promise<void> {
return async (_runId, header) => {
input.emitSessionsChanged(input.sessionId, input.turnId);
if (header.revisionState === 'preparing') {
await input.commitRevisionVersion(input.sessionId);
}
};
}
/** One `sessions:changed` to emit, in order. */
export interface StoppedTurnBroadcast {
reason: 'status-change' | 'turn-status-change' | 'message-appended';
turnId?: string;
}
/**
* The broadcasts that announce a stop, naming the turns it ended.
*
* Stopping is the one turn ending a client can be waiting on without ever
* having seen the turn start — Stop pressed inside the send→run-start window.
* A client holds a local claim on a turn it submitted until it hears back about
* that exact turn, so an unnamed stop leaves the claim with nothing to release
* it, making Stop the one control unable to undo Stop.
*
* With no turn running there is nothing to name and the plain invalidations
* stand: something not tied to a turn is exactly what an unnamed change means.
*/
export function stoppedTurnBroadcasts(
stoppedTurnIds: readonly string[],
): StoppedTurnBroadcast[] {
const reasons = ['status-change', 'turn-status-change', 'message-appended'] as const;
return reasons.flatMap((reason) =>
stoppedTurnIds.length === 0
? [{ reason }]
: stoppedTurnIds.map((turnId) => ({ reason, turnId })),
);
}