From 08e616bcad0984016a9a672733e821edad4e429c Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 21 Jul 2026 19:02:27 +0800 Subject: [PATCH] feat(session): add durable session archival --- 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 | 16 ++++++++++++++++ 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 ++++++++++++++++ 9 files changed, 75 insertions(+), 1 deletion(-) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 53a64866c2..b365a3e367 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -226,6 +226,7 @@ export interface Interface { model: ModelV2.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 @@ -705,6 +706,11 @@ const layer = Layer.effect( title: input.title, }) }), + archive: Effect.fn("V2Session.archive")(function* (sessionID) { + const session = yield* result.get(sessionID) + if (session.time.archived) return + yield* events.publish(SessionEvent.Archived, { sessionID }) + }), move: Effect.fn("V2Session.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 2c507d0058..1f0e5a6cdc 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 ecc6bf6257..acafabc7c9 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 f759a3757f..2147659c07 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* events.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* events.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data)) yield* events.project(SessionEvent.Forked, (event) => projectFork(db, event)) yield* events.project(SessionEvent.InputPromoted, (event) => diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 80136f02f8..a34e9121fc 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -34,6 +34,7 @@ const projects = Layer.succeed( list: () => Effect.succeed([]), resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), directories: () => Effect.succeed([]), + update: () => Effect.die("unused"), commit: () => Effect.void, }), ) @@ -660,4 +661,19 @@ describe("SessionV2.create", () => { ).toBe("Session.NotFoundError") }), ) + + it.effect("archives a Session through one durable event", () => + Effect.gen(function* () { + const session = yield* SessionV2.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) + }), + ) }) 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 cc56a83eab..5036ae3bf3 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -85,6 +85,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, @@ -539,6 +546,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 89766d3d26..b99867d1f9 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 02d1c8d80a..4fbc595d7a 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) {