11import { randomUUID } from "node:crypto" ;
2+ import fs from "node:fs" ;
23import path from "node:path" ;
34import {
45 acquireSessionWriteLock ,
56 resolveSessionWriteLockOptions ,
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 {
2628 patchSessionEntry as patchFileSessionEntry ,
2729 readSessionUpdatedAt as readFileSessionUpdatedAt ,
2830 resolveSessionStoreEntry ,
31+ updateSessionStore ,
2932 updateSessionStoreEntry as updateFileSessionStoreEntry ,
3033 type SessionLifecycleArtifactCleanupParams ,
3134 type SessionLifecycleArtifactCleanupResult ,
@@ -40,6 +43,7 @@ import {
4043 withSessionTranscriptAppendQueue ,
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 {
4549 type OwnedSessionTranscriptPublishedEntry ,
@@ -243,6 +247,26 @@ export type SessionEntryPatchContext = {
243247 existingEntry ?: SessionEntry ;
244248} ;
245249
250+ 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 } ;
247271
248272/** Returns the entry for a canonical or alias session key, if one exists. */
@@ -349,6 +373,100 @@ export async function patchSessionEntry(
349373 } ) ;
350374}
351375
376+ /**
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 (
354472 scope : SessionAccessScope ,
0 commit comments