From 01ee8d6a1dd111f6c410e2603cc570014395bd00 Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Fri, 14 Aug 2026 18:56:26 -0700 Subject: [PATCH 1/3] feat: version every durable Fold event HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a002e8-7aeb-7845-a1f7-b46b966b896a --- packages/fold-core/src/EventLog/LogEntryFactory.ts | 3 ++- packages/fold-core/src/EventLog/Schemas.ts | 11 +++++------ packages/fold-core/src/Session/SessionLayer.ts | 1 - .../test/Compaction/CompactionEngine.vi.test.ts | 2 ++ .../fold-core/test/EventLog/EventLogMemory.vi.test.ts | 5 +++-- .../fold-core/test/Projection/Projection.vi.test.ts | 1 - 6 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/fold-core/src/EventLog/LogEntryFactory.ts b/packages/fold-core/src/EventLog/LogEntryFactory.ts index 31da24e..10481d9 100644 --- a/packages/fold-core/src/EventLog/LogEntryFactory.ts +++ b/packages/fold-core/src/EventLog/LogEntryFactory.ts @@ -11,7 +11,7 @@ const invalidEntryError = (message: string, cause: unknown) => cause, }) -/** Validate append input and assign the canonical EventLog sequence, event ID, and timestamp. */ +/** Validate append input and assign the canonical event envelope. */ export const makeStoredLogEntry = ( input: LogEntryInput, seq: LogSeq, @@ -29,5 +29,6 @@ export const makeStoredLogEntry = ( seq, eventId, ts, + version: 1, }).pipe(Effect.mapError((cause) => invalidEntryError('Invalid stored EventLog entry', cause))) }) diff --git a/packages/fold-core/src/EventLog/Schemas.ts b/packages/fold-core/src/EventLog/Schemas.ts index 0309775..e30d273 100644 --- a/packages/fold-core/src/EventLog/Schemas.ts +++ b/packages/fold-core/src/EventLog/Schemas.ts @@ -16,16 +16,17 @@ export const EpochMillis = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)) }) export type EpochMillis = typeof EpochMillis.Type +/** The durable event format version written on every stored log entry. */ +export const LogVersion = Schema.Literal(1) +export type LogVersion = typeof LogVersion.Type + const StoredLogEntryEnvelope = { seq: LogSeq, eventId: EventId, ts: EpochMillis, + version: LogVersion, } -/** The durable log format version written in the first session entry. */ -export const LogVersion = Schema.Literal(1) -export type LogVersion = typeof LogVersion.Type - /** A configured provider profile id, not a secret or API key. */ export const LlmProviderId = Schema.String.annotate({ identifier: 'LlmProviderId' }) export type LlmProviderId = typeof LlmProviderId.Type @@ -206,7 +207,6 @@ export const SessionStartedLogEntryInput = Schema.TaggedStruct('session_started' agentId: Schema.Null, parentAgentId: Schema.Null, toolCallId: Schema.Null, - version: LogVersion, cwd: Schema.NullOr(Schema.String), sessionId: SessionId, rootAgentId: AgentId, @@ -220,7 +220,6 @@ export const SessionStartedLogEntry = Schema.TaggedStruct('session_started', { agentId: Schema.Null, parentAgentId: Schema.Null, toolCallId: Schema.Null, - version: LogVersion, cwd: Schema.NullOr(Schema.String), sessionId: SessionId, rootAgentId: AgentId, diff --git a/packages/fold-core/src/Session/SessionLayer.ts b/packages/fold-core/src/Session/SessionLayer.ts index a7bf10f..b202b85 100644 --- a/packages/fold-core/src/Session/SessionLayer.ts +++ b/packages/fold-core/src/Session/SessionLayer.ts @@ -62,7 +62,6 @@ export const liveSessionLayer: Layer.Layer .append( // Intentionally invalid input: this test exercises append's schema-validation failure path. // oxlint-disable-next-line typescript/consistent-type-assertions - { ...(yield* makeSessionStarted('/tmp/bad')), version: 2 } as unknown as LogEntryInput, + { ...(yield* makeSessionStarted('/tmp/bad')), cwd: 42 } as unknown as LogEntryInput, ) .pipe(Effect.flip) }).pipe(Effect.provide(testLayer)) diff --git a/packages/fold-core/test/Projection/Projection.vi.test.ts b/packages/fold-core/test/Projection/Projection.vi.test.ts index f22fbf4..6b01a1e 100644 --- a/packages/fold-core/test/Projection/Projection.vi.test.ts +++ b/packages/fold-core/test/Projection/Projection.vi.test.ts @@ -84,7 +84,6 @@ const appendRoot = (tools: ReadonlyArray = ['read']) => agentId: null, parentAgentId: null, toolCallId: null, - version: 1, cwd: '/tmp/project', sessionId: yield* ids.makeSessionId, rootAgentId, From 17b91682c82398bef700b444833ee5eb64f8ac1f Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Fri, 14 Aug 2026 19:52:42 -0700 Subject: [PATCH 2/3] feat: dispatch persisted Fold events by version HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a002e8-7aeb-7845-a1f7-b46b966b896a --- .../fold-agent/src/EventLog/JsonlLayer.ts | 27 ++++++-- .../test/EventLog/EventLogJsonl.vi.test.ts | 33 ++++++++- .../test/Session/TitleGenerator.vi.test.ts | 3 + .../fold-cli/test/PromptRenderer.vi.test.ts | 1 + packages/fold-cli/test/Renderer.vi.test.ts | 11 +++ .../fold-cli/test/TuiSubagents.vi.test.ts | 2 + .../fold-cli/test/fixtures/TuiAppFixture.tsx | 9 +++ packages/fold-core/src/EventLog/Errors.ts | 18 ++++- .../fold-core/src/EventLog/LogEntryFactory.ts | 4 +- packages/fold-core/src/EventLog/Schemas.ts | 66 ++++++++++++------ .../src/EventLog/StoredLogEntryDecoder.ts | 65 +++++++++++++++++ packages/fold-core/src/index.ts | 1 + .../EventLog/StoredLogEntryDecoder.vi.test.ts | 69 +++++++++++++++++++ 13 files changed, 279 insertions(+), 30 deletions(-) create mode 100644 packages/fold-core/src/EventLog/StoredLogEntryDecoder.ts create mode 100644 packages/fold-core/test/EventLog/StoredLogEntryDecoder.vi.test.ts diff --git a/packages/fold-agent/src/EventLog/JsonlLayer.ts b/packages/fold-agent/src/EventLog/JsonlLayer.ts index a50b431..9d9129b 100644 --- a/packages/fold-agent/src/EventLog/JsonlLayer.ts +++ b/packages/fold-agent/src/EventLog/JsonlLayer.ts @@ -6,8 +6,10 @@ import { EventLogCorruptEntryError, EventLogInvalidEntryError, EventLogUnavailableError, + EventLogUnsupportedVersionError, Ids, LogEntry as LogEntrySchema, + decodeStoredLogEntry, layerLiveIdFactory, makeStoredLogEntry, type EventLogError, @@ -58,7 +60,10 @@ const jsonlLines = (contents: string): ReadonlyArray => { return contents.split('\n') } -const decodeJsonlLine = (line: string, lineNumber: number): Effect.Effect => +const decodeJsonlLine = ( + line: string, + lineNumber: number, +): Effect.Effect => Effect.gen(function* () { if (line.length === 0) { return yield* corruptEntryError(lineNumber, `Empty JSONL line at line ${lineNumber}`) @@ -68,9 +73,14 @@ const decodeJsonlLine = (line: string, lineNumber: number): Effect.Effect JSON.parse(line), catch: (cause) => corruptEntryError(lineNumber, `Invalid JSON at line ${lineNumber}`, cause), }) - const entry = yield* Schema.decodeUnknownEffect(LogEntrySchema)(parsed).pipe( - Effect.mapError((cause) => - corruptEntryError(lineNumber, `Invalid EventLog entry at line ${lineNumber}`, cause), + const entry = yield* decodeStoredLogEntry(parsed).pipe( + Effect.catchTag('EventLogCorruptEntryError', (error) => + corruptEntryError( + lineNumber, + `Invalid EventLog entry at line ${lineNumber}`, + error.cause ?? error, + error.seq, + ), ), ) const expectedSeq = lineNumber - 1 @@ -87,7 +97,9 @@ const decodeJsonlLine = (line: string, lineNumber: number): Effect.Effect, EventLogCorruptEntryError> => +const decodeJsonl = ( + contents: string, +): Effect.Effect, EventLogCorruptEntryError | EventLogUnsupportedVersionError> => Effect.forEach(jsonlLines(contents), (line, index) => decodeJsonlLine(line, index + 1), { concurrency: 1 }) const encodeJsonlLine = (entry: LogEntry): Effect.Effect => @@ -105,7 +117,10 @@ const encodeJsonlLine = (entry: LogEntry): Effect.Effect, EventLogCorruptEntryError | EventLogUnavailableError> => +): Effect.Effect< + ReadonlyArray, + EventLogCorruptEntryError | EventLogUnsupportedVersionError | EventLogUnavailableError +> => Effect.gen(function* () { yield* fs .makeDirectory(dirname(filePath), { recursive: true }) diff --git a/packages/fold-agent/test/EventLog/EventLogJsonl.vi.test.ts b/packages/fold-agent/test/EventLog/EventLogJsonl.vi.test.ts index 89cccbf..7dbdf01 100644 --- a/packages/fold-agent/test/EventLog/EventLogJsonl.vi.test.ts +++ b/packages/fold-agent/test/EventLog/EventLogJsonl.vi.test.ts @@ -7,6 +7,7 @@ import { EventId, EventLog, EventLogCorruptEntryError, + EventLogUnsupportedVersionError, MessageId, SessionId, StateId, @@ -21,7 +22,6 @@ const makeSessionStarted = (cwd: string): LogEntryInput => ({ agentId: null, parentAgentId: null, toolCallId: null, - version: 1, cwd, sessionId: SessionId.create(), rootAgentId: AgentId.create(), @@ -174,6 +174,37 @@ it.effect('jsonl layer replays assistant usage when cache fields are absent', () expect(entry.finish?.usage.inputTokens?.cacheWrite).toBeUndefined() expect(entry.finish?.usage.inputTokens?.cacheRead).toBe(0) expect(entry.finish?.usage.outputTokens?.total).toBe(2) + expect(entry.version).toBe(1) + }), + ).pipe(Effect.provide(NodeFileSystem.layer)), +) + +it.effect('jsonl layer rejects event formats newer than the installed Fold runtime', () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const dir = yield* fs.makeTempDirectoryScoped({ prefix: 'fold-event-log-' }) + const filePath = join(dir, 'future-version.jsonl') + const line = JSON.stringify({ + ...makeSessionStarted('/tmp/future-version'), + seq: 0, + eventId: EventId.create(), + ts: 1, + version: 2, + }) + + yield* fs.writeFileString(filePath, `${line}\n`) + + const error = yield* Effect.gen(function* () { + const log = yield* EventLog + return yield* Stream.runCollect(log.entries()) + }).pipe(Effect.provide(layerJsonl(filePath)), Effect.flip) + + expect(error).toBeInstanceOf(EventLogUnsupportedVersionError) + if (error instanceof EventLogUnsupportedVersionError) { + expect(error.version).toBe(2) + expect(error.supportedVersions).toEqual([1]) + } }), ).pipe(Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-agent/test/Session/TitleGenerator.vi.test.ts b/packages/fold-agent/test/Session/TitleGenerator.vi.test.ts index e8bea91..752ab4a 100644 --- a/packages/fold-agent/test/Session/TitleGenerator.vi.test.ts +++ b/packages/fold-agent/test/Session/TitleGenerator.vi.test.ts @@ -17,6 +17,7 @@ const entries: ReadonlyArray = [ seq: 1, eventId: EventId.create(), ts: 1, + version: 1, agentId: root, parentAgentId: null, toolCallId: null, @@ -28,6 +29,7 @@ const entries: ReadonlyArray = [ seq: 2, eventId: EventId.create(), ts: 2, + version: 1, agentId: root, parentAgentId: null, toolCallId: null, @@ -40,6 +42,7 @@ const entries: ReadonlyArray = [ seq: 3, eventId: EventId.create(), ts: 3, + version: 1, agentId: AgentId.make('agent_bbbbbbbbbbbbbbbbbbbbbbbb'), parentAgentId: null, toolCallId: null, diff --git a/packages/fold-cli/test/PromptRenderer.vi.test.ts b/packages/fold-cli/test/PromptRenderer.vi.test.ts index ca78429..7abca28 100644 --- a/packages/fold-cli/test/PromptRenderer.vi.test.ts +++ b/packages/fold-cli/test/PromptRenderer.vi.test.ts @@ -10,6 +10,7 @@ const finished = (resultText: string | null): AgentFinishedLogEntry => ({ seq: 3, eventId: EventId.create(), ts: 1, + version: 1, agentId: AgentId.make('agent_aaaaaaaaaaaaaaaaaaaaaaaa'), parentAgentId: null, toolCallId: null, diff --git a/packages/fold-cli/test/Renderer.vi.test.ts b/packages/fold-cli/test/Renderer.vi.test.ts index 568fd20..a7ded5a 100644 --- a/packages/fold-cli/test/Renderer.vi.test.ts +++ b/packages/fold-cli/test/Renderer.vi.test.ts @@ -41,6 +41,7 @@ it.effect('renders the session id in the header and finish line', () => seq: 2, eventId: EventId.create(), ts: 1, + version: 1, agentId, parentAgentId: null, toolCallId: null, @@ -60,6 +61,7 @@ it.effect('renders the session id in the header and finish line', () => seq: 3, eventId: EventId.create(), ts: 1, + version: 1, agentId, parentAgentId: null, toolCallId: null, @@ -101,6 +103,7 @@ it.effect('json renderer emits only log rows in concise mode and finish is not d seq: 7, eventId: EventId.create(), ts: 1, + version: 1 as const, agentId, parentAgentId: null, toolCallId: null, @@ -192,6 +195,7 @@ it.effect('renders profile-based resume command when the session used --profile' seq: 1, eventId: EventId.create(), ts: 1, + version: 1, agentId, parentAgentId: null, toolCallId: null, @@ -271,6 +275,7 @@ it.effect('with a catalog entry the usage table shows a real cost and the catalo seq: 2, eventId: EventId.create(), ts: 1, + version: 1, agentId, parentAgentId: null, toolCallId: null, @@ -290,6 +295,7 @@ it.effect('with a catalog entry the usage table shows a real cost and the catalo seq: 3, eventId: EventId.create(), ts: 1, + version: 1, agentId, parentAgentId: null, toolCallId: null, @@ -346,6 +352,7 @@ it.effect('tags every subagent line with its bracket label and keeps interleaved seq: 1, eventId: EventId.create(), ts: 1, + version: 1, agentId: rootId, parentAgentId: null, toolCallId: null, @@ -364,6 +371,7 @@ it.effect('tags every subagent line with its bracket label and keeps interleaved seq: 2, eventId: EventId.create(), ts: 1, + version: 1, agentId: subId, parentAgentId: rootId, toolCallId, @@ -399,6 +407,7 @@ it.effect('tags every subagent line with its bracket label and keeps interleaved seq: 3, eventId: EventId.create(), ts: 1, + version: 1, agentId: subId, parentAgentId: rootId, toolCallId, @@ -424,6 +433,7 @@ it.effect('tags every subagent line with its bracket label and keeps interleaved seq: 4, eventId: EventId.create(), ts: 1, + version: 1, agentId: subId, parentAgentId: rootId, toolCallId, @@ -439,6 +449,7 @@ it.effect('tags every subagent line with its bracket label and keeps interleaved seq: 5, eventId: EventId.create(), ts: 1, + version: 1, agentId: subId, parentAgentId: rootId, toolCallId, diff --git a/packages/fold-cli/test/TuiSubagents.vi.test.ts b/packages/fold-cli/test/TuiSubagents.vi.test.ts index b6a160d..fb37c91 100644 --- a/packages/fold-cli/test/TuiSubagents.vi.test.ts +++ b/packages/fold-cli/test/TuiSubagents.vi.test.ts @@ -8,6 +8,7 @@ const startedEntry = (agentId: string, seq: number, ts: number): AgentStartedLog seq, eventId: EventId.create(), ts, + version: 1, agentId: AgentId.make(agentId), parentAgentId: AgentId.make('agent_aaaaaaaaaaaaaaaaaaaaaaaa'), toolCallId: null, @@ -66,6 +67,7 @@ describe('skillViews', () => { seq: 1, eventId: EventId.create(), ts: 1, + version: 1, agentId, parentAgentId: null, toolCallId: null, diff --git a/packages/fold-cli/test/fixtures/TuiAppFixture.tsx b/packages/fold-cli/test/fixtures/TuiAppFixture.tsx index e96859e..5e5e964 100644 --- a/packages/fold-cli/test/fixtures/TuiAppFixture.tsx +++ b/packages/fold-cli/test/fixtures/TuiAppFixture.tsx @@ -48,6 +48,7 @@ const overflowSubagentEntries: ReadonlyArray = seq: 100 + index * 2, eventId: EventId.create(), ts: 100 + index * 2, + version: 1, agentId: rootAgentId, parentAgentId: null, toolCallId: null, @@ -75,6 +76,7 @@ const overflowSubagentEntries: ReadonlyArray = seq: 101 + index * 2, eventId: EventId.create(), ts: 101 + index * 2, + version: 1, agentId, parentAgentId: rootAgentId, toolCallId, @@ -94,6 +96,7 @@ const subagentEntries: ReadonlyArray = [ seq: 0, eventId: EventId.create(), ts: 0, + version: 1, agentId: rootAgentId, parentAgentId: null, toolCallId: null, @@ -109,6 +112,7 @@ const subagentEntries: ReadonlyArray = [ seq: 2, eventId: EventId.create(), ts: 2, + version: 1, agentId: rootAgentId, parentAgentId: null, toolCallId: null, @@ -129,6 +133,7 @@ const subagentEntries: ReadonlyArray = [ seq: 6, eventId: EventId.create(), ts: 6, + version: 1, agentId: rootAgentId, parentAgentId: null, toolCallId: null, @@ -154,6 +159,7 @@ const subagentEntries: ReadonlyArray = [ seq: 3, eventId: EventId.create(), ts: 3, + version: 1, agentId: researcherAgentId, parentAgentId: null, toolCallId: null, @@ -172,6 +178,7 @@ const subagentEntries: ReadonlyArray = [ seq: 4, eventId: EventId.create(), ts: 4, + version: 1, agentId: researcherAgentId, parentAgentId: null, toolCallId: null, @@ -197,6 +204,7 @@ const subagentEntries: ReadonlyArray = [ seq: 5, eventId: EventId.create(), ts: 5, + version: 1, agentId: researcherAgentId, parentAgentId: null, toolCallId: null, @@ -211,6 +219,7 @@ const subagentEntries: ReadonlyArray = [ seq: 1, eventId: EventId.create(), ts: 1, + version: 1, agentId: researcherAgentId, parentAgentId: rootAgentId, toolCallId: subagentToolCallId, diff --git a/packages/fold-core/src/EventLog/Errors.ts b/packages/fold-core/src/EventLog/Errors.ts index 137a948..ce9aad3 100644 --- a/packages/fold-core/src/EventLog/Errors.ts +++ b/packages/fold-core/src/EventLog/Errors.ts @@ -39,5 +39,21 @@ export class EventLogCorruptEntryError extends Schema.TaggedError()( + 'EventLogUnsupportedVersionError', + { + operation: EventLogOperation, + message: Schema.String, + version: Schema.Int, + seq: Schema.optional(Schema.Int), + supportedVersions: Schema.Array(Schema.Int), + }, +) {} + /** Public EventLog error union. */ -export type EventLogError = EventLogInvalidEntryError | EventLogUnavailableError | EventLogCorruptEntryError +export type EventLogError = + | EventLogInvalidEntryError + | EventLogUnavailableError + | EventLogCorruptEntryError + | EventLogUnsupportedVersionError diff --git a/packages/fold-core/src/EventLog/LogEntryFactory.ts b/packages/fold-core/src/EventLog/LogEntryFactory.ts index 10481d9..7bcfe16 100644 --- a/packages/fold-core/src/EventLog/LogEntryFactory.ts +++ b/packages/fold-core/src/EventLog/LogEntryFactory.ts @@ -2,7 +2,7 @@ import { Clock, Effect, Schema } from 'effect' import type { IdsService } from '../Ids' import { EventLogInvalidEntryError } from './Errors' -import { LogEntry, LogEntryInput, type LogSeq } from './Schemas' +import { CURRENT_LOG_ENTRY_VERSION, LogEntry, LogEntryInput, type LogSeq } from './Schemas' const invalidEntryError = (message: string, cause: unknown) => new EventLogInvalidEntryError({ @@ -29,6 +29,6 @@ export const makeStoredLogEntry = ( seq, eventId, ts, - version: 1, + version: CURRENT_LOG_ENTRY_VERSION, }).pipe(Effect.mapError((cause) => invalidEntryError('Invalid stored EventLog entry', cause))) }) diff --git a/packages/fold-core/src/EventLog/Schemas.ts b/packages/fold-core/src/EventLog/Schemas.ts index e30d273..24ab396 100644 --- a/packages/fold-core/src/EventLog/Schemas.ts +++ b/packages/fold-core/src/EventLog/Schemas.ts @@ -16,11 +16,33 @@ export const EpochMillis = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)) }) export type EpochMillis = typeof EpochMillis.Type -/** The durable event format version written on every stored log entry. */ -export const LogVersion = Schema.Literal(1) +/** The event format emitted by this Fold runtime. Readers may support additional historical versions. */ +export const CURRENT_LOG_ENTRY_VERSION = 1 as const + +/** All event formats this Fold runtime can decode. Useful for host capability negotiation. */ +export const SUPPORTED_LOG_ENTRY_VERSIONS = [CURRENT_LOG_ENTRY_VERSION] as const + +/** A version found at the persisted-entry boundary, including versions this runtime may not support. */ +export const StoredLogEntryVersion = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).annotate({ + identifier: 'StoredLogEntryVersion', +}) +export type StoredLogEntryVersion = typeof StoredLogEntryVersion.Type + +/** The current durable event format version. */ +export const LogVersion = Schema.Literal(CURRENT_LOG_ENTRY_VERSION) export type LogVersion = typeof LogVersion.Type -const StoredLogEntryEnvelope = { +/** Version-neutral envelope decoded before dispatching to a historical entry schema. */ +export const StoredLogEntryEnvelope = Schema.Struct({ + seq: LogSeq, + eventId: EventId, + ts: EpochMillis, + // Entries written before per-entry versioning are legacy v1 and omit this field. + version: Schema.optional(StoredLogEntryVersion), +}).annotate({ identifier: 'StoredLogEntryEnvelope' }) +export type StoredLogEntryEnvelope = typeof StoredLogEntryEnvelope.Type + +const CurrentStoredLogEntryEnvelope = { seq: LogSeq, eventId: EventId, ts: EpochMillis, @@ -216,7 +238,7 @@ export type SessionStartedLogEntryInput = typeof SessionStartedLogEntryInput.Typ /** Stored session started log entry. */ export const SessionStartedLogEntry = Schema.TaggedStruct('session_started', { - ...StoredLogEntryEnvelope, + ...CurrentStoredLogEntryEnvelope, agentId: Schema.Null, parentAgentId: Schema.Null, toolCallId: Schema.Null, @@ -247,7 +269,7 @@ export type AgentStartedLogEntryInput = typeof AgentStartedLogEntryInput.Type /** Stored agent started log entry. */ export const AgentStartedLogEntry = Schema.TaggedStruct('agent_started', { - ...StoredLogEntryEnvelope, + ...CurrentStoredLogEntryEnvelope, agentId: AgentId, parentAgentId: Schema.NullOr(AgentId), toolCallId: Schema.NullOr(ToolCallId), @@ -285,7 +307,7 @@ export type SystemMessageLogEntryInput = typeof SystemMessageLogEntryInput.Type /** Log entry for a system message block set. */ export const SystemMessageLogEntry = Schema.TaggedStruct('system-message', { - ...StoredLogEntryEnvelope, + ...CurrentStoredLogEntryEnvelope, agentId: AgentId, parentAgentId: Schema.NullOr(AgentId), toolCallId: Schema.NullOr(ToolCallId), @@ -311,7 +333,7 @@ export type UserMessageLogEntryInput = typeof UserMessageLogEntryInput.Type /** Log entry for user messages. */ export const UserMessageLogEntry = Schema.TaggedStruct('user-message', { - ...StoredLogEntryEnvelope, + ...CurrentStoredLogEntryEnvelope, agentId: AgentId, parentAgentId: Schema.NullOr(AgentId), toolCallId: Schema.NullOr(ToolCallId), @@ -342,7 +364,7 @@ export type AssistantMessageLogEntryInput = typeof AssistantMessageLogEntryInput /** Log entry for assistant messages. */ export const AssistantMessageLogEntry = Schema.TaggedStruct('assistant-message', { - ...StoredLogEntryEnvelope, + ...CurrentStoredLogEntryEnvelope, agentId: AgentId, parentAgentId: Schema.NullOr(AgentId), toolCallId: Schema.NullOr(ToolCallId), @@ -372,7 +394,7 @@ export type ToolResultLogEntryInput = typeof ToolResultLogEntryInput.Type /** Log entry for tool results. */ export const ToolResultLogEntry = Schema.TaggedStruct('tool-result', { - ...StoredLogEntryEnvelope, + ...CurrentStoredLogEntryEnvelope, agentId: AgentId, parentAgentId: Schema.NullOr(AgentId), toolCallId: ToolCallId, @@ -396,7 +418,7 @@ export type ToolStateLogEntryInput = typeof ToolStateLogEntryInput.Type /** Schema for a tool state update log entry. toolCallId is null when a hook writes outside a tool call. */ export const ToolStateLogEntry = Schema.TaggedStruct('tool_state', { - ...StoredLogEntryEnvelope, + ...CurrentStoredLogEntryEnvelope, agentId: AgentId, parentAgentId: Schema.NullOr(AgentId), toolCallId: Schema.NullOr(ToolCallId), @@ -425,7 +447,7 @@ export type CompactionLogEntryInput = typeof CompactionLogEntryInput.Type /** Schema for a compaction log entry. */ export const CompactionLogEntry = Schema.TaggedStruct('compaction', { - ...StoredLogEntryEnvelope, + ...CurrentStoredLogEntryEnvelope, agentId: AgentId, parentAgentId: Schema.NullOr(AgentId), toolCallId: Schema.NullOr(ToolCallId), @@ -454,7 +476,7 @@ export type ModelChangeLogEntryInput = typeof ModelChangeLogEntryInput.Type /** Schema for model change log entry. */ export const ModelChangeLogEntry = Schema.TaggedStruct('model-change', { - ...StoredLogEntryEnvelope, + ...CurrentStoredLogEntryEnvelope, agentId: AgentId, parentAgentId: Schema.NullOr(AgentId), toolCallId: Schema.NullOr(ToolCallId), @@ -479,7 +501,7 @@ export type ThinkingChangeLogEntryInput = typeof ThinkingChangeLogEntryInput.Typ /** Schema for thinking / reasoning setting changes. */ export const ThinkingChangeLogEntry = Schema.TaggedStruct('thinking-change', { - ...StoredLogEntryEnvelope, + ...CurrentStoredLogEntryEnvelope, agentId: AgentId, parentAgentId: Schema.NullOr(AgentId), toolCallId: Schema.NullOr(ToolCallId), @@ -504,7 +526,7 @@ export type ToolsChangeLogEntryInput = typeof ToolsChangeLogEntryInput.Type /** Schema for active toolset changes. */ export const ToolsChangeLogEntry = Schema.TaggedStruct('tools-change', { - ...StoredLogEntryEnvelope, + ...CurrentStoredLogEntryEnvelope, agentId: AgentId, parentAgentId: Schema.NullOr(AgentId), toolCallId: Schema.NullOr(ToolCallId), @@ -536,7 +558,7 @@ export type AgentFinishedLogEntryInput = typeof AgentFinishedLogEntryInput.Type /** Schema for an agent's terminal state. */ export const AgentFinishedLogEntry = Schema.TaggedStruct('agent-finished', { - ...StoredLogEntryEnvelope, + ...CurrentStoredLogEntryEnvelope, agentId: AgentId, parentAgentId: Schema.NullOr(AgentId), toolCallId: Schema.NullOr(ToolCallId), @@ -561,7 +583,7 @@ export type SessionTitleLogEntryInput = typeof SessionTitleLogEntryInput.Type /** Stored session title. The latest entry is the session's authoritative title. */ export const SessionTitleLogEntry = Schema.TaggedStruct('session_title', { - ...StoredLogEntryEnvelope, + ...CurrentStoredLogEntryEnvelope, agentId: Schema.Null, parentAgentId: Schema.Null, toolCallId: Schema.Null, @@ -584,7 +606,7 @@ export type ErrorLogEntryInput = typeof ErrorLogEntryInput.Type /** Schema for a durable error note in the log. */ export const ErrorLogEntry = Schema.TaggedStruct('error', { - ...StoredLogEntryEnvelope, + ...CurrentStoredLogEntryEnvelope, agentId: Schema.NullOr(AgentId), parentAgentId: Schema.NullOr(AgentId), toolCallId: Schema.NullOr(ToolCallId), @@ -613,8 +635,8 @@ export const LogEntryInput = Schema.Union([ ]).annotate({ identifier: 'LogEntryInput', discriminator: '_tag' }) export type LogEntryInput = typeof LogEntryInput.Type -/** Stored log entry schema. */ -export const LogEntry = Schema.Union([ +/** Frozen wire schema for persisted v1 entries. Add a new schema rather than changing incompatible v1 fields. */ +export const LogEntryV1 = Schema.Union([ SessionStartedLogEntry, AgentStartedLogEntry, SystemMessageLogEntry, @@ -629,6 +651,10 @@ export const LogEntry = Schema.Union([ AgentFinishedLogEntry, SessionTitleLogEntry, ErrorLogEntry, -]).annotate({ identifier: 'LogEntry', discriminator: '_tag' }) +]).annotate({ identifier: 'LogEntryV1', discriminator: '_tag' }) +export type LogEntryV1 = typeof LogEntryV1.Type + +/** Current in-memory log entry model. Historical wire entries are upcast to this type while decoding. */ +export const LogEntry = LogEntryV1 export type LogEntry = typeof LogEntry.Type export type LogEntryEncoded = typeof LogEntry.Encoded diff --git a/packages/fold-core/src/EventLog/StoredLogEntryDecoder.ts b/packages/fold-core/src/EventLog/StoredLogEntryDecoder.ts new file mode 100644 index 0000000..1bd03b2 --- /dev/null +++ b/packages/fold-core/src/EventLog/StoredLogEntryDecoder.ts @@ -0,0 +1,65 @@ +import { Effect, Schema } from 'effect' + +import { EventLogCorruptEntryError, EventLogUnsupportedVersionError } from './Errors' +import { + CURRENT_LOG_ENTRY_VERSION, + LogEntryV1, + SUPPORTED_LOG_ENTRY_VERSIONS, + StoredLogEntryEnvelope, + type LogEntry, +} from './Schemas' + +const PersistedRecord = Schema.Record(Schema.String, Schema.Unknown) + +const corruptEntry = (message: string, cause: unknown, seq?: number) => + new EventLogCorruptEntryError({ + operation: 'entries', + message, + ...(seq === undefined ? {} : { seq }), + cause, + }) + +/** + * Decode one persisted Fold event by its wire-format version. + * + * Fold runtimes carry every historical decoder they support; hosts do not load a package dynamically + * or interpret versions themselves. Each decoder upcasts its historical wire shape to the current + * {@link LogEntry} model. Writers always emit {@link CURRENT_LOG_ENTRY_VERSION}. When a future v2 is + * introduced, keep `LogEntryV1` unchanged, add `LogEntryV2` plus its upcaster here, and add `2` to + * `SUPPORTED_LOG_ENTRY_VERSIONS`. + * + * Events written before per-entry versioning are treated as legacy v1. Unknown versions fail explicitly + * so an older runtime never guesses how to replay newer state. + */ +export const decodeStoredLogEntry = Effect.fn('fold.event_log.decode_stored_entry')( + (input: unknown) => + Effect.gen(function* () { + const record = yield* Schema.decodeUnknownEffect(PersistedRecord)(input).pipe( + Effect.mapError((cause) => corruptEntry('Persisted EventLog entry is not an object', cause)), + ) + const envelope = yield* Schema.decodeUnknownEffect(StoredLogEntryEnvelope)(record).pipe( + Effect.mapError((cause) => corruptEntry('Persisted EventLog entry has an invalid envelope', cause)), + ) + const version = envelope.version ?? CURRENT_LOG_ENTRY_VERSION + + switch (version) { + case 1: + return yield* Schema.decodeUnknownEffect(LogEntryV1)({ + ...record, + version: CURRENT_LOG_ENTRY_VERSION, + }).pipe( + Effect.mapError((cause) => + corruptEntry('Persisted EventLog v1 entry has an invalid payload', cause, envelope.seq), + ), + ) + default: + return yield* new EventLogUnsupportedVersionError({ + operation: 'entries', + message: `Fold event format v${version} is not supported by this runtime`, + version, + seq: envelope.seq, + supportedVersions: [...SUPPORTED_LOG_ENTRY_VERSIONS], + }) + } + }) satisfies Effect.Effect, +) diff --git a/packages/fold-core/src/index.ts b/packages/fold-core/src/index.ts index 036252a..6a73192 100644 --- a/packages/fold-core/src/index.ts +++ b/packages/fold-core/src/index.ts @@ -27,6 +27,7 @@ export * from './EventLog/EventLogLayerMemory' export * from './EventLog/EventLogService' export * from './EventLog/LogEntryFactory' export * from './EventLog/Schemas' +export * from './EventLog/StoredLogEntryDecoder' export * from './EventLog/Usage' export * from './HookRunner/HookRunnerLayer' export * from './HookRunner/Errors' diff --git a/packages/fold-core/test/EventLog/StoredLogEntryDecoder.vi.test.ts b/packages/fold-core/test/EventLog/StoredLogEntryDecoder.vi.test.ts new file mode 100644 index 0000000..d4b5916 --- /dev/null +++ b/packages/fold-core/test/EventLog/StoredLogEntryDecoder.vi.test.ts @@ -0,0 +1,69 @@ +import { it, expect } from '@effect/vitest' +import { Effect } from 'effect' + +import { + AgentId, + CURRENT_LOG_ENTRY_VERSION, + EventId, + EventLogUnsupportedVersionError, + SessionId, + SUPPORTED_LOG_ENTRY_VERSIONS, + decodeStoredLogEntry, +} from '../../src/index' + +const sessionStartedEntry = (version?: number) => ({ + _tag: 'session_started', + seq: 0, + eventId: EventId.create(), + ts: 1, + ...(version === undefined ? {} : { version }), + agentId: null, + parentAgentId: null, + toolCallId: null, + cwd: '/tmp/fold', + sessionId: SessionId.create(), + rootAgentId: AgentId.create(), + meta: {}, +}) + +const legacySessionTitleEntry = () => ({ + _tag: 'session_title', + seq: 1, + eventId: EventId.create(), + ts: 2, + agentId: null, + parentAgentId: null, + toolCallId: null, + title: 'Legacy session', +}) + +it.effect('decodes the current persisted event format', () => + Effect.gen(function* () { + const entry = yield* decodeStoredLogEntry(sessionStartedEntry(CURRENT_LOG_ENTRY_VERSION)) + + expect(entry.version).toBe(CURRENT_LOG_ENTRY_VERSION) + expect(SUPPORTED_LOG_ENTRY_VERSIONS).toEqual([CURRENT_LOG_ENTRY_VERSION]) + }), +) + +it.effect('upcasts entries written before per-entry versioning to v1', () => + Effect.gen(function* () { + const entry = yield* decodeStoredLogEntry(legacySessionTitleEntry()) + + expect(entry.version).toBe(1) + expect(entry._tag).toBe('session_title') + }), +) + +it.effect('rejects an unsupported event format without guessing its schema', () => + Effect.gen(function* () { + const error = yield* decodeStoredLogEntry(sessionStartedEntry(2)).pipe(Effect.flip) + + expect(error).toBeInstanceOf(EventLogUnsupportedVersionError) + if (error instanceof EventLogUnsupportedVersionError) { + expect(error.version).toBe(2) + expect(error.seq).toBe(0) + expect(error.supportedVersions).toEqual([1]) + } + }), +) From d35af9cb38b1cd67398b85a06697b45da2524982 Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Fri, 14 Aug 2026 19:57:14 -0700 Subject: [PATCH 3/3] fix: update Fold event fixtures for versioning HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a002e8-7aeb-7845-a1f7-b46b966b896a --- packages/fold-agent/src/EventLog/JsonlLayer.ts | 2 +- packages/fold-cli/test/tui/SessionState.vi.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/fold-agent/src/EventLog/JsonlLayer.ts b/packages/fold-agent/src/EventLog/JsonlLayer.ts index 9d9129b..f2d9128 100644 --- a/packages/fold-agent/src/EventLog/JsonlLayer.ts +++ b/packages/fold-agent/src/EventLog/JsonlLayer.ts @@ -6,7 +6,6 @@ import { EventLogCorruptEntryError, EventLogInvalidEntryError, EventLogUnavailableError, - EventLogUnsupportedVersionError, Ids, LogEntry as LogEntrySchema, decodeStoredLogEntry, @@ -14,6 +13,7 @@ import { makeStoredLogEntry, type EventLogError, type EventLogService, + type EventLogUnsupportedVersionError, type LogEntry, type LogEntryInput, type LogSeq, diff --git a/packages/fold-cli/test/tui/SessionState.vi.test.ts b/packages/fold-cli/test/tui/SessionState.vi.test.ts index c734d67..7b238ee 100644 --- a/packages/fold-cli/test/tui/SessionState.vi.test.ts +++ b/packages/fold-cli/test/tui/SessionState.vi.test.ts @@ -14,7 +14,7 @@ const rootAgentId = Schema.decodeUnknownSync(AgentId)('agent_aaaaaaaaaaaaaaaaaaa const childAgentId = Schema.decodeUnknownSync(AgentId)('agent_bbbbbbbbbbbbbbbbbbbbbbbb') const entry = (input: Record) => - Schema.decodeUnknownSync(LogEntry)({ ...input, eventId: EventId.create() }) + Schema.decodeUnknownSync(LogEntry)({ ...input, eventId: EventId.create(), version: 1 }) const assistant = (seq: number, agentId = rootAgentId) => entry({ _tag: 'assistant-message',