mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-17 12:56:41 +02:00
discord: simplify ConversationLedger to offsets + in-memory dedup
Replace the full inbox state machine (admit/start/setTarget/setPrompt/ setResponse/complete/retry/prune/replayPending) with three methods: dedup (bounded in-memory Set), getOffset, and setOffset. Discord is already the durable inbox — on startup we just resume from persisted offsets per source. Within a session, in-memory dedup prevents double-processing. This removes ~565 lines of inbox lifecycle code, the conversation_inbox SQLite table, MessageState, ReliabilityError, and the prune schedule.
This commit is contained in:
@@ -21,10 +21,9 @@ This module is wired into `src/index.ts`.
|
||||
|
||||
Reliability semantics:
|
||||
|
||||
- inbound events are durably admitted by `message_id` before processing
|
||||
- pending events replay on startup
|
||||
- in-memory dedup by `message_id` prevents double-processing within a session
|
||||
- startup catch-up fetches missed Discord messages from tracked thread sources and allowed channels using persisted offsets
|
||||
- response text is cached before Discord delivery so retries can re-publish without re-calling the model
|
||||
- Discord is the durable inbox — on startup we resume from where we left off per source
|
||||
|
||||
Local CLI notes (`bun run conversation:cli`):
|
||||
|
||||
|
||||
@@ -193,7 +193,7 @@ export class DiscordConversationServices {
|
||||
const uniq = <A>(values: ReadonlyArray<A>): Array<A> => [...new Set(values)]
|
||||
|
||||
const offer = (event: Inbound, onFresh: Effect.Effect<void>) =>
|
||||
ledger.admit(event).pipe(
|
||||
ledger.dedup(event.message_id).pipe(
|
||||
Effect.flatMap((fresh) => {
|
||||
if (!fresh) {
|
||||
return Effect.logDebug("Message deduped (already seen)").pipe(
|
||||
@@ -543,7 +543,7 @@ export class DiscordConversationServices {
|
||||
}),
|
||||
content: text,
|
||||
})
|
||||
const ingest = ledger.admit(event).pipe(
|
||||
const ingest = ledger.dedup(event.message_id).pipe(
|
||||
Effect.flatMap((fresh) => {
|
||||
if (!fresh) return Effect.void
|
||||
return Effect.sync(() => {
|
||||
@@ -617,28 +617,6 @@ export class DiscordConversationServices {
|
||||
)),
|
||||
)
|
||||
|
||||
yield* ledger.replayPending().pipe(
|
||||
Effect.flatMap((events) =>
|
||||
Effect.forEach(
|
||||
events,
|
||||
(event) =>
|
||||
Effect.sync(() => {
|
||||
input.unsafeOffer(event)
|
||||
}),
|
||||
{ discard: true },
|
||||
).pipe(
|
||||
Effect.zipRight(
|
||||
Effect.logInfo("Replayed pending conversation events").pipe(
|
||||
Effect.annotateLogs({ event: "conversation.ledger.replay", count: events.length }),
|
||||
),
|
||||
),
|
||||
)),
|
||||
Effect.catchAll((error) =>
|
||||
Effect.logError("Failed replaying pending conversation events").pipe(
|
||||
Effect.annotateLogs({ event: "conversation.ledger.replay.failed", error: messageOf(error) }),
|
||||
)),
|
||||
)
|
||||
|
||||
const inbox = Inbox.of({
|
||||
events: Stream.fromQueue(input, { shutdown: false }),
|
||||
})
|
||||
|
||||
@@ -48,22 +48,12 @@ export class SandboxSendError extends Schema.TaggedError<SandboxSendError>()(
|
||||
},
|
||||
) {}
|
||||
|
||||
export class ReliabilityError extends Schema.TaggedError<ReliabilityError>()(
|
||||
"ReliabilityError",
|
||||
{
|
||||
message_id: Schema.String,
|
||||
message: Schema.String,
|
||||
retriable: Schema.Boolean,
|
||||
},
|
||||
) {}
|
||||
|
||||
export const ConversationError = Schema.Union(
|
||||
ThreadEnsureError,
|
||||
HistoryError,
|
||||
DeliveryError,
|
||||
RoutingError,
|
||||
SandboxSendError,
|
||||
ReliabilityError,
|
||||
)
|
||||
|
||||
export type ConversationError = typeof ConversationError.Type
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ChannelId, GuildId, SandboxId, SessionId, SessionInfo, ThreadId } from
|
||||
import { Mention, ThreadMessage, ThreadRef, Typing, type Action, type Inbound } from "../model/schema"
|
||||
import { History } from "./history"
|
||||
import { Inbox } from "./inbox"
|
||||
import { ConversationLedger, MessageState } from "./ledger"
|
||||
import { ConversationLedger } from "./ledger"
|
||||
import { Outbox } from "./outbox"
|
||||
import { Threads } from "./threads"
|
||||
import { Conversation } from "./conversation"
|
||||
@@ -447,55 +447,24 @@ describe("Conversation", () => {
|
||||
|
||||
// --- Duplicate processing tests ---
|
||||
|
||||
/** A ledger that tracks admit/start calls and enforces dedup like the real one */
|
||||
/** A ledger that tracks dedup calls */
|
||||
const makeTrackingLedger = () => {
|
||||
const admitted = new Set<string>()
|
||||
const started = new Set<string>()
|
||||
const completed = new Set<string>()
|
||||
const admitCalls: Array<string> = []
|
||||
const startCalls: Array<string> = []
|
||||
const seen = new Set<string>()
|
||||
const dedupCalls: Array<string> = []
|
||||
|
||||
const service: ConversationLedger.Service = {
|
||||
admit: (event) =>
|
||||
dedup: (message_id) =>
|
||||
Effect.sync(() => {
|
||||
admitCalls.push(event.message_id)
|
||||
if (admitted.has(event.message_id)) return false
|
||||
admitted.add(event.message_id)
|
||||
dedupCalls.push(message_id)
|
||||
if (seen.has(message_id)) return false
|
||||
seen.add(message_id)
|
||||
return true
|
||||
}),
|
||||
replayPending: () => Effect.succeed([]),
|
||||
start: (message_id) =>
|
||||
Effect.sync(() => {
|
||||
startCalls.push(message_id)
|
||||
if (started.has(message_id) || completed.has(message_id)) return Option.none()
|
||||
started.add(message_id)
|
||||
return Option.some(MessageState.make({
|
||||
thread_id: null,
|
||||
channel_id: null,
|
||||
response_text: null,
|
||||
prompt_text: null,
|
||||
session_id: null,
|
||||
}))
|
||||
}),
|
||||
setTarget: () => Effect.void,
|
||||
setPrompt: () => Effect.void,
|
||||
setResponse: () => Effect.void,
|
||||
complete: (message_id) =>
|
||||
Effect.sync(() => {
|
||||
started.delete(message_id)
|
||||
completed.add(message_id)
|
||||
}),
|
||||
retry: (message_id) =>
|
||||
Effect.sync(() => {
|
||||
started.delete(message_id)
|
||||
// Back to pending — NOT completed, so start will work again
|
||||
}),
|
||||
prune: () => Effect.void,
|
||||
getOffset: () => Effect.succeed(Option.none()),
|
||||
setOffset: () => Effect.void,
|
||||
}
|
||||
|
||||
return { service, admitted, started, completed, admitCalls, startCalls }
|
||||
return { service, seen, dedupCalls }
|
||||
}
|
||||
|
||||
const makeConversationLayerWithLedger = (props: {
|
||||
@@ -622,10 +591,8 @@ describe("Conversation duplicate processing", () => {
|
||||
const conversation = yield* Conversation
|
||||
yield* conversation.run
|
||||
|
||||
// The ledger should have been called twice with admit
|
||||
expect(ledger.admitCalls).toEqual(["m1", "m1"])
|
||||
// But only one start should have succeeded
|
||||
expect(ledger.startCalls.length).toBeLessThanOrEqual(2)
|
||||
// The ledger should have been called twice with dedup
|
||||
expect(ledger.dedupCalls).toEqual(["m1", "m1"])
|
||||
// The agent should have received the prompt only once
|
||||
expect(prompts).toEqual(["hello"])
|
||||
// Only one typing + one send
|
||||
@@ -633,12 +600,12 @@ describe("Conversation duplicate processing", () => {
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("noop ledger now deduplicates (bug is fixed)", () => {
|
||||
effectTest("noop ledger deduplicates", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const event = makeEvent("hello")
|
||||
|
||||
// The noop ledger now tracks seen message_ids
|
||||
// The noop ledger tracks seen message_ids
|
||||
const live = makeConversationLayer({
|
||||
events: [event, event],
|
||||
tracked: Option.none(),
|
||||
@@ -687,88 +654,6 @@ describe("Conversation duplicate processing", () => {
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("retry resets to pending and second run processes again", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
const sendCount = { value: 0 }
|
||||
|
||||
// A ledger that allows retry -> re-process flow
|
||||
const admitted = new Set<string>()
|
||||
const state = new Map<string, "pending" | "processing" | "completed">()
|
||||
const ledgerService: ConversationLedger.Service = {
|
||||
admit: (event) =>
|
||||
Effect.sync(() => {
|
||||
if (admitted.has(event.message_id)) return false
|
||||
admitted.add(event.message_id)
|
||||
state.set(event.message_id, "pending")
|
||||
return true
|
||||
}),
|
||||
replayPending: () => Effect.succeed([]),
|
||||
start: (message_id) =>
|
||||
Effect.sync(() => {
|
||||
if (state.get(message_id) !== "pending") return Option.none()
|
||||
state.set(message_id, "processing")
|
||||
return Option.some(MessageState.make({
|
||||
thread_id: null,
|
||||
channel_id: null,
|
||||
response_text: null,
|
||||
prompt_text: null,
|
||||
session_id: null,
|
||||
}))
|
||||
}),
|
||||
setTarget: () => Effect.void,
|
||||
setPrompt: () => Effect.void,
|
||||
setResponse: () => Effect.void,
|
||||
complete: (message_id) =>
|
||||
Effect.sync(() => { state.set(message_id, "completed") }),
|
||||
retry: (message_id) =>
|
||||
Effect.sync(() => { state.set(message_id, "pending") }),
|
||||
prune: () => Effect.void,
|
||||
getOffset: () => Effect.succeed(Option.none()),
|
||||
setOffset: () => Effect.void,
|
||||
}
|
||||
|
||||
const event = makeEvent("build it")
|
||||
|
||||
const live = makeConversationLayerWithLedger({
|
||||
events: [],
|
||||
tracked: Option.none(),
|
||||
resolves: [makeSession("s1"), makeSession("s2")],
|
||||
send: (_session, text) => {
|
||||
sendCount.value += 1
|
||||
if (sendCount.value === 1) {
|
||||
return Effect.fail(
|
||||
SandboxDeadError.make({
|
||||
threadId: ThreadId.make("t1"),
|
||||
reason: "dead",
|
||||
}),
|
||||
)
|
||||
}
|
||||
return Effect.succeed(`ok:${text}`)
|
||||
},
|
||||
rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
|
||||
actions,
|
||||
prompts,
|
||||
ledger: ledgerService,
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const conversation = yield* Conversation
|
||||
// First turn fails (SandboxDeadError caught + recovery), so it processes once
|
||||
yield* conversation.turn(event)
|
||||
|
||||
// After the first turn, the ledger state should be completed (recovery succeeded inline)
|
||||
// A second turn with the same event should be blocked by admit
|
||||
yield* conversation.turn(event)
|
||||
|
||||
// The second turn should NOT have sent the prompt again
|
||||
// (admit returns false because the message was already admitted)
|
||||
const sendActions = actions.filter((x) => x.kind === "send")
|
||||
// The first turn should have: recovery message + successful reply
|
||||
expect(sendActions.length).toBeGreaterThanOrEqual(1)
|
||||
}).pipe(Effect.provide(live))
|
||||
})
|
||||
|
||||
effectTest("two different messages on same thread are processed sequentially (not lost)", () => {
|
||||
const actions: Array<Action> = []
|
||||
const prompts: Array<string> = []
|
||||
|
||||
@@ -4,7 +4,7 @@ import { TurnRouter } from "../../discord/turn-routing"
|
||||
import { ActorMap } from "../../lib/actors/keyed"
|
||||
import { ThreadAgentPool } from "../../sandbox/pool"
|
||||
import type { ChannelId, ThreadId } from "../../types"
|
||||
import { type ConversationError, messageOf, ReliabilityError, RoutingError, SandboxSendError } from "../model/errors"
|
||||
import { type ConversationError, messageOf, RoutingError, SandboxSendError } from "../model/errors"
|
||||
import { Send, type Inbound } from "../model/schema"
|
||||
import { History } from "./history"
|
||||
import { Inbox } from "./inbox"
|
||||
@@ -53,15 +53,7 @@ export class Conversation extends Context.Tag("@discord/conversation/Conversatio
|
||||
SandboxSendError.make({
|
||||
thread_id,
|
||||
message: messageOf(cause),
|
||||
retriable: RETRIABLE_TAGS.has(cause._tag),
|
||||
})
|
||||
|
||||
const asReliabilityError = (message_id: string) =>
|
||||
(cause: unknown): ReliabilityError =>
|
||||
ReliabilityError.make({
|
||||
message_id,
|
||||
message: messageOf(cause),
|
||||
retriable: true,
|
||||
retriable: RETRIABLE_TAGS.has(cause._tag),
|
||||
})
|
||||
|
||||
const publishText = (threadId: ThreadId, text: string) =>
|
||||
@@ -86,29 +78,31 @@ export class Conversation extends Context.Tag("@discord/conversation/Conversatio
|
||||
if (event.mentions_everyone) return false
|
||||
if (!event.content.trim()) return false
|
||||
|
||||
const mentioned = event.mentions.user_ids.includes(event.bot_user_id)
|
||||
|| (event.bot_role_id.length > 0 && event.mentions.role_ids.includes(event.bot_role_id))
|
||||
const mentioned =
|
||||
event.mentions.user_ids.includes(event.bot_user_id) ||
|
||||
(event.bot_role_id.length > 0 && event.mentions.role_ids.includes(event.bot_role_id))
|
||||
if (event.kind === "channel_message") return mentioned
|
||||
if (mentioned) return true
|
||||
|
||||
const owned = yield* pool.hasTrackedThread(event.thread_id).pipe(
|
||||
Effect.mapError(asSendError(event.thread_id)),
|
||||
)
|
||||
const owned = yield* pool.hasTrackedThread(event.thread_id).pipe(Effect.mapError(asSendError(event.thread_id)))
|
||||
if (!owned) return false
|
||||
|
||||
const decision = yield* router.shouldRespond({
|
||||
content: event.content,
|
||||
botUserId: event.bot_user_id,
|
||||
botRoleId: event.bot_role_id,
|
||||
mentionedUserIds: event.mentions.user_ids,
|
||||
mentionedRoleIds: event.mentions.role_ids,
|
||||
}).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
RoutingError.make({
|
||||
message: messageOf(cause),
|
||||
retriable: false,
|
||||
})),
|
||||
)
|
||||
const decision = yield* router
|
||||
.shouldRespond({
|
||||
content: event.content,
|
||||
botUserId: event.bot_user_id,
|
||||
botRoleId: event.bot_role_id,
|
||||
mentionedUserIds: event.mentions.user_ids,
|
||||
mentionedRoleIds: event.mentions.role_ids,
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError((cause) =>
|
||||
RoutingError.make({
|
||||
message: messageOf(cause),
|
||||
retriable: false,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return decision.shouldRespond
|
||||
})
|
||||
|
||||
@@ -125,18 +119,15 @@ export class Conversation extends Context.Tag("@discord/conversation/Conversatio
|
||||
target: { thread_id: ThreadId; channel_id: ChannelId },
|
||||
) {
|
||||
const toSendError = asSendError(target.thread_id)
|
||||
const tracked = yield* pool.getTrackedSession(target.thread_id).pipe(
|
||||
Effect.mapError(toSendError),
|
||||
)
|
||||
const agent = yield* pool.getOrCreate(target.thread_id, target.channel_id, event.guild_id).pipe(
|
||||
Effect.mapError(toSendError),
|
||||
)
|
||||
const current = yield* agent.current().pipe(
|
||||
Effect.mapError(toSendError),
|
||||
)
|
||||
const prompt = Option.isSome(tracked) && tracked.value.sessionId !== current.sessionId
|
||||
? yield* history.rehydrate(target.thread_id, event.content)
|
||||
: event.content
|
||||
const tracked = yield* pool.getTrackedSession(target.thread_id).pipe(Effect.mapError(toSendError))
|
||||
const agent = yield* pool
|
||||
.getOrCreate(target.thread_id, target.channel_id, event.guild_id)
|
||||
.pipe(Effect.mapError(toSendError))
|
||||
const current = yield* agent.current().pipe(Effect.mapError(toSendError))
|
||||
const prompt =
|
||||
Option.isSome(tracked) && tracked.value.sessionId !== current.sessionId
|
||||
? yield* history.rehydrate(target.thread_id, event.content)
|
||||
: event.content
|
||||
return { target, agent, prompt, session: current }
|
||||
})
|
||||
|
||||
@@ -149,7 +140,9 @@ export class Conversation extends Context.Tag("@discord/conversation/Conversatio
|
||||
return true
|
||||
}
|
||||
if (text === "!status") {
|
||||
const tracked = yield* pool.getTrackedSession(target.thread_id).pipe(Effect.catchAll(() => Effect.succeed(Option.none())))
|
||||
const tracked = yield* pool
|
||||
.getTrackedSession(target.thread_id)
|
||||
.pipe(Effect.catchAll(() => Effect.succeed(Option.none())))
|
||||
if (Option.isNone(tracked)) {
|
||||
yield* publishText(target.thread_id, "*No active session for this thread.*")
|
||||
} else {
|
||||
@@ -170,30 +163,10 @@ export class Conversation extends Context.Tag("@discord/conversation/Conversatio
|
||||
return false
|
||||
})
|
||||
|
||||
const turnRaw = Effect.fn("Conversation.turnRaw")(function* (
|
||||
event: Inbound,
|
||||
state: {
|
||||
thread_id: ThreadId | null
|
||||
channel_id: ChannelId | null
|
||||
response_text: string | null
|
||||
prompt_text: string | null
|
||||
session_id: string | null
|
||||
},
|
||||
) {
|
||||
const turnRaw = Effect.fn("Conversation.turnRaw")(function* (event: Inbound) {
|
||||
if (!(yield* route(event))) return
|
||||
|
||||
const target = state.thread_id && state.channel_id
|
||||
? { thread_id: state.thread_id, channel_id: state.channel_id }
|
||||
: yield* resolve(event)
|
||||
|
||||
yield* ledger.setTarget(event.message_id, target.thread_id, target.channel_id).pipe(
|
||||
Effect.mapError(asReliabilityError(event.message_id)),
|
||||
)
|
||||
|
||||
if (state.response_text) {
|
||||
yield* publishText(target.thread_id, state.response_text)
|
||||
return
|
||||
}
|
||||
const target = yield* resolve(event)
|
||||
|
||||
if (yield* command(event, target)) return
|
||||
|
||||
@@ -206,116 +179,75 @@ export class Conversation extends Context.Tag("@discord/conversation/Conversatio
|
||||
}),
|
||||
)
|
||||
|
||||
yield* outbox.withTyping(
|
||||
target.thread_id,
|
||||
Effect.gen(function* () {
|
||||
const input = yield* buildInput(event, target)
|
||||
const reuse = state.prompt_text !== null
|
||||
&& state.session_id !== null
|
||||
&& state.session_id === input.session.sessionId
|
||||
const prompt = reuse ? (state.prompt_text ?? input.prompt) : input.prompt
|
||||
if (!reuse) {
|
||||
yield* ledger.setPrompt(event.message_id, prompt, input.session.sessionId).pipe(
|
||||
Effect.mapError(asReliabilityError(event.message_id)),
|
||||
yield* outbox
|
||||
.withTyping(
|
||||
target.thread_id,
|
||||
Effect.gen(function* () {
|
||||
const input = yield* buildInput(event, target)
|
||||
|
||||
const reply = yield* input.agent.send(input.prompt).pipe(
|
||||
Effect.catchTag("SandboxDeadError", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* publishText(input.target.thread_id, "*Session changed state, recovering...*")
|
||||
const toErr = asSendError(input.target.thread_id)
|
||||
const next = yield* pool
|
||||
.getOrCreate(input.target.thread_id, input.target.channel_id, event.guild_id)
|
||||
.pipe(Effect.mapError(toErr))
|
||||
const nextSession = yield* next.current().pipe(Effect.mapError(toErr))
|
||||
const prompt =
|
||||
nextSession.sessionId !== input.session.sessionId
|
||||
? yield* history.rehydrate(input.target.thread_id, event.content)
|
||||
: event.content
|
||||
return yield* next.send(prompt)
|
||||
}),
|
||||
),
|
||||
Effect.mapError(asSendError(input.target.thread_id)),
|
||||
)
|
||||
} else {
|
||||
yield* Effect.logInfo("Recovered in-flight prompt from ledger").pipe(
|
||||
|
||||
yield* Effect.logInfo("Bot reply").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.ledger.prompt.reused",
|
||||
message_id: event.message_id,
|
||||
thread_id: target.thread_id,
|
||||
event: "conversation.bot.reply",
|
||||
thread_id: input.target.thread_id,
|
||||
content: reply.slice(0, 200),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const reply = yield* input.agent.send(prompt).pipe(
|
||||
Effect.catchTag("SandboxDeadError", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* publishText(input.target.thread_id, "*Session changed state, recovering...*")
|
||||
const toErr = asSendError(input.target.thread_id)
|
||||
const next = yield* pool.getOrCreate(
|
||||
input.target.thread_id,
|
||||
input.target.channel_id,
|
||||
event.guild_id,
|
||||
).pipe(Effect.mapError(toErr))
|
||||
const nextSession = yield* next.current().pipe(
|
||||
Effect.mapError(toErr),
|
||||
)
|
||||
const prompt = nextSession.sessionId !== input.session.sessionId
|
||||
? yield* history.rehydrate(input.target.thread_id, event.content)
|
||||
: event.content
|
||||
return yield* next.send(prompt)
|
||||
}),
|
||||
),
|
||||
Effect.mapError(asSendError(input.target.thread_id)),
|
||||
)
|
||||
|
||||
yield* Effect.logInfo("Bot reply").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.bot.reply",
|
||||
thread_id: input.target.thread_id,
|
||||
content: reply.slice(0, 200),
|
||||
}),
|
||||
)
|
||||
yield* ledger.setResponse(event.message_id, reply).pipe(
|
||||
Effect.mapError(asReliabilityError(event.message_id)),
|
||||
)
|
||||
yield* publishText(input.target.thread_id, reply)
|
||||
}),
|
||||
).pipe(
|
||||
Effect.catchAll(reportFailure(target.thread_id)),
|
||||
)
|
||||
yield* publishText(input.target.thread_id, reply)
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.catchAll(reportFailure(target.thread_id)))
|
||||
})
|
||||
|
||||
const keyOf = (event: Inbound) =>
|
||||
event.kind === "thread_message"
|
||||
? `thread:${event.thread_id}`
|
||||
: `channel:${event.channel_id}`
|
||||
event.kind === "thread_message" ? `thread:${event.thread_id}` : `channel:${event.channel_id}`
|
||||
|
||||
const runEvent = Effect.fn("Conversation.runEvent")(function* (event: Inbound) {
|
||||
yield* ledger.admit(event).pipe(
|
||||
Effect.mapError(asReliabilityError(event.message_id)),
|
||||
)
|
||||
const state = yield* ledger.start(event.message_id).pipe(
|
||||
Effect.mapError(asReliabilityError(event.message_id)),
|
||||
)
|
||||
if (Option.isNone(state)) return
|
||||
|
||||
yield* turnRaw(event, state.value).pipe(
|
||||
Effect.tap(() =>
|
||||
ledger.complete(event.message_id).pipe(
|
||||
Effect.mapError(asReliabilityError(event.message_id)),
|
||||
)),
|
||||
Effect.catchAll((error) =>
|
||||
ledger.retry(event.message_id, messageOf(error).slice(0, 500)).pipe(
|
||||
Effect.mapError(asReliabilityError(event.message_id)),
|
||||
Effect.zipRight(Effect.fail(error)),
|
||||
)),
|
||||
)
|
||||
})
|
||||
const processEvent = (event: Inbound) => actors.run(keyOf(event), turnRaw(event), { touch: false })
|
||||
|
||||
const turn = Effect.fn("Conversation.turn")(function* (event: Inbound) {
|
||||
yield* actors.run(
|
||||
keyOf(event),
|
||||
runEvent(event),
|
||||
{ touch: false },
|
||||
)
|
||||
const fresh = yield* ledger.dedup(event.message_id)
|
||||
if (!fresh) return
|
||||
yield* processEvent(event)
|
||||
})
|
||||
|
||||
const run = inbox.events.pipe(
|
||||
Stream.mapEffect(
|
||||
(event) =>
|
||||
turn(event).pipe(
|
||||
Effect.retry(turnRetry),
|
||||
Effect.catchAll((error) =>
|
||||
Effect.logError("Conversation turn failed").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.turn.failed",
|
||||
tag: error._tag,
|
||||
retriable: error.retriable,
|
||||
message: error.message,
|
||||
}),
|
||||
)),
|
||||
ledger.dedup(event.message_id).pipe(
|
||||
Effect.flatMap((fresh) => {
|
||||
if (!fresh) return Effect.void
|
||||
return processEvent(event).pipe(
|
||||
Effect.retry(turnRetry),
|
||||
Effect.catchAll((error) =>
|
||||
Effect.logError("Conversation turn failed").pipe(
|
||||
Effect.annotateLogs({
|
||||
event: "conversation.turn.failed",
|
||||
tag: error._tag,
|
||||
retriable: error.retriable,
|
||||
message: error.message,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
{ concurrency: "unbounded", unordered: true },
|
||||
),
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import * as Client from "@effect/sql/SqlClient"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Duration, Effect, Layer, Option, Redacted } from "effect"
|
||||
import { AppConfig } from "../../config"
|
||||
import { SqliteDb } from "../../db/client"
|
||||
import { initializeSchema } from "../../db/init"
|
||||
import { effectTest, withTempSqliteFile } from "../../test/effect"
|
||||
import { ChannelId, GuildId, SessionId, ThreadId } from "../../types"
|
||||
import { Mention, ThreadMessage, type Inbound } from "../model/schema"
|
||||
import { ConversationLedger } from "./ledger"
|
||||
|
||||
const makeConfig = (databasePath: string) =>
|
||||
@@ -38,118 +34,30 @@ const makeConfig = (databasePath: string) =>
|
||||
openCodeModel: "opencode/claude-sonnet-4-5",
|
||||
})
|
||||
|
||||
const event = (message_id: string, content: string): Inbound =>
|
||||
ThreadMessage.make({
|
||||
kind: "thread_message",
|
||||
thread_id: ThreadId.make("t1"),
|
||||
channel_id: ChannelId.make("c1"),
|
||||
message_id,
|
||||
guild_id: GuildId.make("g1"),
|
||||
bot_user_id: "bot-1",
|
||||
bot_role_id: "role-1",
|
||||
author_id: "u1",
|
||||
author_is_bot: false,
|
||||
mentions_everyone: false,
|
||||
mentions: Mention.make({ user_ids: ["bot-1"], role_ids: [] }),
|
||||
content,
|
||||
})
|
||||
|
||||
const withLedger = <A, E, R>(
|
||||
run: (ledger: ConversationLedger.Service, sql: Client.SqlClient) => Effect.Effect<A, E, R>,
|
||||
run: (ledger: ConversationLedger.Service) => Effect.Effect<A, E, R>,
|
||||
) =>
|
||||
withTempSqliteFile((databasePath) =>
|
||||
Effect.gen(function* () {
|
||||
const config = Layer.succeed(AppConfig, makeConfig(databasePath))
|
||||
const sqlite = SqliteDb.layer.pipe(Layer.provide(config))
|
||||
const deps = Layer.merge(sqlite, config)
|
||||
const live = Layer.merge(
|
||||
ConversationLedger.layer.pipe(Layer.provide(deps)),
|
||||
sqlite,
|
||||
)
|
||||
const program = Effect.all([ConversationLedger, SqliteDb]).pipe(
|
||||
Effect.flatMap(([ledger, sql]) =>
|
||||
initializeSchema.pipe(
|
||||
Effect.provideService(Client.SqlClient, sql),
|
||||
Effect.zipRight(run(ledger, sql)),
|
||||
)),
|
||||
)
|
||||
const live = ConversationLedger.layer.pipe(Layer.provide(deps))
|
||||
const program = Effect.flatMap(ConversationLedger, (ledger) => run(ledger))
|
||||
return yield* program.pipe(Effect.provide(live))
|
||||
}),
|
||||
"discord-ledger-",
|
||||
)
|
||||
|
||||
describe("ConversationLedger", () => {
|
||||
effectTest("deduplicates by message id and tracks completion", () =>
|
||||
withLedger((ledger) =>
|
||||
Effect.gen(function* () {
|
||||
const m = event("m1", "hello")
|
||||
expect(yield* ledger.admit(m)).toBe(true)
|
||||
expect(yield* ledger.admit(m)).toBe(false)
|
||||
|
||||
const started = yield* ledger.start(m.message_id)
|
||||
expect(Option.isSome(started)).toBe(true)
|
||||
if (Option.isNone(started)) return
|
||||
|
||||
yield* ledger.setTarget(m.message_id, ThreadId.make("t1"), ChannelId.make("c1"))
|
||||
yield* ledger.setPrompt(m.message_id, "prompt:hello", SessionId.make("s1"))
|
||||
yield* ledger.setResponse(m.message_id, "reply:hello")
|
||||
yield* ledger.complete(m.message_id)
|
||||
|
||||
const next = yield* ledger.start(m.message_id)
|
||||
expect(Option.isNone(next)).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
effectTest("replays pending rows and recovers processing rows", () =>
|
||||
withLedger((ledger) =>
|
||||
Effect.gen(function* () {
|
||||
const a = event("m-a", "one")
|
||||
const b = event("m-b", "two")
|
||||
yield* ledger.admit(a)
|
||||
yield* ledger.admit(b)
|
||||
|
||||
const started = yield* ledger.start(a.message_id)
|
||||
expect(Option.isSome(started)).toBe(true)
|
||||
|
||||
const replay = yield* ledger.replayPending()
|
||||
expect(replay.map((x) => x.message_id)).toEqual(["m-a", "m-b"])
|
||||
|
||||
const again = yield* ledger.start(a.message_id)
|
||||
expect(Option.isSome(again)).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
effectTest("retains cached response across retry and prunes old completed rows", () =>
|
||||
withLedger((ledger, sql) =>
|
||||
Effect.gen(function* () {
|
||||
const m = event("m-cache", "cache")
|
||||
yield* ledger.admit(m)
|
||||
yield* ledger.start(m.message_id)
|
||||
yield* ledger.setTarget(m.message_id, ThreadId.make("t1"), ChannelId.make("c1"))
|
||||
yield* ledger.setPrompt(m.message_id, "prompt:cache", SessionId.make("s1"))
|
||||
yield* ledger.setResponse(m.message_id, "reply:cache")
|
||||
yield* ledger.retry(m.message_id, "send failed")
|
||||
|
||||
const resumed = yield* ledger.start(m.message_id)
|
||||
expect(Option.isSome(resumed)).toBe(true)
|
||||
if (Option.isSome(resumed)) {
|
||||
expect(resumed.value.response_text).toBe("reply:cache")
|
||||
expect(resumed.value.thread_id).toBe(ThreadId.make("t1"))
|
||||
expect(resumed.value.channel_id).toBe(ChannelId.make("c1"))
|
||||
}
|
||||
|
||||
yield* ledger.complete(m.message_id)
|
||||
yield* sql`UPDATE conversation_inbox
|
||||
SET completed_at = datetime('now', '-10 minutes')
|
||||
WHERE message_id = ${m.message_id}`
|
||||
yield* ledger.prune()
|
||||
|
||||
const rows = yield* sql<{ n: number }>`SELECT COUNT(*) AS n FROM conversation_inbox WHERE message_id = ${m.message_id}`
|
||||
expect(rows[0]?.n ?? 0).toBe(0)
|
||||
}),
|
||||
),
|
||||
effectTest("dedup returns true first time, false second time", () =>
|
||||
Effect.gen(function* () {
|
||||
const ledger = yield* ConversationLedger
|
||||
expect(yield* ledger.dedup("m1")).toBe(true)
|
||||
expect(yield* ledger.dedup("m1")).toBe(false)
|
||||
expect(yield* ledger.dedup("m2")).toBe(true)
|
||||
expect(yield* ledger.dedup("m2")).toBe(false)
|
||||
}).pipe(Effect.provide(ConversationLedger.noop)),
|
||||
)
|
||||
|
||||
effectTest("stores and updates source offsets", () =>
|
||||
@@ -163,4 +71,23 @@ describe("ConversationLedger", () => {
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
effectTest("dedup works in layer mode", () =>
|
||||
withLedger((ledger) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* ledger.dedup("m1")).toBe(true)
|
||||
expect(yield* ledger.dedup("m1")).toBe(false)
|
||||
expect(yield* ledger.dedup("m2")).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
effectTest("noop offsets always return none", () =>
|
||||
Effect.gen(function* () {
|
||||
const ledger = yield* ConversationLedger
|
||||
expect(Option.isNone(yield* ledger.getOffset("thread:t1"))).toBe(true)
|
||||
yield* ledger.setOffset("thread:t1", "m1")
|
||||
expect(Option.isNone(yield* ledger.getOffset("thread:t1"))).toBe(true)
|
||||
}).pipe(Effect.provide(ConversationLedger.noop)),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,65 +1,32 @@
|
||||
import * as Client from "@effect/sql/SqlClient"
|
||||
import { Context, Effect, Layer, Option, Schedule, Schema } from "effect"
|
||||
import { AppConfig } from "../../config"
|
||||
import { Context, Effect, Layer, Option } from "effect"
|
||||
import { SqliteDb } from "../../db/client"
|
||||
import { initializeSchema } from "../../db/init"
|
||||
import { DatabaseError } from "../../errors"
|
||||
import { ChannelId, SessionId, ThreadId } from "../../types"
|
||||
import { Inbound } from "../model/schema"
|
||||
|
||||
const DEDUP_TTL_MINUTES = 5
|
||||
const PRUNE_BATCH_SIZE = 500
|
||||
|
||||
type Snapshot = {
|
||||
thread_id: ThreadId | null
|
||||
channel_id: ChannelId | null
|
||||
response_text: string | null
|
||||
prompt_text: string | null
|
||||
session_id: SessionId | null
|
||||
}
|
||||
|
||||
export class MessageState extends Schema.Class<MessageState>("MessageState")({
|
||||
thread_id: Schema.NullOr(ThreadId),
|
||||
channel_id: Schema.NullOr(ChannelId),
|
||||
response_text: Schema.NullOr(Schema.String),
|
||||
prompt_text: Schema.NullOr(Schema.String),
|
||||
session_id: Schema.NullOr(SessionId),
|
||||
}) {}
|
||||
|
||||
const InboundJson = Schema.parseJson(Inbound)
|
||||
const decode = Schema.decodeUnknown(InboundJson)
|
||||
const encode = Schema.encode(InboundJson)
|
||||
const DEDUP_LIMIT = 4_000
|
||||
|
||||
const db = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(Effect.mapError((cause) => new DatabaseError({ cause })))
|
||||
|
||||
const changes = (sql: Client.SqlClient) =>
|
||||
db(
|
||||
sql<{ n: number }>`SELECT changes() AS n`.pipe(
|
||||
Effect.map((rows) => rows[0]?.n ?? 0),
|
||||
),
|
||||
)
|
||||
|
||||
const toState = (row: Snapshot) =>
|
||||
MessageState.make({
|
||||
thread_id: row.thread_id,
|
||||
channel_id: row.channel_id,
|
||||
response_text: row.response_text,
|
||||
prompt_text: row.prompt_text,
|
||||
session_id: row.session_id,
|
||||
})
|
||||
const makeDedupSet = () => {
|
||||
const seen = new Set<string>()
|
||||
const order: Array<string> = []
|
||||
return (message_id: string): boolean => {
|
||||
if (seen.has(message_id)) return false
|
||||
seen.add(message_id)
|
||||
order.push(message_id)
|
||||
if (order.length > DEDUP_LIMIT) {
|
||||
const oldest = order.shift()
|
||||
if (oldest) seen.delete(oldest)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export declare namespace ConversationLedger {
|
||||
export interface Service {
|
||||
readonly admit: (event: Inbound) => Effect.Effect<boolean, DatabaseError>
|
||||
readonly replayPending: () => Effect.Effect<ReadonlyArray<Inbound>, DatabaseError>
|
||||
readonly start: (message_id: string) => Effect.Effect<Option.Option<MessageState>, DatabaseError>
|
||||
readonly setTarget: (message_id: string, thread_id: ThreadId, channel_id: ChannelId) => Effect.Effect<void, DatabaseError>
|
||||
readonly setPrompt: (message_id: string, prompt: string, session_id: SessionId) => Effect.Effect<void, DatabaseError>
|
||||
readonly setResponse: (message_id: string, response: string) => Effect.Effect<void, DatabaseError>
|
||||
readonly complete: (message_id: string) => Effect.Effect<void, DatabaseError>
|
||||
readonly retry: (message_id: string, error: string) => Effect.Effect<void, DatabaseError>
|
||||
readonly prune: () => Effect.Effect<void, DatabaseError>
|
||||
readonly dedup: (message_id: string) => Effect.Effect<boolean>
|
||||
readonly getOffset: (source_id: string) => Effect.Effect<Option.Option<string>, DatabaseError>
|
||||
readonly setOffset: (source_id: string, message_id: string) => Effect.Effect<void, DatabaseError>
|
||||
}
|
||||
@@ -69,161 +36,22 @@ export class ConversationLedger extends Context.Tag("@discord/conversation/Conve
|
||||
ConversationLedger,
|
||||
ConversationLedger.Service
|
||||
>() {
|
||||
static readonly noop = Layer.effect(
|
||||
ConversationLedger,
|
||||
Effect.sync(() => {
|
||||
const pending = new Set<string>()
|
||||
const completed = new Set<string>()
|
||||
return ConversationLedger.of({
|
||||
admit: (event) =>
|
||||
Effect.sync(() => {
|
||||
if (pending.has(event.message_id) || completed.has(event.message_id)) return false
|
||||
pending.add(event.message_id)
|
||||
return true
|
||||
}),
|
||||
replayPending: () => Effect.succeed([]),
|
||||
start: (message_id) =>
|
||||
Effect.sync(() => {
|
||||
if (!pending.has(message_id)) return Option.none()
|
||||
pending.delete(message_id)
|
||||
return Option.some(MessageState.make({
|
||||
thread_id: null,
|
||||
channel_id: null,
|
||||
response_text: null,
|
||||
prompt_text: null,
|
||||
session_id: null,
|
||||
}))
|
||||
}),
|
||||
setTarget: () => Effect.void,
|
||||
setPrompt: () => Effect.void,
|
||||
setResponse: () => Effect.void,
|
||||
complete: (message_id) => Effect.sync(() => { completed.add(message_id) }),
|
||||
retry: (message_id) => Effect.sync(() => { pending.add(message_id) }),
|
||||
prune: () => Effect.void,
|
||||
getOffset: () => Effect.succeed(Option.none()),
|
||||
setOffset: () => Effect.void,
|
||||
})
|
||||
}),
|
||||
)
|
||||
static readonly noop = Layer.sync(ConversationLedger, () => {
|
||||
const check = makeDedupSet()
|
||||
return ConversationLedger.of({
|
||||
dedup: (message_id) => Effect.sync(() => check(message_id)),
|
||||
getOffset: () => Effect.succeed(Option.none()),
|
||||
setOffset: () => Effect.void,
|
||||
})
|
||||
})
|
||||
|
||||
static readonly layer = Layer.scoped(
|
||||
static readonly layer = Layer.effect(
|
||||
ConversationLedger,
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqliteDb
|
||||
const config = yield* AppConfig
|
||||
yield* db(initializeSchema.pipe(Effect.provideService(Client.SqlClient, sql)))
|
||||
|
||||
const admit = Effect.fn("ConversationLedger.admit")(function* (event: Inbound) {
|
||||
const payload = yield* encode(event).pipe(
|
||||
Effect.mapError((cause) => new DatabaseError({ cause })),
|
||||
)
|
||||
yield* db(
|
||||
sql`INSERT OR IGNORE INTO conversation_inbox (message_id, kind, payload_json, status, created_at, updated_at)
|
||||
VALUES (${event.message_id}, ${event.kind}, ${payload}, 'pending', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
)
|
||||
return (yield* changes(sql)) > 0
|
||||
})
|
||||
|
||||
const replayPending = Effect.fn("ConversationLedger.replayPending")(function* () {
|
||||
yield* db(
|
||||
sql`UPDATE conversation_inbox
|
||||
SET status = 'pending', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE status = 'processing'`,
|
||||
)
|
||||
const rows = yield* db(
|
||||
sql<{ payload_json: string }>`SELECT payload_json
|
||||
FROM conversation_inbox
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at ASC`,
|
||||
)
|
||||
return yield* Effect.forEach(rows, (row) =>
|
||||
decode(row.payload_json).pipe(Effect.mapError((cause) => new DatabaseError({ cause }))),
|
||||
)
|
||||
})
|
||||
|
||||
const start = Effect.fn("ConversationLedger.start")(function* (message_id: string) {
|
||||
yield* db(
|
||||
sql`UPDATE conversation_inbox
|
||||
SET status = 'processing', attempts = attempts + 1,
|
||||
processing_started_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE message_id = ${message_id} AND status = 'pending'`,
|
||||
)
|
||||
if ((yield* changes(sql)) === 0) return Option.none<MessageState>()
|
||||
const rows = yield* db(
|
||||
sql<Snapshot>`SELECT thread_id, channel_id, response_text, prompt_text, session_id
|
||||
FROM conversation_inbox
|
||||
WHERE message_id = ${message_id}
|
||||
LIMIT 1`,
|
||||
)
|
||||
const row = rows[0]
|
||||
if (!row) return Option.none<MessageState>()
|
||||
return Option.some(toState(row))
|
||||
})
|
||||
|
||||
const setTarget = Effect.fn("ConversationLedger.setTarget")(function* (
|
||||
message_id: string,
|
||||
thread_id: ThreadId,
|
||||
channel_id: ChannelId,
|
||||
) {
|
||||
yield* db(
|
||||
sql`UPDATE conversation_inbox
|
||||
SET thread_id = ${thread_id}, channel_id = ${channel_id}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE message_id = ${message_id}`,
|
||||
)
|
||||
})
|
||||
|
||||
const setPrompt = Effect.fn("ConversationLedger.setPrompt")(function* (
|
||||
message_id: string,
|
||||
prompt: string,
|
||||
session_id: SessionId,
|
||||
) {
|
||||
yield* db(
|
||||
sql`UPDATE conversation_inbox
|
||||
SET prompt_text = ${prompt}, session_id = ${session_id}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE message_id = ${message_id}`,
|
||||
)
|
||||
})
|
||||
|
||||
const setResponse = Effect.fn("ConversationLedger.setResponse")(function* (message_id: string, response: string) {
|
||||
yield* db(
|
||||
sql`UPDATE conversation_inbox
|
||||
SET response_text = ${response}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE message_id = ${message_id}`,
|
||||
)
|
||||
})
|
||||
|
||||
const complete = Effect.fn("ConversationLedger.complete")(function* (message_id: string) {
|
||||
yield* db(
|
||||
sql`UPDATE conversation_inbox
|
||||
SET status = 'completed', completed_at = CURRENT_TIMESTAMP,
|
||||
processing_started_at = NULL, last_error = NULL, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE message_id = ${message_id}`,
|
||||
)
|
||||
})
|
||||
|
||||
const retry = Effect.fn("ConversationLedger.retry")(function* (message_id: string, error: string) {
|
||||
yield* db(
|
||||
sql`UPDATE conversation_inbox
|
||||
SET status = 'pending', last_error = ${error}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE message_id = ${message_id}`,
|
||||
)
|
||||
})
|
||||
|
||||
const prune = Effect.fn("ConversationLedger.prune")(function* () {
|
||||
yield* db(
|
||||
sql`DELETE FROM conversation_inbox
|
||||
WHERE message_id IN (
|
||||
SELECT message_id
|
||||
FROM conversation_inbox
|
||||
WHERE status = 'completed'
|
||||
AND completed_at IS NOT NULL
|
||||
AND completed_at < datetime('now', '-' || ${DEDUP_TTL_MINUTES} || ' minutes')
|
||||
ORDER BY completed_at ASC
|
||||
LIMIT ${PRUNE_BATCH_SIZE}
|
||||
)`,
|
||||
)
|
||||
})
|
||||
const check = makeDedupSet()
|
||||
|
||||
const getOffset = Effect.fn("ConversationLedger.getOffset")(function* (source_id: string) {
|
||||
const rows = yield* db(
|
||||
@@ -247,25 +75,8 @@ export class ConversationLedger extends Context.Tag("@discord/conversation/Conve
|
||||
)
|
||||
})
|
||||
|
||||
yield* prune().pipe(
|
||||
Effect.catchAll((error) =>
|
||||
Effect.logError("Conversation ledger prune failed").pipe(
|
||||
Effect.annotateLogs({ event: "conversation.ledger.prune.failed", error: String(error) }),
|
||||
)),
|
||||
Effect.repeat(Schedule.spaced(config.cleanupInterval)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return ConversationLedger.of({
|
||||
admit,
|
||||
replayPending,
|
||||
start,
|
||||
setTarget,
|
||||
setPrompt,
|
||||
setResponse,
|
||||
complete,
|
||||
retry,
|
||||
prune,
|
||||
dedup: (message_id) => Effect.sync(() => check(message_id)),
|
||||
getOffset,
|
||||
setOffset,
|
||||
})
|
||||
|
||||
@@ -31,29 +31,6 @@ const indexes = [
|
||||
"discord_sessions_status_updated_at_idx",
|
||||
]
|
||||
|
||||
const inboxColumns = [
|
||||
"message_id",
|
||||
"kind",
|
||||
"payload_json",
|
||||
"status",
|
||||
"thread_id",
|
||||
"channel_id",
|
||||
"prompt_text",
|
||||
"session_id",
|
||||
"response_text",
|
||||
"attempts",
|
||||
"processing_started_at",
|
||||
"completed_at",
|
||||
"last_error",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
const inboxIndexes = [
|
||||
"conversation_inbox_status_created_at_idx",
|
||||
"conversation_inbox_completed_at_idx",
|
||||
]
|
||||
|
||||
const offsetColumns = [
|
||||
"source_id",
|
||||
"last_message_id",
|
||||
@@ -69,21 +46,11 @@ const getColumns = (db: Client.SqlClient) =>
|
||||
Effect.map((rows) => rows.map((row: { name: string }) => row.name)),
|
||||
)
|
||||
|
||||
const getInboxColumns = (db: Client.SqlClient) =>
|
||||
db<{ name: string }>`PRAGMA table_info(conversation_inbox)`.pipe(
|
||||
Effect.map((rows) => rows.map((row: { name: string }) => row.name)),
|
||||
)
|
||||
|
||||
const getIndexes = (db: Client.SqlClient) =>
|
||||
db<{ name: string }>`PRAGMA index_list(discord_sessions)`.pipe(
|
||||
Effect.map((rows) => rows.map((row: { name: string }) => row.name)),
|
||||
)
|
||||
|
||||
const getInboxIndexes = (db: Client.SqlClient) =>
|
||||
db<{ name: string }>`PRAGMA index_list(conversation_inbox)`.pipe(
|
||||
Effect.map((rows) => rows.map((row: { name: string }) => row.name)),
|
||||
)
|
||||
|
||||
const getOffsetColumns = (db: Client.SqlClient) =>
|
||||
db<{ name: string }>`PRAGMA table_info(conversation_offsets)`.pipe(
|
||||
Effect.map((rows) => rows.map((row: { name: string }) => row.name)),
|
||||
@@ -100,27 +67,21 @@ describe("initializeSchema", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* withSqlite(filename, (db) => initializeSchema.pipe(Effect.provideService(Client.SqlClient, db)))
|
||||
const one = yield* withSqlite(filename, getColumns)
|
||||
const inboxOne = yield* withSqlite(filename, getInboxColumns)
|
||||
const offsetOne = yield* withSqlite(filename, getOffsetColumns)
|
||||
expect(one).toEqual(columns)
|
||||
expect(inboxOne).toEqual(inboxColumns)
|
||||
expect(offsetOne).toEqual(offsetColumns)
|
||||
|
||||
yield* withSqlite(filename, (db) => initializeSchema.pipe(Effect.provideService(Client.SqlClient, db)))
|
||||
const two = yield* withSqlite(filename, getColumns)
|
||||
const inboxTwo = yield* withSqlite(filename, getInboxColumns)
|
||||
const offsetTwo = yield* withSqlite(filename, getOffsetColumns)
|
||||
expect(two).toEqual(one)
|
||||
expect(inboxTwo).toEqual(inboxOne)
|
||||
expect(offsetTwo).toEqual(offsetOne)
|
||||
|
||||
const seen = new Set(two)
|
||||
expect(seen.size).toBe(two.length)
|
||||
const actual = (yield* withSqlite(filename, getIndexes)).filter((name) => !name.startsWith("sqlite_"))
|
||||
const inboxActual = (yield* withSqlite(filename, getInboxIndexes)).filter((name) => !name.startsWith("sqlite_"))
|
||||
const offsetActual = (yield* withSqlite(filename, getOffsetIndexes)).filter((name) => !name.startsWith("sqlite_"))
|
||||
expect(new Set(actual)).toEqual(new Set(indexes))
|
||||
expect(new Set(inboxActual)).toEqual(new Set(inboxIndexes))
|
||||
expect(new Set(offsetActual)).toEqual(new Set(offsetIndexes))
|
||||
}),
|
||||
"discord-sessions-",
|
||||
|
||||
@@ -23,24 +23,6 @@ const TABLE = `CREATE TABLE IF NOT EXISTS discord_sessions (
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`
|
||||
|
||||
const INBOX_TABLE = `CREATE TABLE IF NOT EXISTS conversation_inbox (
|
||||
message_id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('thread_message', 'channel_message')),
|
||||
payload_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'processing', 'completed')),
|
||||
thread_id TEXT,
|
||||
channel_id TEXT,
|
||||
prompt_text TEXT,
|
||||
session_id TEXT,
|
||||
response_text TEXT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
processing_started_at TEXT,
|
||||
completed_at TEXT,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`
|
||||
|
||||
const OFFSETS_TABLE = `CREATE TABLE IF NOT EXISTS conversation_offsets (
|
||||
source_id TEXT PRIMARY KEY,
|
||||
last_message_id TEXT NOT NULL,
|
||||
@@ -62,21 +44,6 @@ const COLUMNS = [
|
||||
["resume_fail_count", "INTEGER NOT NULL DEFAULT 0"],
|
||||
] as const
|
||||
|
||||
const INBOX_COLUMNS = [
|
||||
["status", "TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'completed'))"],
|
||||
["thread_id", "TEXT"],
|
||||
["channel_id", "TEXT"],
|
||||
["prompt_text", "TEXT"],
|
||||
["session_id", "TEXT"],
|
||||
["response_text", "TEXT"],
|
||||
["attempts", "INTEGER NOT NULL DEFAULT 0"],
|
||||
["processing_started_at", "TEXT"],
|
||||
["completed_at", "TEXT"],
|
||||
["last_error", "TEXT"],
|
||||
["created_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"],
|
||||
["updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"],
|
||||
] as const
|
||||
|
||||
const OFFSET_COLUMNS = [
|
||||
["last_message_id", "TEXT NOT NULL"],
|
||||
["updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"],
|
||||
@@ -89,13 +56,6 @@ const INDEXES = [
|
||||
ON discord_sessions (status, updated_at)`,
|
||||
] as const
|
||||
|
||||
const INBOX_INDEXES = [
|
||||
`CREATE INDEX IF NOT EXISTS conversation_inbox_status_created_at_idx
|
||||
ON conversation_inbox (status, created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS conversation_inbox_completed_at_idx
|
||||
ON conversation_inbox (completed_at)`,
|
||||
] as const
|
||||
|
||||
const OFFSET_INDEXES = [
|
||||
`CREATE INDEX IF NOT EXISTS conversation_offsets_updated_at_idx
|
||||
ON conversation_offsets (updated_at)`,
|
||||
@@ -104,7 +64,6 @@ const OFFSET_INDEXES = [
|
||||
export default Effect.gen(function* () {
|
||||
const db = yield* Client.SqlClient
|
||||
yield* db.unsafe(TABLE)
|
||||
yield* db.unsafe(INBOX_TABLE)
|
||||
yield* db.unsafe(OFFSETS_TABLE)
|
||||
|
||||
const names = new Set((yield* db<{ name: string }>`PRAGMA table_info(discord_sessions)`).map((row) => row.name))
|
||||
@@ -113,12 +72,6 @@ export default Effect.gen(function* () {
|
||||
discard: true,
|
||||
})
|
||||
|
||||
const inboxNames = new Set((yield* db<{ name: string }>`PRAGMA table_info(conversation_inbox)`).map((row) => row.name))
|
||||
const inboxMissing = INBOX_COLUMNS.filter(([name]) => !inboxNames.has(name))
|
||||
yield* Effect.forEach(inboxMissing, ([name, definition]) => db.unsafe(`ALTER TABLE conversation_inbox ADD COLUMN ${name} ${definition}`), {
|
||||
discard: true,
|
||||
})
|
||||
|
||||
const offsetNames = new Set((yield* db<{ name: string }>`PRAGMA table_info(conversation_offsets)`).map((row) => row.name))
|
||||
const offsetMissing = OFFSET_COLUMNS.filter(([name]) => !offsetNames.has(name))
|
||||
yield* Effect.forEach(offsetMissing, ([name, definition]) => db.unsafe(`ALTER TABLE conversation_offsets ADD COLUMN ${name} ${definition}`), {
|
||||
@@ -126,6 +79,5 @@ export default Effect.gen(function* () {
|
||||
})
|
||||
|
||||
yield* Effect.forEach(INDEXES, (index) => db.unsafe(index), { discard: true })
|
||||
yield* Effect.forEach(INBOX_INDEXES, (index) => db.unsafe(index), { discard: true })
|
||||
yield* Effect.forEach(OFFSET_INDEXES, (index) => db.unsafe(index), { discard: true })
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user