mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-08 16:25:33 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9dbba2fa54 |
No files matched your search
@@ -118,6 +118,7 @@ export type SessionListInput = {
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: Session.ID | null | undefined
|
||||
readonly archived?: boolean | undefined
|
||||
readonly directory?: AbsolutePath | undefined
|
||||
readonly project?: Project.ID | undefined
|
||||
readonly subpath?: RelativePath | undefined
|
||||
@@ -254,6 +255,7 @@ export type SessionUpdateInput = {
|
||||
readonly title?: string | undefined
|
||||
readonly metadata?: Session.Metadata | undefined
|
||||
readonly permissions?: Permission.Ruleset | undefined
|
||||
readonly archived?: boolean | undefined
|
||||
}
|
||||
export type SessionUpdateOutput = void
|
||||
export type SessionUpdateOperation<E = never> = (input: SessionUpdateInput) => Effect.Effect<SessionUpdateOutput, E>
|
||||
@@ -570,6 +572,34 @@ export type SessionLogOutput =
|
||||
| undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly idle: number }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
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?:
|
||||
| {
|
||||
readonly directory: AbsolutePath
|
||||
readonly workspaceID?: (string & Brand.Brand<"Workspace.ID">) | undefined
|
||||
}
|
||||
| undefined
|
||||
readonly data: { readonly sessionID: Session.ID }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.unarchived"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?:
|
||||
| {
|
||||
readonly directory: AbsolutePath
|
||||
readonly workspaceID?: (string & Brand.Brand<"Workspace.ID">) | undefined
|
||||
}
|
||||
| undefined
|
||||
readonly data: { readonly sessionID: Session.ID }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
|
||||
@@ -366,6 +366,7 @@ const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: Sessi
|
||||
order: input?.["order"],
|
||||
search: input?.["search"],
|
||||
parentID: input?.["parentID"],
|
||||
archived: input?.["archived"],
|
||||
directory: input?.["directory"],
|
||||
project: input?.["project"],
|
||||
subpath: input?.["subpath"],
|
||||
@@ -474,7 +475,12 @@ const EndpointSessionUpdate = (raw: RawClient["server.session"]) => (input: Sess
|
||||
preserveEffect<SessionUpdateOutput>()(
|
||||
raw["session.update"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { title: input["title"], metadata: input["metadata"], permissions: input["permissions"] },
|
||||
payload: {
|
||||
title: input["title"],
|
||||
metadata: input["metadata"],
|
||||
permissions: input["permissions"],
|
||||
archived: input["archived"],
|
||||
},
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
|
||||
@@ -543,6 +543,7 @@ export function make(options: ClientOptions) {
|
||||
order: input?.["order"],
|
||||
search: input?.["search"],
|
||||
parentID: input?.["parentID"],
|
||||
archived: input?.["archived"],
|
||||
directory: input?.["directory"],
|
||||
project: input?.["project"],
|
||||
subpath: input?.["subpath"],
|
||||
@@ -691,7 +692,12 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "PATCH",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}`,
|
||||
body: { title: input["title"], metadata: input["metadata"], permissions: input["permissions"] },
|
||||
body: {
|
||||
title: input["title"],
|
||||
metadata: input["metadata"],
|
||||
permissions: input["permissions"],
|
||||
archived: input["archived"],
|
||||
},
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: true,
|
||||
|
||||
@@ -623,6 +623,26 @@ export type SessionViewed = {
|
||||
data: { sessionID: string; idle: number }
|
||||
}
|
||||
|
||||
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 SessionUnarchived = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.unarchived"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string }
|
||||
}
|
||||
|
||||
export type SessionDeleted = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -2378,6 +2398,8 @@ export type SessionEventDurable =
|
||||
| SessionMetadataUpdated
|
||||
| SessionPermissions
|
||||
| SessionViewed
|
||||
| SessionArchived
|
||||
| SessionUnarchived
|
||||
| SessionDeleted
|
||||
| SessionForked
|
||||
| SessionInboxDelivered
|
||||
@@ -2441,6 +2463,8 @@ export type V2Event =
|
||||
| SessionMetadataUpdated
|
||||
| SessionPermissions
|
||||
| SessionViewed
|
||||
| SessionArchived
|
||||
| SessionUnarchived
|
||||
| SessionUsageUpdated
|
||||
| SessionDeleted
|
||||
| SessionForked
|
||||
@@ -2823,6 +2847,7 @@ export type SessionListInput = {
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly archived?: boolean | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
@@ -2833,6 +2858,7 @@ export type SessionListInput = {
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly archived?: boolean | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
@@ -2843,6 +2869,7 @@ export type SessionListInput = {
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly archived?: boolean | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
@@ -2853,16 +2880,29 @@ export type SessionListInput = {
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly archived?: boolean | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
readonly cursor?: string | undefined
|
||||
}["parentID"]
|
||||
readonly archived?: {
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly archived?: boolean | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
readonly cursor?: string | undefined
|
||||
}["archived"]
|
||||
readonly directory?: {
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly archived?: boolean | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
@@ -2873,6 +2913,7 @@ export type SessionListInput = {
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly archived?: boolean | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
@@ -2883,6 +2924,7 @@ export type SessionListInput = {
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly archived?: boolean | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
@@ -2893,6 +2935,7 @@ export type SessionListInput = {
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly archived?: boolean | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
@@ -4123,6 +4166,7 @@ export type SessionUpdateInput = {
|
||||
readonly permissions?:
|
||||
| ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" | "ask" }>
|
||||
| undefined
|
||||
readonly archived?: boolean | undefined
|
||||
}["title"]
|
||||
readonly metadata?: {
|
||||
readonly title?: string | undefined
|
||||
@@ -4130,6 +4174,7 @@ export type SessionUpdateInput = {
|
||||
readonly permissions?:
|
||||
| ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" | "ask" }>
|
||||
| undefined
|
||||
readonly archived?: boolean | undefined
|
||||
}["metadata"]
|
||||
readonly permissions?: {
|
||||
readonly title?: string | undefined
|
||||
@@ -4137,7 +4182,16 @@ export type SessionUpdateInput = {
|
||||
readonly permissions?:
|
||||
| ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" | "ask" }>
|
||||
| undefined
|
||||
readonly archived?: boolean | undefined
|
||||
}["permissions"]
|
||||
readonly archived?: {
|
||||
readonly title?: string | undefined
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | undefined
|
||||
readonly permissions?:
|
||||
| ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" | "ask" }>
|
||||
| undefined
|
||||
readonly archived?: boolean | undefined
|
||||
}["archived"]
|
||||
}
|
||||
|
||||
export type SessionUpdateOutput = void
|
||||
|
||||
@@ -701,6 +701,14 @@ export function createData(config: CreateDataInput) {
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "permissions", event.data.permissions)
|
||||
return
|
||||
case "session.archived":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "time", "archived", event.created)
|
||||
return
|
||||
case "session.unarchived":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "time", "archived", undefined)
|
||||
return
|
||||
case "session.moved": {
|
||||
const current = store.session.info[event.data.sessionID]
|
||||
if (current) {
|
||||
|
||||
@@ -174,6 +174,8 @@ export interface Interface {
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => 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 unarchive: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly setMetadata: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
metadata: SessionSchema.Metadata
|
||||
@@ -433,6 +435,8 @@ const layer = Layer.effect(
|
||||
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
|
||||
switchModel: (input) => sessions.forSession(input.sessionID).switchModel(input),
|
||||
rename: (input) => sessions.forSession(input.sessionID).rename(input),
|
||||
archive: (sessionID) => sessions.forSession(sessionID).archive(),
|
||||
unarchive: (sessionID) => sessions.forSession(sessionID).unarchive(),
|
||||
setMetadata: (input) => sessions.forSession(input.sessionID).setMetadata(input),
|
||||
setPermissions: (input) => sessions.forSession(input.sessionID).setPermissions(input),
|
||||
move: moves.move,
|
||||
|
||||
@@ -58,7 +58,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
|
||||
updated: DateTime.makeUnsafe(row.time_updated),
|
||||
idle: row.time_idle === null ? undefined : DateTime.makeUnsafe(row.time_idle),
|
||||
viewed: row.time_viewed === null ? undefined : DateTime.makeUnsafe(row.time_viewed),
|
||||
archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
|
||||
archived: row.time_archived === null ? undefined : DateTime.makeUnsafe(row.time_archived),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -80,6 +80,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
Match.discriminatorsExhaustive("type")({
|
||||
"session.created": () => Effect.void,
|
||||
"session.viewed": () => Effect.void,
|
||||
"session.archived": () => Effect.void,
|
||||
"session.unarchived": () => Effect.void,
|
||||
"session.message.content.updated": (event) =>
|
||||
updateOwnedAssistant(event.data.messageID, (draft) => {
|
||||
draft.content = castDraft(
|
||||
|
||||
@@ -603,6 +603,22 @@ const layer = Layer.effectDiscard(
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
yield* bus.project(SessionEvent.Archived, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ time_archived: event.created, time_updated: event.created })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Unarchived, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ time_archived: null, time_updated: event.created })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.MessageContentUpdated, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data))
|
||||
yield* bus.project(SessionEvent.Forked, (event) => projectFork(db, event))
|
||||
|
||||
@@ -72,6 +72,16 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
yield* get(sessionID)
|
||||
yield* bus.publish(SessionEvent.Renamed, { sessionID, title: input.title })
|
||||
})
|
||||
const archive = Effect.fn("Session.archive")(function* (sessionID: SessionSchema.ID) {
|
||||
const session = yield* get(sessionID)
|
||||
if (session.time.archived) return
|
||||
yield* bus.publish(SessionEvent.Archived, { sessionID })
|
||||
})
|
||||
const unarchive = Effect.fn("Session.unarchive")(function* (sessionID: SessionSchema.ID) {
|
||||
const session = yield* get(sessionID)
|
||||
if (!session.time.archived) return
|
||||
yield* bus.publish(SessionEvent.Unarchived, { sessionID })
|
||||
})
|
||||
const setMetadata = Effect.fn("Session.setMetadata")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
input: { metadata: SessionSchema.Metadata },
|
||||
@@ -348,6 +358,8 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
message,
|
||||
view,
|
||||
rename,
|
||||
archive,
|
||||
unarchive,
|
||||
setMetadata,
|
||||
setPermissions,
|
||||
switchAgent,
|
||||
@@ -372,6 +384,8 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
const message = operations.message.bind(undefined, sessionID)
|
||||
const view = operations.view.bind(undefined, sessionID)
|
||||
const rename = operations.rename.bind(undefined, sessionID)
|
||||
const archive = operations.archive.bind(undefined, sessionID)
|
||||
const unarchive = operations.unarchive.bind(undefined, sessionID)
|
||||
const setMetadata = operations.setMetadata.bind(undefined, sessionID)
|
||||
const setPermissions = operations.setPermissions.bind(undefined, sessionID)
|
||||
const switchAgent = operations.switchAgent.bind(undefined, sessionID)
|
||||
@@ -399,6 +413,8 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
message,
|
||||
view,
|
||||
rename,
|
||||
archive,
|
||||
unarchive,
|
||||
setMetadata,
|
||||
setPermissions,
|
||||
switchAgent,
|
||||
|
||||
@@ -20,6 +20,7 @@ const ListInputBase = {
|
||||
limit: PositiveInt.pipe(Schema.optional),
|
||||
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
|
||||
parentID: Schema.NullOr(Session.ID).pipe(Schema.optional),
|
||||
archived: Schema.Boolean.pipe(Schema.optional),
|
||||
anchor: Session.ListAnchor.pipe(Schema.optional),
|
||||
}
|
||||
|
||||
@@ -111,6 +112,8 @@ const layer = Layer.effect(
|
||||
conditions.push(
|
||||
input.parentID === null ? isNull(SessionTable.parent_id) : eq(SessionTable.parent_id, input.parentID),
|
||||
)
|
||||
if (input.archived !== undefined)
|
||||
conditions.push(input.archived ? isNotNull(SessionTable.time_archived) : isNull(SessionTable.time_archived))
|
||||
if (input.anchor) {
|
||||
conditions.push(
|
||||
order === "asc"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Database } from "@opencode/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { EventTable } from "@opencode/core/event/sql"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { Project } from "@opencode/core/project"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionEvent } from "@opencode/core/session/event"
|
||||
import { SessionExecution } from "@opencode/core/session/execution"
|
||||
import { SessionProjector } from "@opencode/core/session/projector"
|
||||
import { SessionStore } from "@opencode/core/session/store"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Effect } from "effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectNode } from "./lib/project"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
Project.node.replace(globalProjectNode),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
],
|
||||
),
|
||||
)
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
|
||||
describe("Session.archive", () => {
|
||||
it.effect("archives and unarchives through one durable event each", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const { db } = yield* Database.Service
|
||||
const created = yield* session.create({ location })
|
||||
expect(created.time.archived).toBeUndefined()
|
||||
|
||||
yield* session.archive(created.id)
|
||||
yield* session.archive(created.id)
|
||||
expect((yield* session.get(created.id)).time.archived).toBeDefined()
|
||||
|
||||
yield* session.unarchive(created.id)
|
||||
yield* session.unarchive(created.id)
|
||||
expect((yield* session.get(created.id)).time.archived).toBeUndefined()
|
||||
|
||||
const types = (yield* db
|
||||
.select({ type: EventTable.type })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, created.id))
|
||||
.all()).map((event) => event.type)
|
||||
expect(types.filter((type) => type === Bus.versionedType(SessionEvent.Archived.type, 1))).toHaveLength(1)
|
||||
expect(types.filter((type) => type === Bus.versionedType(SessionEvent.Unarchived.type, 1))).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters listed sessions by archive state", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const active = yield* session.create({ location })
|
||||
const archived = yield* session.create({ location })
|
||||
yield* session.archive(archived.id)
|
||||
|
||||
const ids = (input?: Session.ListInput) =>
|
||||
session.list(input).pipe(Effect.map((page) => page.data.map((item) => item.id).toSorted()))
|
||||
expect(yield* ids()).toEqual([active.id, archived.id].toSorted())
|
||||
expect(yield* ids({ archived: false })).toEqual([active.id])
|
||||
expect(yield* ids({ archived: true })).toEqual([archived.id])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects an unknown session", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const sessionID = Session.ID.make("ses_missing_archive")
|
||||
expect(yield* Effect.flip(session.archive(sessionID))).toEqual(new Session.NotFoundError({ sessionID }))
|
||||
expect(yield* Effect.flip(session.unarchive(sessionID))).toEqual(new Session.NotFoundError({ sessionID }))
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -56,6 +56,13 @@ const ParentIDFilter = Schema.Union([
|
||||
description: "Filter by parent session. Use null to return only root sessions.",
|
||||
})
|
||||
|
||||
const BooleanFromString = Schema.Literals(["true", "false"]).pipe(
|
||||
Schema.decodeTo(Schema.Boolean, {
|
||||
decode: SchemaGetter.transform((value) => value === "true"),
|
||||
encode: SchemaGetter.transform((value): "true" | "false" => (value ? "true" : "false")),
|
||||
}),
|
||||
)
|
||||
|
||||
const SessionsQueryFields = {
|
||||
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({
|
||||
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
|
||||
@@ -65,6 +72,10 @@ const SessionsQueryFields = {
|
||||
}),
|
||||
search: Schema.optional(Schema.String),
|
||||
parentID: ParentIDFilter.pipe(Schema.optional),
|
||||
archived: BooleanFromString.pipe(Schema.optional).annotate({
|
||||
description:
|
||||
"Filter by archive state. Use true to return only archived sessions or false to exclude them. Omit to include both.",
|
||||
}),
|
||||
}
|
||||
|
||||
const SessionsDirectoryQuery = Schema.Struct({
|
||||
@@ -152,13 +163,6 @@ const FormCreatePayload = Schema.Struct({
|
||||
fields: Form.Info.fields.fields,
|
||||
}).annotate({ identifier: "Form.CreatePayload" })
|
||||
|
||||
const BooleanFromString = Schema.Literals(["true", "false"]).pipe(
|
||||
Schema.decodeTo(Schema.Boolean, {
|
||||
decode: SchemaGetter.transform((value) => value === "true"),
|
||||
encode: SchemaGetter.transform((value): "true" | "false" => (value ? "true" : "false")),
|
||||
}),
|
||||
)
|
||||
|
||||
const SessionsQueryCursor = SessionsCursor.annotate({
|
||||
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
|
||||
})
|
||||
@@ -363,6 +367,9 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S, FormI ext
|
||||
title: Schema.String.pipe(Schema.optional),
|
||||
metadata: Session.Metadata.pipe(Schema.optional),
|
||||
permissions: Permission.Ruleset.pipe(Schema.optional),
|
||||
archived: Schema.Boolean.pipe(Schema.optional).annotate({
|
||||
description: "Archive the session with true or restore it with false. Repeating the current state is a no-op.",
|
||||
}),
|
||||
}),
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: SessionNotFoundError,
|
||||
|
||||
@@ -139,6 +139,20 @@ export const Viewed = Event.durable({
|
||||
})
|
||||
export type Viewed = typeof Viewed.Type
|
||||
|
||||
export const Archived = Event.durable({
|
||||
type: "session.archived",
|
||||
...options,
|
||||
schema: Base,
|
||||
})
|
||||
export type Archived = typeof Archived.Type
|
||||
|
||||
export const Unarchived = Event.durable({
|
||||
type: "session.unarchived",
|
||||
...options,
|
||||
schema: Base,
|
||||
})
|
||||
export type Unarchived = typeof Unarchived.Type
|
||||
|
||||
// Replay-only: older releases allowed replacing completed assistant content.
|
||||
export const MessageContentUpdated = Event.durable({
|
||||
type: "session.message.content.updated",
|
||||
@@ -658,6 +672,8 @@ export const Definitions = Event.inventory(
|
||||
MetadataUpdated,
|
||||
Permissions,
|
||||
Viewed,
|
||||
Archived,
|
||||
Unarchived,
|
||||
UsageUpdated,
|
||||
Deleted,
|
||||
Forked,
|
||||
|
||||
@@ -111,6 +111,8 @@ describe("public event manifest", () => {
|
||||
"session.metadata.updated.1",
|
||||
"session.permissions.1",
|
||||
"session.viewed.1",
|
||||
"session.archived.1",
|
||||
"session.unarchived.1",
|
||||
"session.message.content.updated.1",
|
||||
"session.usage.recorded.1",
|
||||
"session.forked.2",
|
||||
|
||||
@@ -277,6 +277,10 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
yield* session
|
||||
.setPermissions({ sessionID: ctx.params.sessionID, permissions: ctx.payload.permissions })
|
||||
.pipe(Effect.catchTag("Session.NotFoundError", missingSession))
|
||||
if (ctx.payload.archived !== undefined)
|
||||
yield* (ctx.payload.archived ? session.archive : session.unarchive)(ctx.params.sessionID).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -5,28 +5,32 @@ import { it } from "../../core/test/lib/effect"
|
||||
import { ServerFetch } from "../src/fetch"
|
||||
|
||||
const SessionResponse = Schema.Struct({ data: Schema.toEncoded(Session.Info) })
|
||||
const SessionsResponse = Schema.Struct({ data: Schema.Array(Schema.toEncoded(Session.Info)) })
|
||||
|
||||
const makeRequest = Effect.fn(function* () {
|
||||
const handler = yield* ServerFetch.make({
|
||||
app: { version: "test" },
|
||||
database: { path: ":memory:" },
|
||||
fs: { filewatcher: false },
|
||||
models: { fetch: false },
|
||||
})
|
||||
return (path: string, method: string, body?: unknown) =>
|
||||
Effect.promise(async () => {
|
||||
const response = await handler(
|
||||
new Request(`http://opencode.local${path}`, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(method === "PATCH" ? 204 : 200)
|
||||
return response.status === 204 ? undefined : response.json()
|
||||
})
|
||||
})
|
||||
|
||||
it.live("updates session metadata through PATCH", () =>
|
||||
Effect.gen(function* () {
|
||||
const handler = yield* ServerFetch.make({
|
||||
app: { version: "test" },
|
||||
database: { path: ":memory:" },
|
||||
fs: { filewatcher: false },
|
||||
models: { fetch: false },
|
||||
})
|
||||
const request = (path: string, method: string, body?: unknown) =>
|
||||
Effect.promise(async () => {
|
||||
const response = await handler(
|
||||
new Request(`http://opencode.local${path}`, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(method === "PATCH" ? 204 : 200)
|
||||
return response.status === 204 ? undefined : response.json()
|
||||
})
|
||||
|
||||
const request = yield* makeRequest()
|
||||
const created = Schema.decodeUnknownSync(SessionResponse)(
|
||||
yield* request("/api/session", "POST", { metadata: { source: "create", stale: true } }),
|
||||
)
|
||||
@@ -36,3 +40,27 @@ it.live("updates session metadata through PATCH", () =>
|
||||
expect(updated.data.metadata).toEqual({ source: "patch" })
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
|
||||
it.live("archives and unarchives a session through PATCH", () =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* makeRequest()
|
||||
const created = Schema.decodeUnknownSync(SessionResponse)(yield* request("/api/session", "POST", {}))
|
||||
const get = () =>
|
||||
request(`/api/session/${created.data.id}`, "GET").pipe(
|
||||
Effect.map((body) => Schema.decodeUnknownSync(SessionResponse)(body).data),
|
||||
)
|
||||
const listed = (archived: boolean) =>
|
||||
request(`/api/session?archived=${archived}`, "GET").pipe(
|
||||
Effect.map((body) => Schema.decodeUnknownSync(SessionsResponse)(body).data.map((item) => item.id)),
|
||||
)
|
||||
|
||||
yield* request(`/api/session/${created.data.id}`, "PATCH", { archived: true })
|
||||
expect((yield* get()).time.archived).toBeNumber()
|
||||
expect(yield* listed(true)).toContain(created.data.id)
|
||||
expect(yield* listed(false)).not.toContain(created.data.id)
|
||||
|
||||
yield* request(`/api/session/${created.data.id}`, "PATCH", { archived: false })
|
||||
expect((yield* get()).time.archived).toBeUndefined()
|
||||
expect(yield* listed(false)).toContain(created.data.id)
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
Reference in new issue
Block a user