Skip to content

Commit 02334d4

Browse files
committed
clawdbot-d02.1.9.1.31: add sessions.create lifecycle seam
1 parent 8db66b4 commit 02334d4

6 files changed

Lines changed: 337 additions & 104 deletions

File tree

scripts/check-session-accessor-boundary.mjs

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ const legacyTranscriptWriterNames = new Set([
3434
"emitSessionTranscriptUpdate",
3535
"rewriteTranscriptEntriesInSessionFile",
3636
]);
37+
const sessionCreateLifecycleWriterNames = new Set([
38+
"applySessionStoreEntryPatch",
39+
"saveSessionStore",
40+
"updateSessionStore",
41+
"updateSessionStoreEntry",
42+
"ensureSessionTranscriptFile",
43+
]);
3744

3845
export const migratedSessionAccessorFiles = new Set([
3946
"src/agents/embedded-agent-runner/compaction-successor-transcript.ts",
@@ -238,6 +245,39 @@ export function findTranscriptWriterBoundaryViolations(content, fileName = "sour
238245
);
239246
}
240247

248+
export function findGatewaySessionCreateLifecycleViolations(content, fileName = "source.ts") {
249+
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
250+
const violations = [];
251+
252+
const visitCreateHandler = (node) => {
253+
if (ts.isCallExpression(node)) {
254+
const calleeName = propertyAccessName(node.expression);
255+
if (calleeName && sessionCreateLifecycleWriterNames.has(calleeName)) {
256+
violations.push({
257+
line: toLine(sourceFile, node.expression),
258+
reason: `calls legacy sessions.create lifecycle writer "${calleeName}"`,
259+
});
260+
}
261+
}
262+
ts.forEachChild(node, visitCreateHandler);
263+
};
264+
265+
const visit = (node) => {
266+
if (
267+
ts.isPropertyAssignment(node) &&
268+
ts.isStringLiteralLike(node.name) &&
269+
node.name.text === "sessions.create"
270+
) {
271+
visitCreateHandler(node.initializer);
272+
return;
273+
}
274+
ts.forEachChild(node, visit);
275+
};
276+
277+
visit(sourceFile);
278+
return violations;
279+
}
280+
241281
export async function main() {
242282
const repoRoot = resolveRepoRoot(import.meta.url);
243283
const readSourceRoots = resolveSourceRoots(repoRoot, [
@@ -287,7 +327,20 @@ export async function main() {
287327
!migratedTranscriptWriterFiles.has(normalizeRelativePath(path.relative(repoRoot, filePath))),
288328
findViolations: findTranscriptWriterBoundaryViolations,
289329
});
290-
const violations = [...readViolations, ...writeViolations, ...transcriptWriterViolations];
330+
const sessionCreateLifecycleViolations = await collectFileViolations({
331+
repoRoot,
332+
sourceRoots: resolveSourceRoots(repoRoot, ["src/gateway/server-methods"]),
333+
skipFile: (filePath) =>
334+
normalizeRelativePath(path.relative(repoRoot, filePath)) !==
335+
"src/gateway/server-methods/sessions.ts",
336+
findViolations: findGatewaySessionCreateLifecycleViolations,
337+
});
338+
const violations = [
339+
...readViolations,
340+
...writeViolations,
341+
...transcriptWriterViolations,
342+
...sessionCreateLifecycleViolations,
343+
];
291344

292345
if (violations.length === 0) {
293346
console.log("session accessor boundary guard passed.");

src/config/sessions/session-accessor.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
appendTranscriptMessage,
88
appendTranscriptEvent,
99
cleanupSessionLifecycleArtifacts,
10+
createSessionEntryWithTranscript,
1011
listSessionEntries,
1112
loadExactSessionEntry,
1213
loadSessionEntry,
@@ -93,6 +94,57 @@ describe("session accessor file-backed seam", () => {
9394
expect(loadSessionEntry(scope)?.sessionId).toBe(inserted?.sessionId);
9495
});
9596

97+
it("creates entries with initialized transcripts and normalized sessionFile metadata", async () => {
98+
const scope = {
99+
agentId: "main",
100+
sessionKey: "agent:main:main",
101+
storePath,
102+
};
103+
104+
const created = await createSessionEntryWithTranscript(scope, ({ sessionEntries }) => {
105+
expect(sessionEntries).toEqual({});
106+
return {
107+
ok: true,
108+
entry: {
109+
sessionId: "session-1",
110+
updatedAt: 10,
111+
},
112+
};
113+
});
114+
115+
expect(created.ok).toBe(true);
116+
if (!created.ok) {
117+
throw new Error("expected session creation to succeed");
118+
}
119+
expect(path.basename(created.sessionFile)).toBe("session-1.jsonl");
120+
expect(created.entry.sessionFile).toBe(created.sessionFile);
121+
});
122+
123+
it("rolls back the entry when transcript initialization fails", async () => {
124+
const scope = {
125+
agentId: "main",
126+
sessionKey: "agent:main:main",
127+
storePath,
128+
};
129+
fs.writeFileSync(path.join(tempDir, "blocked"), "not a directory", "utf8");
130+
131+
const created = await createSessionEntryWithTranscript(scope, () => ({
132+
ok: true,
133+
entry: {
134+
sessionFile: "blocked/session-1.jsonl",
135+
sessionId: "session-1",
136+
updatedAt: 10,
137+
},
138+
}));
139+
140+
expect(created).toMatchObject({
141+
ok: false,
142+
phase: "transcript",
143+
});
144+
expect(loadSessionEntry(scope)).toBeUndefined();
145+
expect(loadSessionStore(storePath, { skipCache: true })[scope.sessionKey]).toBeUndefined();
146+
});
147+
96148
it("can borrow cached entry objects for read-only hot paths", async () => {
97149
const scope = {
98150
clone: false,

src/config/sessions/session-accessor.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { randomUUID } from "node:crypto";
2+
import fs from "node:fs";
23
import path from "node:path";
34
import {
45
acquireSessionWriteLock,
56
resolveSessionWriteLockOptions,
67
} from "../../agents/session-write-lock.js";
8+
import { formatErrorMessage } from "../../infra/errors.js";
79
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
810
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
911
import 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";
4245
import { resolveSessionTranscriptFile } from "./transcript-file-resolve.js";
46+
import { createSessionTranscriptHeader } from "./transcript-header.js";
4347
import { streamSessionTranscriptLines } from "./transcript-stream.js";
4448
import {
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+
246270
export 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. */
353471
export async function updateSessionEntry(
354472
scope: SessionAccessScope,

0 commit comments

Comments
 (0)