316 lines
9.6 KiB
TypeScript
316 lines
9.6 KiB
TypeScript
export * as SessionInput from "./input"
|
|
|
|
import { and, asc, eq, isNull, lte } from "drizzle-orm"
|
|
import { DateTime, Effect, Schema } from "effect"
|
|
import type { Database } from "../database/database"
|
|
import type { EventV2 } from "../event"
|
|
import { EventSequenceTable } from "../event/sql"
|
|
import { NonNegativeInt } from "../schema"
|
|
import { V2Schema } from "../v2-schema"
|
|
import { SessionEvent } from "./event"
|
|
import { SessionMessage } from "./message"
|
|
import { Prompt } from "./prompt"
|
|
import { SessionSchema } from "./schema"
|
|
import { SessionInputTable, SessionMessageTable } from "./sql"
|
|
|
|
type DatabaseService = Database.Interface["db"]
|
|
|
|
export const Delivery = Schema.Literals(["steer", "queue"])
|
|
export type Delivery = typeof Delivery.Type
|
|
|
|
export class Admitted extends Schema.Class<Admitted>("SessionInput.Admitted")({
|
|
admittedSeq: NonNegativeInt,
|
|
id: SessionMessage.ID,
|
|
sessionID: SessionSchema.ID,
|
|
prompt: Prompt,
|
|
delivery: Delivery,
|
|
timeCreated: V2Schema.DateTimeUtcFromMillis,
|
|
promotedSeq: NonNegativeInt.pipe(Schema.optional),
|
|
}) {}
|
|
|
|
const decodePrompt = Schema.decodeUnknownSync(Prompt)
|
|
const encodePrompt = Schema.encodeSync(Prompt)
|
|
|
|
const fromRow = (row: typeof SessionInputTable.$inferSelect): Admitted =>
|
|
new Admitted({
|
|
admittedSeq: row.admitted_seq,
|
|
id: SessionMessage.ID.make(row.id),
|
|
sessionID: SessionSchema.ID.make(row.session_id),
|
|
prompt: decodePrompt(row.prompt),
|
|
delivery: row.delivery,
|
|
timeCreated: DateTime.makeUnsafe(row.time_created),
|
|
...(row.promoted_seq === null ? {} : { promotedSeq: row.promoted_seq }),
|
|
})
|
|
|
|
export const find = Effect.fn("SessionInput.find")(function* (db: DatabaseService, id: SessionMessage.ID) {
|
|
const row = yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie)
|
|
return row === undefined ? undefined : fromRow(row)
|
|
})
|
|
|
|
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()("SessionInput.LifecycleConflict", {
|
|
id: SessionMessage.ID,
|
|
}) {}
|
|
|
|
export const admit = Effect.fn("SessionInput.admit")(function* (
|
|
db: DatabaseService,
|
|
events: EventV2.Interface,
|
|
input: {
|
|
readonly id: SessionMessage.ID
|
|
readonly sessionID: SessionSchema.ID
|
|
readonly prompt: Prompt
|
|
readonly delivery: Delivery
|
|
},
|
|
) {
|
|
const existing = yield* find(db, input.id)
|
|
if (existing !== undefined) return existing
|
|
const timestamp = yield* DateTime.now
|
|
return yield* events
|
|
.publish(SessionEvent.PromptLifecycle.Admitted, {
|
|
messageID: input.id,
|
|
sessionID: input.sessionID,
|
|
timestamp,
|
|
prompt: input.prompt,
|
|
delivery: input.delivery,
|
|
})
|
|
.pipe(
|
|
Effect.flatMap((event) =>
|
|
event.durable === undefined
|
|
? Effect.die("Prompt admission event is missing aggregate sequence")
|
|
: Effect.succeed(
|
|
new Admitted({
|
|
admittedSeq: event.durable.seq,
|
|
id: input.id,
|
|
sessionID: input.sessionID,
|
|
prompt: input.prompt,
|
|
delivery: input.delivery,
|
|
timeCreated: timestamp,
|
|
}),
|
|
),
|
|
),
|
|
Effect.catchDefect((defect) =>
|
|
find(db, input.id).pipe(Effect.flatMap((stored) => (stored ? Effect.succeed(stored) : Effect.die(defect)))),
|
|
),
|
|
)
|
|
})
|
|
|
|
export const latestSeq = Effect.fn("SessionInput.latestSeq")(function* (
|
|
db: DatabaseService,
|
|
sessionID: SessionSchema.ID,
|
|
) {
|
|
const row = yield* db
|
|
.select({ seq: EventSequenceTable.seq })
|
|
.from(EventSequenceTable)
|
|
.where(eq(EventSequenceTable.aggregate_id, sessionID))
|
|
.get()
|
|
.pipe(Effect.orDie)
|
|
return row?.seq ?? -1
|
|
})
|
|
|
|
export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(function* (
|
|
db: DatabaseService,
|
|
input: {
|
|
readonly admittedSeq: number
|
|
readonly id: SessionMessage.ID
|
|
readonly sessionID: SessionSchema.ID
|
|
readonly prompt: Prompt
|
|
readonly delivery: Delivery
|
|
readonly timeCreated: DateTime.Utc
|
|
},
|
|
) {
|
|
const stored = yield* db
|
|
.insert(SessionInputTable)
|
|
.values({
|
|
id: input.id,
|
|
session_id: input.sessionID,
|
|
admitted_seq: input.admittedSeq,
|
|
prompt: encodePrompt(input.prompt),
|
|
delivery: input.delivery,
|
|
time_created: DateTime.toEpochMillis(input.timeCreated),
|
|
})
|
|
.onConflictDoNothing()
|
|
.returning({ id: SessionInputTable.id })
|
|
.get()
|
|
.pipe(Effect.orDie)
|
|
if (!stored) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
|
})
|
|
|
|
export const projectPromoted = Effect.fn("SessionInput.projectPromoted")(function* (
|
|
db: DatabaseService,
|
|
input: {
|
|
readonly id: SessionMessage.ID
|
|
readonly sessionID: SessionSchema.ID
|
|
readonly prompt: Prompt
|
|
readonly timeCreated: DateTime.Utc
|
|
readonly promotedSeq: number
|
|
},
|
|
) {
|
|
const updated = yield* db
|
|
.update(SessionInputTable)
|
|
.set({ promoted_seq: input.promotedSeq })
|
|
.where(
|
|
and(
|
|
eq(SessionInputTable.id, input.id),
|
|
eq(SessionInputTable.session_id, input.sessionID),
|
|
isNull(SessionInputTable.promoted_seq),
|
|
),
|
|
)
|
|
.returning()
|
|
.get()
|
|
.pipe(Effect.orDie)
|
|
if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
|
const stored = fromRow(updated)
|
|
if (
|
|
!matchesPrompt(stored, input) ||
|
|
DateTime.toEpochMillis(stored.timeCreated) !== DateTime.toEpochMillis(input.timeCreated)
|
|
)
|
|
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
|
return toMessage(stored)
|
|
})
|
|
|
|
export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
|
|
db: DatabaseService,
|
|
sessionID: SessionSchema.ID,
|
|
delivery: Delivery,
|
|
) {
|
|
const row = yield* db
|
|
.select({ id: SessionInputTable.id })
|
|
.from(SessionInputTable)
|
|
.where(
|
|
and(
|
|
eq(SessionInputTable.session_id, sessionID),
|
|
isNull(SessionInputTable.promoted_seq),
|
|
eq(SessionInputTable.delivery, delivery),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.get()
|
|
.pipe(Effect.orDie)
|
|
return row !== undefined
|
|
})
|
|
|
|
export const equivalent = (
|
|
input: Admitted,
|
|
expected: {
|
|
readonly sessionID: SessionSchema.ID
|
|
readonly prompt: Prompt
|
|
readonly delivery: Delivery
|
|
},
|
|
) => input.delivery === expected.delivery && matchesPrompt(input, expected)
|
|
|
|
const matchesPrompt = (input: Admitted, expected: { readonly sessionID: SessionSchema.ID; readonly prompt: Prompt }) =>
|
|
input.sessionID === expected.sessionID &&
|
|
JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt))
|
|
|
|
export const projectLegacyPrompted = Effect.fn("SessionInput.projectLegacyPrompted")(function* (
|
|
db: DatabaseService,
|
|
input: {
|
|
readonly id: SessionMessage.ID
|
|
readonly sessionID: SessionSchema.ID
|
|
readonly prompt: Prompt
|
|
readonly delivery: Delivery
|
|
readonly timeCreated: DateTime.Utc
|
|
readonly promotedSeq: number
|
|
},
|
|
) {
|
|
const inserted = yield* db
|
|
.insert(SessionInputTable)
|
|
.values({
|
|
id: input.id,
|
|
session_id: input.sessionID,
|
|
admitted_seq: input.promotedSeq,
|
|
prompt: encodePrompt(input.prompt),
|
|
delivery: input.delivery,
|
|
promoted_seq: input.promotedSeq,
|
|
time_created: DateTime.toEpochMillis(input.timeCreated),
|
|
})
|
|
.onConflictDoNothing()
|
|
.returning()
|
|
.get()
|
|
.pipe(Effect.orDie)
|
|
if (!inserted) return yield* Effect.die("Prompt projection conflicts with admitted input")
|
|
return fromRow(inserted)
|
|
})
|
|
|
|
const publish = Effect.fn("SessionInput.publish")(function* (
|
|
db: DatabaseService,
|
|
events: EventV2.Interface,
|
|
sessionID: SessionSchema.ID,
|
|
rows: ReadonlyArray<typeof SessionInputTable.$inferSelect>,
|
|
) {
|
|
for (const row of rows) {
|
|
yield* events
|
|
.publish(SessionEvent.PromptLifecycle.Promoted, {
|
|
sessionID,
|
|
timestamp: yield* DateTime.now,
|
|
messageID: SessionMessage.ID.make(row.id),
|
|
prompt: decodePrompt(row.prompt),
|
|
timeCreated: DateTime.makeUnsafe(row.time_created),
|
|
})
|
|
.pipe(
|
|
Effect.catchDefect((defect) =>
|
|
defect instanceof LifecycleConflict
|
|
? find(db, SessionMessage.ID.make(row.id)).pipe(
|
|
Effect.flatMap((stored) => (stored?.promotedSeq === undefined ? Effect.die(defect) : Effect.void)),
|
|
)
|
|
: Effect.die(defect),
|
|
),
|
|
)
|
|
}
|
|
return rows.length
|
|
})
|
|
|
|
export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
|
|
db: DatabaseService,
|
|
events: EventV2.Interface,
|
|
sessionID: SessionSchema.ID,
|
|
cutoff: number,
|
|
) {
|
|
const rows = yield* db
|
|
.select()
|
|
.from(SessionInputTable)
|
|
.where(
|
|
and(
|
|
eq(SessionInputTable.session_id, sessionID),
|
|
isNull(SessionInputTable.promoted_seq),
|
|
eq(SessionInputTable.delivery, "steer"),
|
|
lte(SessionInputTable.admitted_seq, cutoff),
|
|
),
|
|
)
|
|
.orderBy(asc(SessionInputTable.admitted_seq))
|
|
.all()
|
|
.pipe(Effect.orDie)
|
|
return yield* publish(db, events, sessionID, rows)
|
|
})
|
|
|
|
export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(function* (
|
|
db: DatabaseService,
|
|
events: EventV2.Interface,
|
|
sessionID: SessionSchema.ID,
|
|
) {
|
|
const row = yield* db
|
|
.select()
|
|
.from(SessionInputTable)
|
|
.where(
|
|
and(
|
|
eq(SessionInputTable.session_id, sessionID),
|
|
isNull(SessionInputTable.promoted_seq),
|
|
eq(SessionInputTable.delivery, "queue"),
|
|
),
|
|
)
|
|
.orderBy(asc(SessionInputTable.admitted_seq))
|
|
.limit(1)
|
|
.get()
|
|
.pipe(Effect.orDie)
|
|
return row === undefined ? false : yield* publish(db, events, sessionID, [row]).pipe(Effect.as(true))
|
|
})
|
|
|
|
const toMessage = (input: Admitted) =>
|
|
new SessionMessage.User({
|
|
id: input.id,
|
|
type: "user",
|
|
text: input.prompt.text,
|
|
files: input.prompt.files,
|
|
agents: input.prompt.agents,
|
|
time: { created: input.timeCreated },
|
|
})
|