feat(session): add durable session archival

This commit is contained in:
Brendan Allan 2026-07-21 19:02:27 +08:00
commit 08e616bcad
No known key found for this signature in database
GPG key ID: 41E835AEA046A32E
9 changed files with 75 additions and 1 deletions

View file

@ -226,6 +226,7 @@ export interface Interface {
model: ModelV2.Ref model: ModelV2.Ref
}) => Effect.Effect<void, NotFoundError> }) => Effect.Effect<void, NotFoundError>
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError> readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
readonly archive: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly move: (input: { readonly move: (input: {
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
directory: AbsolutePath directory: AbsolutePath
@ -705,6 +706,11 @@ const layer = Layer.effect(
title: input.title, 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) { move: Effect.fn("V2Session.move")(function* (input) {
const current = yield* result.get(input.sessionID) const current = yield* result.get(input.sessionID)
const value = input.directory.trim() const value = input.directory.trim()

View file

@ -53,7 +53,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
time: { time: {
created: DateTime.makeUnsafe(row.time_created), created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated), 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,
}, },
}) })
} }

View file

@ -171,6 +171,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}, },
"session.moved": () => Effect.void, "session.moved": () => Effect.void,
"session.renamed": () => Effect.void, "session.renamed": () => Effect.void,
"session.archived": () => Effect.void,
"session.deleted": () => Effect.void, "session.deleted": () => Effect.void,
"session.forked": () => Effect.void, "session.forked": () => Effect.void,
"session.input.promoted": () => Effect.void, "session.input.promoted": () => Effect.void,

View file

@ -609,6 +609,17 @@ const layer = Layer.effectDiscard(
.run() .run()
.pipe(Effect.orDie), .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.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data))
yield* events.project(SessionEvent.Forked, (event) => projectFork(db, event)) yield* events.project(SessionEvent.Forked, (event) => projectFork(db, event))
yield* events.project(SessionEvent.InputPromoted, (event) => yield* events.project(SessionEvent.InputPromoted, (event) =>

View file

@ -34,6 +34,7 @@ const projects = Layer.succeed(
list: () => Effect.succeed([]), list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
directories: () => Effect.succeed([]), directories: () => Effect.succeed([]),
update: () => Effect.die("unused"),
commit: () => Effect.void, commit: () => Effect.void,
}), }),
) )
@ -660,4 +661,19 @@ describe("SessionV2.create", () => {
).toBe("Session.NotFoundError") ).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)
}),
)
}) })

View file

@ -269,6 +269,21 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(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( .add(
HttpApiEndpoint.post("session.move", "/api/session/:sessionID/move", { HttpApiEndpoint.post("session.move", "/api/session/:sessionID/move", {
params: { sessionID: Session.ID }, params: { sessionID: Session.ID },

View file

@ -85,6 +85,13 @@ export const Renamed = Event.durable({
}) })
export type Renamed = typeof Renamed.Type 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({ export const UsageRecorded = Event.durable({
type: "session.usage.recorded", type: "session.usage.recorded",
...options, ...options,
@ -539,6 +546,7 @@ export const Definitions = Event.inventory(
ModelSelected, ModelSelected,
Moved, Moved,
Renamed, Renamed,
Archived,
UsageUpdated, UsageUpdated,
Deleted, Deleted,
Forked, Forked,

View file

@ -104,6 +104,7 @@ describe("public event manifest", () => {
"session.model.selected.1", "session.model.selected.1",
"session.moved.1", "session.moved.1",
"session.renamed.1", "session.renamed.1",
"session.archived.1",
"session.usage.recorded.1", "session.usage.recorded.1",
"session.forked.2", "session.forked.2",
"session.input.promoted.1", "session.input.promoted.1",

View file

@ -201,6 +201,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
return HttpApiSchema.NoContent.make() 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( .handle(
"session.move", "session.move",
Effect.fn(function* (ctx) { Effect.fn(function* (ctx) {