/**
* In-flight message content: the agent loop's write path for streaming
* content, kept off the SQLite writer lock.
*
* Rows are BORN streaming: the reserve seam creates the row with `content`
* already holding the `{ ref }` pointer at an in-flight JSONL delta file
* under the conversation directory (`conversations/
/inflight/…`) and
* `finalized = 0`. Each partial flush appends only the blocks that changed
* since the last flush — a pure file append, no SQLite write at all. At the
* message seam, {@link finalizeInflightContent} writes the folded content
* inline, sets `finalized = 1`, and deletes the file.
*
* A message that never partial-flushes (fast replies inside the debounce
* window) never creates the file — its single finalize write is the only
* content persistence, exactly as before.
*
* Reads of an in-flight row work transparently — the message row mapper
* resolves `{ ref }` by folding the delta file — so content stays readable
* mid-stream and after a crash. Batch readers (search, memory indexing)
* filter `finalized = 1` and never consume in-flight content.
*/
import { rmSync } from "node:fs";
import type pino from "pino";
import { v4 as uuid } from "uuid";
import {
finalizeMessageContent,
getConversation,
getMessageById,
} from "../persistence/conversation-crud.js";
import { getConversationDirName } from "../persistence/conversation-directories.js";
import {
appendContentDeltas,
type ContentDeltaLine,
resolveContentRefPath,
} from "../persistence/message-content-file.js";
import type { ContentBlock } from "../providers/types.js";
import { withSqliteRetry } from "../util/sqlite-retry.js";
export interface InflightContentWriter {
/** Row id — assigned by the reserve seam once the reservation resolves. */
messageId: string;
conversationId: string;
/** Workspace-relative delta-file path persisted in the row's `{ ref }`. */
ref: string;
/** Absolute delta-file path. */
absPath: string;
/**
* Highest stream-event `seq` stamped on an appended delta line. Flushes
* stamp the triggering event's seq so file lines correlate 1:1 with the
* event stream; this tracks the high-water mark for monotonicity.
*/
lastSeq: number;
/** Per-index serialized form of the last flushed snapshot (diff base). */
lastSerialized: string[];
}
/**
* Create a writer for one message's in-flight content, ahead of the row's
* reservation — the returned `ref` is what the reserve seam persists into
* the newborn row's `{ ref }` content. The delta file is named by a fresh
* uuid (the row id does not exist yet); callers stamp `messageId` once the
* reservation resolves. Returns null when the conversation row can't be
* resolved — callers then reserve a plain inline row and fall back to
* direct row writes, which is always correct, just not contention-free.
*/
export function createInflightContentWriter(
conversationId: string,
): InflightContentWriter | null {
const conv = getConversation(conversationId);
if (!conv || !Number.isFinite(conv.createdAt)) {
return null;
}
// POSIX join — the ref schema requires forward slashes.
const ref = [
"conversations",
getConversationDirName(conversationId, conv.createdAt),
"inflight",
`${uuid()}.jsonl`,
].join("/");
const absPath = resolveContentRefPath(ref);
if (!absPath) {
return null;
}
return {
messageId: "",
conversationId,
ref,
absPath,
lastSeq: 0,
lastSerialized: [],
};
}
/**
* Persist a full content snapshot by appending only the blocks that changed
* since the last flush — a pure file append, no SQLite write.
*
* `eventSeq` is the stream-event seq that triggered this flush; every delta
* line of the flush is stamped with it so file lines correlate 1:1 with the
* event stream. Flushes without a stream seq (e.g. content synthesized
* outside the delta stream) advance the writer's high-water mark by one.
* The stamp is clamped monotonic so the fold's highest-seq-wins rule holds
* even across mixed sources.
*
* Returns whether the snapshot is durably persisted, mirroring the old
* row-write contract so callers can gate seq bookkeeping on it.
*/
export function appendInflightSnapshot(
writer: InflightContentWriter,
blocks: ContentBlock[],
eventSeq: number | undefined,
rlog: pino.Logger,
): boolean {
try {
// The fold keeps the highest-seq line per index, so a shrinking snapshot
// (fewer blocks than the diff base) cannot be expressed as an append.
// Streaming content only grows within a row, so this is a never-path
// guard: reset the diff base and re-append everything.
if (blocks.length < writer.lastSerialized.length) {
rlog.warn(
{
messageId: writer.messageId,
from: writer.lastSerialized.length,
to: blocks.length,
},
"In-flight snapshot shrank; rewriting delta base",
);
rmSync(writer.absPath, { force: true });
writer.lastSerialized = [];
}
const stamp = Math.max(eventSeq ?? writer.lastSeq + 1, writer.lastSeq + 1);
const deltas: ContentDeltaLine[] = [];
for (let i = 0; i < blocks.length; i++) {
const serialized = JSON.stringify(blocks[i]);
if (writer.lastSerialized[i] === serialized) {
continue;
}
deltas.push({ i, seq: stamp, block: blocks[i] });
writer.lastSerialized[i] = serialized;
}
if (deltas.length > 0) {
writer.lastSeq = stamp;
}
appendContentDeltas(writer.absPath, deltas);
return true;
} catch (err) {
rlog.error(
{ err, messageId: writer.messageId },
"in-flight content append failed; continuing without interrupting the turn",
);
return false;
}
}
/**
* Finalize a message's content at its terminal write seam: fold the
* authoritative blocks inline into the row, set `finalized = 1`, and
* delete the delta file. Safe for rows that never went in-flight (the
* common fast-reply case) — it is then exactly the single content write
* the seam always performed.
*/
export async function finalizeInflightContent(
writer: InflightContentWriter | undefined,
messageId: string,
contentJson: string,
rlog: pino.Logger,
metadataUpdates?: Record,
): Promise {
try {
await withSqliteRetry(
() => finalizeMessageContent(messageId, contentJson, metadataUpdates),
{ op: "finalize_message_content", context: { messageId } },
);
} catch (err) {
rlog.error(
{ err, messageId },
"message-content finalize failed after retries; continuing without interrupting the turn",
);
return false;
}
if (writer) {
try {
rmSync(writer.absPath, { force: true });
} catch (err) {
rlog.warn(
{ err, messageId },
"failed to remove in-flight delta file after finalize",
);
}
}
return true;
}
/**
* Fold and finalize any writers a turn left behind — cancelled or aborted
* turns exit without reaching a message's finalize seam. The row's current
* resolved content (the folded delta file, or inline content if another
* path already rewrote the row) becomes the finalized inline value. Runs
* at the turn-finalize seam so `finalized = 0` rows cannot leak from a
* live daemon; the startup recovery sweep covers crashes.
*/
export async function finalizeStrandedInflightContent(
writers: Map,
rlog: pino.Logger,
): Promise {
for (const writer of writers.values()) {
try {
const row = getMessageById(writer.messageId, writer.conversationId);
if (row && row.finalized === 0) {
await finalizeInflightContent(
writer,
writer.messageId,
JSON.stringify(row.content),
rlog,
);
}
} catch (err) {
rlog.warn(
{ err, messageId: writer.messageId },
"failed to finalize stranded in-flight content",
);
}
}
writers.clear();
}