




















11import { randomUUID } from "node:crypto";
2+import fs from "node:fs";
23import path from "node:path";
34import {
45acquireSessionWriteLock,
56resolveSessionWriteLockOptions,
67} from "../../agents/session-write-lock.js";
8+import { formatErrorMessage } from "../../infra/errors.js";
79import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
810import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
911import type { SessionTranscriptUpdate } from "../../sessions/transcript-events.js";
@@ -26,6 +28,7 @@ import {
2628patchSessionEntry as patchFileSessionEntry,
2729readSessionUpdatedAt as readFileSessionUpdatedAt,
2830resolveSessionStoreEntry,
31+updateSessionStore,
2932updateSessionStoreEntry as updateFileSessionStoreEntry,
3033type SessionLifecycleArtifactCleanupParams,
3134type SessionLifecycleArtifactCleanupResult,
@@ -40,6 +43,7 @@ import {
4043withSessionTranscriptAppendQueue,
4144} from "./transcript-append.js";
4245import { resolveSessionTranscriptFile } from "./transcript-file-resolve.js";
46+import { createSessionTranscriptHeader } from "./transcript-header.js";
4347import { streamSessionTranscriptLines } from "./transcript-stream.js";
4448import {
4549type OwnedSessionTranscriptPublishedEntry,
@@ -243,6 +247,26 @@ export type SessionEntryPatchContext = {
243247existingEntry?: SessionEntry;
244248};
245249250+export type SessionEntryCreateWithTranscriptContext = {
251+/** Current entry under the requested key before creation, if any. */
252+existingEntry?: SessionEntry;
253+/** Current entries snapshot for validation rules such as label uniqueness. */
254+sessionEntries: Record<string, SessionEntry>;
255+};
256+257+export type SessionEntryCreateWithTranscriptResult<TError = string> =
258+| { ok: true; entry: SessionEntry; sessionFile: string }
259+| { ok: false; error: TError; phase: "entry" }
260+| { ok: false; error: string; phase: "transcript" };
261+262+export type SessionEntryCreateWithTranscriptPrepareResult<TError = string> =
263+| { ok: true; entry: SessionEntry }
264+| { ok: false; error: TError };
265+266+type CreatedSessionTranscriptResult =
267+| { ok: true; sessionFile: string }
268+| { ok: false; error: string; phase: "transcript" };
269+246270export type { SessionLifecycleArtifactCleanupParams, SessionLifecycleArtifactCleanupResult };
247271248272/** Returns the entry for a canonical or alias session key, if one exists. */
@@ -349,6 +373,100 @@ export async function patchSessionEntry(
349373});
350374}
351375376+/**
377+ * Creates or updates one session entry and initializes its transcript header as
378+ * one storage-sized lifecycle operation. File-backed storage still writes JSON
379+ * plus JSONL, but callers no longer compose entry write, header creation,
380+ * rollback, and normalized sessionFile persistence themselves.
381+ */
382+export async function createSessionEntryWithTranscript<TError = string>(
383+scope: SessionAccessScope,
384+createEntry: (
385+context: SessionEntryCreateWithTranscriptContext,
386+) =>
387+| Promise<SessionEntryCreateWithTranscriptPrepareResult<TError>>
388+| SessionEntryCreateWithTranscriptPrepareResult<TError>,
389+): Promise<SessionEntryCreateWithTranscriptResult<TError>> {
390+const storePath = resolveAccessStorePath(scope);
391+return await updateSessionStore(storePath, async (store) => {
392+const resolved = resolveSessionStoreEntry({ store, sessionKey: scope.sessionKey });
393+const created = await createEntry({
394+existingEntry: resolved.existing ? { ...resolved.existing } : undefined,
395+sessionEntries: cloneSessionEntries(store),
396+});
397+if (!created.ok) {
398+return { ok: false, error: created.error, phase: "entry" };
399+}
400+401+const ensured = ensureCreatedSessionTranscript({
402+agentId: scope.agentId,
403+entry: created.entry,
404+ storePath,
405+});
406+if (!ensured.ok) {
407+delete store[resolved.normalizedKey];
408+return ensured;
409+}
410+411+const entry =
412+created.entry.sessionFile === ensured.sessionFile
413+ ? created.entry
414+ : {
415+ ...created.entry,
416+sessionFile: ensured.sessionFile,
417+};
418+store[resolved.normalizedKey] = entry;
419+for (const legacyKey of resolved.legacyKeys) {
420+delete store[legacyKey];
421+}
422+return { ok: true, entry, sessionFile: ensured.sessionFile };
423+});
424+}
425+426+function cloneSessionEntries(store: Record<string, SessionEntry>): Record<string, SessionEntry> {
427+return Object.fromEntries(
428+Object.entries(store).map(([sessionKey, entry]) => [sessionKey, { ...entry }]),
429+);
430+}
431+432+// File-backed creation resolves the concrete transcript artifact and writes the
433+// header before the store mutation is saved; SQLite adapters implement this as
434+// the same lifecycle operation without exposing rollback details to callers.
435+function ensureCreatedSessionTranscript(params: {
436+agentId?: string;
437+entry: SessionEntry;
438+storePath: string;
439+}): CreatedSessionTranscriptResult {
440+try {
441+const sessionFile = resolveSessionFilePath(
442+params.entry.sessionId,
443+params.entry.sessionFile ? { sessionFile: params.entry.sessionFile } : undefined,
444+{
445+agentId: params.agentId,
446+sessionsDir: path.dirname(path.resolve(params.storePath)),
447+},
448+);
449+if (!fs.existsSync(sessionFile)) {
450+fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
451+fs.writeFileSync(
452+sessionFile,
453+`${JSON.stringify(createSessionTranscriptHeader({ sessionId: params.entry.sessionId }))}\n`,
454+{
455+encoding: "utf-8",
456+mode: 0o600,
457+},
458+);
459+}
460+return { ok: true, sessionFile };
461+} catch (err) {
462+return {
463+ok: false,
464+error: formatErrorMessage(err),
465+phase: "transcript",
466+};
467+}
468+}
469+352470/** Updates an existing entry only; returns null when the session is absent. */
353471export async function updateSessionEntry(
354472scope: SessionAccessScope,
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。