From 2b767d8cb14489970bf3ed1f4aae655476b47139 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 28 Jul 2026 11:18:37 -0400 Subject: [PATCH] feat(session): add durable session archival --- packages/client/src/effect/api/api.ts | 138 ++++++++++-------- .../client/src/effect/generated/client.ts | 122 ++++++++-------- .../client/src/promise/generated/client.ts | 13 ++ .../client/src/promise/generated/types.ts | 16 ++ packages/client/test/promise.test.ts | 2 + packages/core/src/session.ts | 6 + packages/core/src/session/info.ts | 2 +- packages/core/src/session/message-updater.ts | 1 + packages/core/src/session/projector.ts | 11 ++ packages/core/test/session-create.test.ts | 28 ++++ packages/protocol/src/groups/session.ts | 15 ++ packages/schema/src/session-event.ts | 8 + packages/schema/test/event-manifest.test.ts | 1 + packages/server/src/handlers/session.ts | 16 ++ 14 files changed, 259 insertions(+), 120 deletions(-) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 3bb65cc007..78aac160a0 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -152,15 +152,19 @@ export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly title: export type Endpoint5_8Output = void export type SessionRenameOperation = (input: Endpoint5_8Input) => Effect.Effect -export type Endpoint5_9Input = { +export type Endpoint5_9Input = { readonly sessionID: Session.ID } +export type Endpoint5_9Output = void +export type SessionArchiveOperation = (input: Endpoint5_9Input) => Effect.Effect + +export type Endpoint5_10Input = { readonly sessionID: Session.ID readonly directory: AbsolutePath readonly workspaceID?: Workspace.ID | undefined } -export type Endpoint5_9Output = void -export type SessionMoveOperation = (input: Endpoint5_9Input) => Effect.Effect +export type Endpoint5_10Output = void +export type SessionMoveOperation = (input: Endpoint5_10Input) => Effect.Effect -export type Endpoint5_10Input = { +export type Endpoint5_11Input = { readonly sessionID: Session.ID readonly id?: SessionMessage.ID | undefined readonly text: string @@ -170,10 +174,10 @@ export type Endpoint5_10Input = { readonly delivery?: "steer" | "queue" | undefined readonly resume?: boolean | undefined } -export type Endpoint5_10Output = SessionPending.User -export type SessionPromptOperation = (input: Endpoint5_10Input) => Effect.Effect +export type Endpoint5_11Output = SessionPending.User +export type SessionPromptOperation = (input: Endpoint5_11Input) => Effect.Effect -export type Endpoint5_11Input = { +export type Endpoint5_12Input = { readonly sessionID: Session.ID readonly id?: SessionMessage.ID | undefined readonly command: string @@ -185,19 +189,19 @@ export type Endpoint5_11Input = { readonly delivery?: "steer" | "queue" | undefined readonly resume?: boolean | undefined } -export type Endpoint5_11Output = SessionPending.User -export type SessionCommandOperation = (input: Endpoint5_11Input) => Effect.Effect +export type Endpoint5_12Output = SessionPending.User +export type SessionCommandOperation = (input: Endpoint5_12Input) => Effect.Effect -export type Endpoint5_12Input = { +export type Endpoint5_13Input = { readonly sessionID: Session.ID readonly id?: SessionMessage.ID | undefined readonly skill: Skill.ID readonly resume?: boolean | undefined } -export type Endpoint5_12Output = void -export type SessionSkillOperation = (input: Endpoint5_12Input) => Effect.Effect +export type Endpoint5_13Output = void +export type SessionSkillOperation = (input: Endpoint5_13Input) => Effect.Effect -export type Endpoint5_13Input = { +export type Endpoint5_14Input = { readonly sessionID: Session.ID readonly id?: SessionMessage.ID | undefined readonly text: string @@ -206,81 +210,81 @@ export type Endpoint5_13Input = { readonly delivery?: "steer" | "queue" | undefined readonly resume?: boolean | undefined } -export type Endpoint5_13Output = SessionPending.Synthetic -export type SessionSyntheticOperation = (input: Endpoint5_13Input) => Effect.Effect +export type Endpoint5_14Output = SessionPending.Synthetic +export type SessionSyntheticOperation = (input: Endpoint5_14Input) => Effect.Effect -export type Endpoint5_14Input = { +export type Endpoint5_15Input = { readonly sessionID: Session.ID readonly id?: Event.ID | undefined readonly command: string } -export type Endpoint5_14Output = void -export type SessionShellOperation = (input: Endpoint5_14Input) => Effect.Effect +export type Endpoint5_15Output = void +export type SessionShellOperation = (input: Endpoint5_15Input) => Effect.Effect -export type Endpoint5_15Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined } -export type Endpoint5_15Output = SessionPending.Compaction -export type SessionCompactOperation = (input: Endpoint5_15Input) => Effect.Effect +export type Endpoint5_16Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined } +export type Endpoint5_16Output = SessionPending.Compaction +export type SessionCompactOperation = (input: Endpoint5_16Input) => Effect.Effect -export type Endpoint5_16Input = { readonly sessionID: Session.ID } -export type Endpoint5_16Output = void -export type SessionWaitOperation = (input: Endpoint5_16Input) => Effect.Effect +export type Endpoint5_17Input = { readonly sessionID: Session.ID } +export type Endpoint5_17Output = void +export type SessionWaitOperation = (input: Endpoint5_17Input) => Effect.Effect -export type Endpoint5_17Input = { +export type Endpoint5_18Input = { readonly sessionID: Session.ID readonly messageID: SessionMessage.ID readonly files?: boolean | undefined } -export type Endpoint5_17Output = Session.Revert -export type SessionRevertStageOperation = (input: Endpoint5_17Input) => Effect.Effect - -export type Endpoint5_18Input = { readonly sessionID: Session.ID } -export type Endpoint5_18Output = void -export type SessionRevertClearOperation = (input: Endpoint5_18Input) => Effect.Effect +export type Endpoint5_18Output = Session.Revert +export type SessionRevertStageOperation = (input: Endpoint5_18Input) => Effect.Effect export type Endpoint5_19Input = { readonly sessionID: Session.ID } export type Endpoint5_19Output = void -export type SessionRevertCommitOperation = (input: Endpoint5_19Input) => Effect.Effect +export type SessionRevertClearOperation = (input: Endpoint5_19Input) => Effect.Effect export type Endpoint5_20Input = { readonly sessionID: Session.ID } -export type Endpoint5_20Output = ReadonlyArray -export type SessionContextOperation = (input: Endpoint5_20Input) => Effect.Effect +export type Endpoint5_20Output = void +export type SessionRevertCommitOperation = (input: Endpoint5_20Input) => Effect.Effect export type Endpoint5_21Input = { readonly sessionID: Session.ID } -export type Endpoint5_21Output = ReadonlyArray -export type SessionPendingListOperation = (input: Endpoint5_21Input) => Effect.Effect +export type Endpoint5_21Output = ReadonlyArray +export type SessionContextOperation = (input: Endpoint5_21Input) => Effect.Effect export type Endpoint5_22Input = { readonly sessionID: Session.ID } -export type Endpoint5_22Output = ReadonlyArray -export type SessionInstructionsEntryListOperation = ( - input: Endpoint5_22Input, -) => Effect.Effect +export type Endpoint5_22Output = ReadonlyArray +export type SessionPendingListOperation = (input: Endpoint5_22Input) => Effect.Effect -export type Endpoint5_23Input = { +export type Endpoint5_23Input = { readonly sessionID: Session.ID } +export type Endpoint5_23Output = ReadonlyArray +export type SessionInstructionsEntryListOperation = ( + input: Endpoint5_23Input, +) => Effect.Effect + +export type Endpoint5_24Input = { readonly sessionID: Session.ID readonly key: InstructionEntry.Key readonly value: Schema.Json } -export type Endpoint5_23Output = void -export type SessionInstructionsEntryPutOperation = ( - input: Endpoint5_23Input, -) => Effect.Effect - -export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key } export type Endpoint5_24Output = void -export type SessionInstructionsEntryRemoveOperation = ( +export type SessionInstructionsEntryPutOperation = ( input: Endpoint5_24Input, ) => Effect.Effect -export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly prompt: string } -export type Endpoint5_25Output = { readonly text: string } -export type SessionGenerateOperation = (input: Endpoint5_25Input) => Effect.Effect +export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key } +export type Endpoint5_25Output = void +export type SessionInstructionsEntryRemoveOperation = ( + input: Endpoint5_25Input, +) => Effect.Effect -export type Endpoint5_26Input = { +export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly prompt: string } +export type Endpoint5_26Output = { readonly text: string } +export type SessionGenerateOperation = (input: Endpoint5_26Input) => Effect.Effect + +export type Endpoint5_27Input = { readonly sessionID: Session.ID readonly after?: Event.Seq | undefined readonly follow?: boolean | undefined } -export type Endpoint5_26Output = +export type Endpoint5_27Output = | ( | { readonly id: Event.ID @@ -323,6 +327,15 @@ export type Endpoint5_26Output = readonly location?: Location.Ref | undefined readonly data: { readonly sessionID: Session.ID; readonly title: string } } + | { + readonly id: Event.ID + readonly created: DateTime.Utc + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.archived" + readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } + readonly location?: Location.Ref | undefined + readonly data: { readonly sessionID: Session.ID } + } | { readonly id: Event.ID readonly created: DateTime.Utc @@ -825,19 +838,19 @@ export type Endpoint5_26Output = } ) | EventLog.Synced -export type SessionLogOperation = (input: Endpoint5_26Input) => Stream.Stream - -export type Endpoint5_27Input = { readonly sessionID: Session.ID } -export type Endpoint5_27Output = void -export type SessionInterruptOperation = (input: Endpoint5_27Input) => Effect.Effect +export type SessionLogOperation = (input: Endpoint5_27Input) => Stream.Stream export type Endpoint5_28Input = { readonly sessionID: Session.ID } export type Endpoint5_28Output = void -export type SessionBackgroundOperation = (input: Endpoint5_28Input) => Effect.Effect +export type SessionInterruptOperation = (input: Endpoint5_28Input) => Effect.Effect -export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID } -export type Endpoint5_29Output = SessionMessage.Info -export type SessionMessageOperation = (input: Endpoint5_29Input) => Effect.Effect +export type Endpoint5_29Input = { readonly sessionID: Session.ID } +export type Endpoint5_29Output = void +export type SessionBackgroundOperation = (input: Endpoint5_29Input) => Effect.Effect + +export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID } +export type Endpoint5_30Output = SessionMessage.Info +export type SessionMessageOperation = (input: Endpoint5_30Input) => Effect.Effect export interface SessionApi { readonly list: SessionListOperation @@ -849,6 +862,7 @@ export interface SessionApi { readonly switchAgent: SessionSwitchAgentOperation readonly switchModel: SessionSwitchModelOperation readonly rename: SessionRenameOperation + readonly archive: SessionArchiveOperation readonly move: SessionMoveOperation readonly prompt: SessionPromptOperation readonly command: SessionCommandOperation diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 8b457db56f..161f824b6d 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -76,6 +76,8 @@ import type { Endpoint5_28Output, Endpoint5_29Input, Endpoint5_29Output, + Endpoint5_30Input, + Endpoint5_30Output, Endpoint6_0Input, Endpoint6_0Output, Endpoint7_0Input, @@ -358,14 +360,19 @@ const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Inp const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) => preserveEffect()( + raw["session.archive"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), + ) + +const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) => + preserveEffect()( raw["session.move"]({ params: { sessionID: input["sessionID"] }, payload: { directory: input["directory"], workspaceID: input["workspaceID"] }, }).pipe(Effect.mapError(mapClientError)), ) -const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) => - preserveEffect()( +const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) => + preserveEffect()( raw["session.prompt"]({ params: { sessionID: input["sessionID"] }, payload: { @@ -383,8 +390,8 @@ const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10I ), ) -const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) => - preserveEffect()( +const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) => + preserveEffect()( raw["session.command"]({ params: { sessionID: input["sessionID"] }, payload: { @@ -404,16 +411,16 @@ const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11I ), ) -const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) => - preserveEffect()( +const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) => + preserveEffect()( raw["session.skill"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"], skill: input["skill"], resume: input["resume"] }, }).pipe(Effect.mapError(mapClientError)), ) -const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) => - preserveEffect()( +const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) => + preserveEffect()( raw["session.synthetic"]({ params: { sessionID: input["sessionID"] }, payload: { @@ -430,29 +437,29 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I ), ) -const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) => - preserveEffect()( +const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) => + preserveEffect()( raw["session.shell"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"], command: input["command"] }, }).pipe(Effect.mapError(mapClientError)), ) -const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) => - preserveEffect()( +const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) => + preserveEffect()( raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ), ) -const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) => - preserveEffect()( +const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) => + preserveEffect()( raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), ) -const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) => - preserveEffect()( +const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) => + preserveEffect()( raw["session.revert.stage"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"], files: input["files"] }, @@ -462,27 +469,19 @@ const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17I ), ) -const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) => - preserveEffect()( - raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), - ) - const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) => preserveEffect()( - raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), + raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), ) const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) => preserveEffect()( - raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( - Effect.mapError(mapClientError), - Effect.map((value) => value.data), - ), + raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), ) const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) => preserveEffect()( - raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe( + raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ), @@ -490,7 +489,7 @@ const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21I const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) => preserveEffect()( - raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe( + raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ), @@ -498,29 +497,37 @@ const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22I const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) => preserveEffect()( + raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ), + ) + +const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) => + preserveEffect()( raw["session.instructions.entry.put"]({ params: { sessionID: input["sessionID"], key: input["key"] }, payload: { value: input["value"] }, }).pipe(Effect.mapError(mapClientError)), ) -const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) => - preserveEffect()( +const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) => + preserveEffect()( raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe( Effect.mapError(mapClientError), ), ) -const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) => - preserveEffect()( +const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) => + preserveEffect()( raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ), ) -const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) => - preserveStream()( +const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) => + preserveStream()( Stream.unwrap( raw["session.log"]({ params: { sessionID: input["sessionID"] }, @@ -532,18 +539,18 @@ const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26I ), ) -const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) => - preserveEffect()( - raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), - ) - const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) => preserveEffect()( - raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), + raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), ) const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) => preserveEffect()( + raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), + ) + +const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) => + preserveEffect()( raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), @@ -560,23 +567,24 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({ switchAgent: Endpoint5_6(raw), switchModel: Endpoint5_7(raw), rename: Endpoint5_8(raw), - move: Endpoint5_9(raw), - prompt: Endpoint5_10(raw), - command: Endpoint5_11(raw), - skill: Endpoint5_12(raw), - synthetic: Endpoint5_13(raw), - shell: Endpoint5_14(raw), - compact: Endpoint5_15(raw), - wait: Endpoint5_16(raw), - revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) }, - context: Endpoint5_20(raw), - pending: { list: Endpoint5_21(raw) }, - instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } }, - generate: Endpoint5_25(raw), - log: Endpoint5_26(raw), - interrupt: Endpoint5_27(raw), - background: Endpoint5_28(raw), - message: Endpoint5_29(raw), + archive: Endpoint5_9(raw), + move: Endpoint5_10(raw), + prompt: Endpoint5_11(raw), + command: Endpoint5_12(raw), + skill: Endpoint5_13(raw), + synthetic: Endpoint5_14(raw), + shell: Endpoint5_15(raw), + compact: Endpoint5_16(raw), + wait: Endpoint5_17(raw), + revert: { stage: Endpoint5_18(raw), clear: Endpoint5_19(raw), commit: Endpoint5_20(raw) }, + context: Endpoint5_21(raw), + pending: { list: Endpoint5_22(raw) }, + instructions: { entry: { list: Endpoint5_23(raw), put: Endpoint5_24(raw), remove: Endpoint5_25(raw) } }, + generate: Endpoint5_26(raw), + log: Endpoint5_27(raw), + interrupt: Endpoint5_28(raw), + background: Endpoint5_29(raw), + message: Endpoint5_30(raw), }) const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) => diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index e6edc8a856..4e27c2a313 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -28,6 +28,8 @@ import type { SessionSwitchModelOutput, SessionRenameInput, SessionRenameOutput, + SessionArchiveInput, + SessionArchiveOutput, SessionMoveInput, SessionMoveOutput, SessionPromptInput, @@ -553,6 +555,17 @@ export function make(options: ClientOptions) { }, requestOptions, ), + archive: (input: SessionArchiveInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/archive`, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), move: (input: SessionMoveInput, requestOptions?: RequestOptions) => request( { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index fcd25cc09b..58ecc49f8a 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -611,6 +611,16 @@ export type SessionRenamed = { data: { sessionID: string; title: string } } +export type SessionArchived = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.archived" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { sessionID: string } +} + export type SessionDeleted = { id: string created: number @@ -2159,6 +2169,7 @@ export type SessionEventDurable = | SessionModelSelected | SessionMoved | SessionRenamed + | SessionArchived | SessionDeleted | SessionForked | SessionInputPromoted @@ -2253,6 +2264,7 @@ export type V2Event = | SessionModelSelected | SessionMoved | SessionRenamed + | SessionArchived | SessionUsageUpdated | SessionDeleted | SessionForked @@ -2730,6 +2742,10 @@ export type SessionRenameInput = { export type SessionRenameOutput = void +export type SessionArchiveInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionArchiveOutput = void + export type SessionMoveInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly directory: { readonly directory: string; readonly workspaceID?: string }["directory"] diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index c3c0512f17..23f53c4514 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -432,6 +432,7 @@ test("session methods use the public HTTP contract", async () => { sessionID: "ses_test", model: { id: "claude", providerID: "anthropic" }, }) + await client.session.archive({ sessionID: "ses_test" }) const admitted = await client.session.prompt({ sessionID: "ses_test", text: "Hello", @@ -467,6 +468,7 @@ test("session methods use the public HTTP contract", async () => { ["POST", "http://localhost:3000/api/session"], ["POST", "http://localhost:3000/api/session/ses_test/agent"], ["POST", "http://localhost:3000/api/session/ses_test/model"], + ["POST", "http://localhost:3000/api/session/ses_test/archive"], ["POST", "http://localhost:3000/api/session/ses_test/prompt"], ["POST", "http://localhost:3000/api/session/ses_test/generate"], ["POST", "http://localhost:3000/api/session/ses_test/synthetic"], diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index c2cdbb3ffc..bde0ca64a5 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -227,6 +227,7 @@ export interface Interface { model: Model.Ref }) => Effect.Effect readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect + readonly archive: (sessionID: SessionSchema.ID) => Effect.Effect readonly move: (input: { sessionID: SessionSchema.ID directory: AbsolutePath @@ -706,6 +707,11 @@ const layer = Layer.effect( title: input.title, }) }), + archive: Effect.fn("Session.archive")(function* (sessionID) { + const session = yield* result.get(sessionID) + if (session.time.archived) return + yield* bus.publish(SessionEvent.Archived, { sessionID }) + }), move: Effect.fn("Session.move")(function* (input) { const current = yield* result.get(input.sessionID) const value = input.directory.trim() diff --git a/packages/core/src/session/info.ts b/packages/core/src/session/info.ts index 83d0297bd2..ecad358d4e 100644 --- a/packages/core/src/session/info.ts +++ b/packages/core/src/session/info.ts @@ -53,7 +53,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In time: { created: DateTime.makeUnsafe(row.time_created), updated: DateTime.makeUnsafe(row.time_updated), - archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined, + archived: row.time_archived !== null ? DateTime.makeUnsafe(row.time_archived) : undefined, }, }) } diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 6d9c4723ff..cd7f2017bc 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -171,6 +171,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }, "session.moved": () => Effect.void, "session.renamed": () => Effect.void, + "session.archived": () => Effect.void, "session.deleted": () => Effect.void, "session.forked": () => Effect.void, "session.input.promoted": () => Effect.void, diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 5c88813762..78f0e4d315 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -609,6 +609,17 @@ const layer = Layer.effectDiscard( .run() .pipe(Effect.orDie), ) + yield* bus.project(SessionEvent.Archived, (event) => + db + .update(SessionTable) + .set({ + time_archived: DateTime.toEpochMillis(event.created), + time_updated: DateTime.toEpochMillis(event.created), + }) + .where(eq(SessionTable.id, event.data.sessionID)) + .run() + .pipe(Effect.orDie), + ) yield* bus.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data)) yield* bus.project(SessionEvent.Forked, (event) => projectFork(db, event)) yield* bus.project(SessionEvent.InputPromoted, (event) => diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index ae048e228b..2d45743a85 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -660,4 +660,32 @@ describe("Session.create", () => { ).toBe("Session.NotFoundError") }), ) + + it.effect("archives a Session through one durable event", () => + Effect.gen(function* () { + const session = yield* Session.Service + const created = yield* session.create({ location }) + + yield* session.archive(created.id) + yield* session.archive(created.id) + + expect((yield* session.get(created.id)).time.archived).toBeDefined() + const events = Array.from(yield* logEvents(session, created.id).pipe(Stream.runCollect)) + expect(events.map((event) => event.type)).toContain("session.archived") + expect(events.filter((event) => event.type === "session.archived")).toHaveLength(1) + }), + ) + + it.effect("rejects archiving a missing Session", () => + Effect.gen(function* () { + const session = yield* Session.Service + + expect( + yield* session.archive(Session.ID.make("ses_missing_archive")).pipe( + Effect.flip, + Effect.map((error) => error._tag), + ), + ).toBe("Session.NotFoundError") + }), + ) }) diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 3e9a4a5015..247e4435d3 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -269,6 +269,21 @@ export const makeSessionGroup = (sessionLo }), ), ) + .add( + HttpApiEndpoint.post("session.archive", "/api/session/:sessionID/archive", { + params: { sessionID: Session.ID }, + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.archive", + summary: "Archive session", + description: "Archive a session.", + }), + ), + ) .add( HttpApiEndpoint.post("session.move", "/api/session/:sessionID/move", { params: { sessionID: Session.ID }, diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 6a9258a549..aff7cf427b 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -86,6 +86,13 @@ export const Renamed = Event.durable({ }) export type Renamed = typeof Renamed.Type +export const Archived = Event.durable({ + type: "session.archived", + ...options, + schema: Base, +}) +export type Archived = typeof Archived.Type + export const UsageRecorded = Event.durable({ type: "session.usage.recorded", ...options, @@ -550,6 +557,7 @@ export const Definitions = Event.inventory( ModelSelected, Moved, Renamed, + Archived, UsageUpdated, Deleted, Forked, diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index bd08c653a5..e9fd20984b 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -104,6 +104,7 @@ describe("public event manifest", () => { "session.model.selected.1", "session.moved.1", "session.renamed.1", + "session.archived.1", "session.usage.recorded.1", "session.forked.2", "session.input.promoted.1", diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 796116da01..d38dff14c0 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -201,6 +201,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl return HttpApiSchema.NoContent.make() }), ) + .handle( + "session.archive", + Effect.fn(function* (ctx) { + yield* session.archive(ctx.params.sessionID).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "session.move", Effect.fn(function* (ctx) {