refactor(question): effectify QuestionService (#17432)
This commit is contained in:
@@ -1,5 +1,8 @@
|
|||||||
import { Layer, ManagedRuntime } from "effect"
|
import { Layer, ManagedRuntime } from "effect"
|
||||||
import { AccountService } from "@/account/service"
|
import { AccountService } from "@/account/service"
|
||||||
import { AuthService } from "@/auth/service"
|
import { AuthService } from "@/auth/service"
|
||||||
|
import { QuestionService } from "@/question/service"
|
||||||
|
|
||||||
export const runtime = ManagedRuntime.make(Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer))
|
export const runtime = ManagedRuntime.make(
|
||||||
|
Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer, QuestionService.layer),
|
||||||
|
)
|
||||||
|
|||||||
@@ -79,18 +79,17 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
|
|||||||
ProviderAuthService,
|
ProviderAuthService,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const auth = yield* Auth.AuthService
|
const auth = yield* Auth.AuthService
|
||||||
const state = yield* InstanceState.make({
|
const state = yield* InstanceState.make(() =>
|
||||||
lookup: () =>
|
Effect.promise(async () => {
|
||||||
Effect.promise(async () => {
|
const methods = pipe(
|
||||||
const methods = pipe(
|
await Plugin.list(),
|
||||||
await Plugin.list(),
|
filter((x) => x.auth?.provider !== undefined),
|
||||||
filter((x) => x.auth?.provider !== undefined),
|
map((x) => [x.auth!.provider, x.auth!] as const),
|
||||||
map((x) => [x.auth!.provider, x.auth!] as const),
|
fromEntries(),
|
||||||
fromEntries(),
|
)
|
||||||
)
|
return { methods, pending: new Map<ProviderID, AuthOuathResult>() }
|
||||||
return { methods, pending: new Map<ProviderID, AuthOuathResult>() }
|
}),
|
||||||
}),
|
)
|
||||||
})
|
|
||||||
|
|
||||||
const methods = Effect.fn("ProviderAuthService.methods")(function* () {
|
const methods = Effect.fn("ProviderAuthService.methods")(function* () {
|
||||||
const x = yield* InstanceState.get(state)
|
const x = yield* InstanceState.get(state)
|
||||||
|
|||||||
@@ -1,167 +1,44 @@
|
|||||||
import { Bus } from "@/bus"
|
import { Effect } from "effect"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { runtime } from "@/effect/runtime"
|
||||||
import { SessionID, MessageID } from "@/session/schema"
|
import * as S from "./service"
|
||||||
import { Instance } from "@/project/instance"
|
import type { QuestionID } from "./schema"
|
||||||
import { Log } from "@/util/log"
|
import type { SessionID, MessageID } from "@/session/schema"
|
||||||
import z from "zod"
|
|
||||||
import { QuestionID } from "./schema"
|
function runPromise<A>(f: (service: S.QuestionService.Service) => Effect.Effect<A, S.QuestionServiceError>) {
|
||||||
|
return runtime.runPromise(S.QuestionService.use(f))
|
||||||
|
}
|
||||||
|
|
||||||
export namespace Question {
|
export namespace Question {
|
||||||
const log = Log.create({ service: "question" })
|
export const Option = S.Option
|
||||||
|
export type Option = S.Option
|
||||||
export const Option = z
|
export const Info = S.Info
|
||||||
.object({
|
export type Info = S.Info
|
||||||
label: z.string().describe("Display text (1-5 words, concise)"),
|
export const Request = S.Request
|
||||||
description: z.string().describe("Explanation of choice"),
|
export type Request = S.Request
|
||||||
})
|
export const Answer = S.Answer
|
||||||
.meta({
|
export type Answer = S.Answer
|
||||||
ref: "QuestionOption",
|
export const Reply = S.Reply
|
||||||
})
|
export type Reply = S.Reply
|
||||||
export type Option = z.infer<typeof Option>
|
export const Event = S.Event
|
||||||
|
export const RejectedError = S.RejectedError
|
||||||
export const Info = z
|
|
||||||
.object({
|
|
||||||
question: z.string().describe("Complete question"),
|
|
||||||
header: z.string().describe("Very short label (max 30 chars)"),
|
|
||||||
options: z.array(Option).describe("Available choices"),
|
|
||||||
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
|
|
||||||
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "QuestionInfo",
|
|
||||||
})
|
|
||||||
export type Info = z.infer<typeof Info>
|
|
||||||
|
|
||||||
export const Request = z
|
|
||||||
.object({
|
|
||||||
id: QuestionID.zod,
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
questions: z.array(Info).describe("Questions to ask"),
|
|
||||||
tool: z
|
|
||||||
.object({
|
|
||||||
messageID: MessageID.zod,
|
|
||||||
callID: z.string(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "QuestionRequest",
|
|
||||||
})
|
|
||||||
export type Request = z.infer<typeof Request>
|
|
||||||
|
|
||||||
export const Answer = z.array(z.string()).meta({
|
|
||||||
ref: "QuestionAnswer",
|
|
||||||
})
|
|
||||||
export type Answer = z.infer<typeof Answer>
|
|
||||||
|
|
||||||
export const Reply = z.object({
|
|
||||||
answers: z
|
|
||||||
.array(Answer)
|
|
||||||
.describe("User answers in order of questions (each answer is an array of selected labels)"),
|
|
||||||
})
|
|
||||||
export type Reply = z.infer<typeof Reply>
|
|
||||||
|
|
||||||
export const Event = {
|
|
||||||
Asked: BusEvent.define("question.asked", Request),
|
|
||||||
Replied: BusEvent.define(
|
|
||||||
"question.replied",
|
|
||||||
z.object({
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
requestID: QuestionID.zod,
|
|
||||||
answers: z.array(Answer),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Rejected: BusEvent.define(
|
|
||||||
"question.rejected",
|
|
||||||
z.object({
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
requestID: QuestionID.zod,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PendingEntry {
|
|
||||||
info: Request
|
|
||||||
resolve: (answers: Answer[]) => void
|
|
||||||
reject: (e: any) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = Instance.state(async () => ({
|
|
||||||
pending: new Map<QuestionID, PendingEntry>(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
export async function ask(input: {
|
export async function ask(input: {
|
||||||
sessionID: SessionID
|
sessionID: SessionID
|
||||||
questions: Info[]
|
questions: Info[]
|
||||||
tool?: { messageID: MessageID; callID: string }
|
tool?: { messageID: MessageID; callID: string }
|
||||||
}): Promise<Answer[]> {
|
}): Promise<Answer[]> {
|
||||||
const s = await state()
|
return runPromise((service) => service.ask(input))
|
||||||
const id = QuestionID.ascending()
|
|
||||||
|
|
||||||
log.info("asking", { id, questions: input.questions.length })
|
|
||||||
|
|
||||||
return new Promise<Answer[]>((resolve, reject) => {
|
|
||||||
const info: Request = {
|
|
||||||
id,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
questions: input.questions,
|
|
||||||
tool: input.tool,
|
|
||||||
}
|
|
||||||
s.pending.set(id, {
|
|
||||||
info,
|
|
||||||
resolve,
|
|
||||||
reject,
|
|
||||||
})
|
|
||||||
Bus.publish(Event.Asked, info)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> {
|
export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> {
|
||||||
const s = await state()
|
return runPromise((service) => service.reply(input))
|
||||||
const existing = s.pending.get(input.requestID)
|
|
||||||
if (!existing) {
|
|
||||||
log.warn("reply for unknown request", { requestID: input.requestID })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.pending.delete(input.requestID)
|
|
||||||
|
|
||||||
log.info("replied", { requestID: input.requestID, answers: input.answers })
|
|
||||||
|
|
||||||
Bus.publish(Event.Replied, {
|
|
||||||
sessionID: existing.info.sessionID,
|
|
||||||
requestID: existing.info.id,
|
|
||||||
answers: input.answers,
|
|
||||||
})
|
|
||||||
|
|
||||||
existing.resolve(input.answers)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reject(requestID: QuestionID): Promise<void> {
|
export async function reject(requestID: QuestionID): Promise<void> {
|
||||||
const s = await state()
|
return runPromise((service) => service.reject(requestID))
|
||||||
const existing = s.pending.get(requestID)
|
|
||||||
if (!existing) {
|
|
||||||
log.warn("reject for unknown request", { requestID })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.pending.delete(requestID)
|
|
||||||
|
|
||||||
log.info("rejected", { requestID })
|
|
||||||
|
|
||||||
Bus.publish(Event.Rejected, {
|
|
||||||
sessionID: existing.info.sessionID,
|
|
||||||
requestID: existing.info.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
existing.reject(new RejectedError())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RejectedError extends Error {
|
export async function list(): Promise<Request[]> {
|
||||||
constructor() {
|
return runPromise((service) => service.list())
|
||||||
super("The user dismissed this question")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function list() {
|
|
||||||
return state().then((x) => Array.from(x.pending.values(), (x) => x.info))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,16 @@ import { Schema } from "effect"
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
||||||
import { Identifier } from "@/id/id"
|
import { Identifier } from "@/id/id"
|
||||||
import { withStatics } from "@/util/schema"
|
import { Newtype } from "@/util/schema"
|
||||||
|
|
||||||
const questionIdSchema = Schema.String.pipe(Schema.brand("QuestionID"))
|
export class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String) {
|
||||||
|
static make(id: string): QuestionID {
|
||||||
|
return this.makeUnsafe(id)
|
||||||
|
}
|
||||||
|
|
||||||
export type QuestionID = typeof questionIdSchema.Type
|
static ascending(id?: string): QuestionID {
|
||||||
|
return this.makeUnsafe(Identifier.ascending("question", id))
|
||||||
|
}
|
||||||
|
|
||||||
export const QuestionID = questionIdSchema.pipe(
|
static readonly zod = Identifier.schema("question") as unknown as z.ZodType<QuestionID>
|
||||||
withStatics((schema: typeof questionIdSchema) => ({
|
}
|
||||||
make: (id: string) => schema.makeUnsafe(id),
|
|
||||||
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("question", id)),
|
|
||||||
zod: Identifier.schema("question").pipe(z.custom<QuestionID>()),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { SessionID, MessageID } from "@/session/schema"
|
||||||
|
import { InstanceState } from "@/util/instance-state"
|
||||||
|
import { Log } from "@/util/log"
|
||||||
|
import z from "zod"
|
||||||
|
import { QuestionID } from "./schema"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "question" })
|
||||||
|
|
||||||
|
// --- Zod schemas (re-exported by facade) ---
|
||||||
|
|
||||||
|
export const Option = z
|
||||||
|
.object({
|
||||||
|
label: z.string().describe("Display text (1-5 words, concise)"),
|
||||||
|
description: z.string().describe("Explanation of choice"),
|
||||||
|
})
|
||||||
|
.meta({ ref: "QuestionOption" })
|
||||||
|
export type Option = z.infer<typeof Option>
|
||||||
|
|
||||||
|
export const Info = z
|
||||||
|
.object({
|
||||||
|
question: z.string().describe("Complete question"),
|
||||||
|
header: z.string().describe("Very short label (max 30 chars)"),
|
||||||
|
options: z.array(Option).describe("Available choices"),
|
||||||
|
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
|
||||||
|
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
|
||||||
|
})
|
||||||
|
.meta({ ref: "QuestionInfo" })
|
||||||
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
|
export const Request = z
|
||||||
|
.object({
|
||||||
|
id: QuestionID.zod,
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
questions: z.array(Info).describe("Questions to ask"),
|
||||||
|
tool: z
|
||||||
|
.object({
|
||||||
|
messageID: MessageID.zod,
|
||||||
|
callID: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.meta({ ref: "QuestionRequest" })
|
||||||
|
export type Request = z.infer<typeof Request>
|
||||||
|
|
||||||
|
export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
|
||||||
|
export type Answer = z.infer<typeof Answer>
|
||||||
|
|
||||||
|
export const Reply = z.object({
|
||||||
|
answers: z.array(Answer).describe("User answers in order of questions (each answer is an array of selected labels)"),
|
||||||
|
})
|
||||||
|
export type Reply = z.infer<typeof Reply>
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Asked: BusEvent.define("question.asked", Request),
|
||||||
|
Replied: BusEvent.define(
|
||||||
|
"question.replied",
|
||||||
|
z.object({
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
requestID: QuestionID.zod,
|
||||||
|
answers: z.array(Answer),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Rejected: BusEvent.define(
|
||||||
|
"question.rejected",
|
||||||
|
z.object({
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
requestID: QuestionID.zod,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RejectedError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("The user dismissed this question")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Effect service ---
|
||||||
|
|
||||||
|
export class QuestionServiceError extends Schema.TaggedErrorClass<QuestionServiceError>()("QuestionServiceError", {
|
||||||
|
message: Schema.String,
|
||||||
|
cause: Schema.optional(Schema.Defect),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
interface PendingEntry {
|
||||||
|
info: Request
|
||||||
|
deferred: Deferred.Deferred<Answer[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace QuestionService {
|
||||||
|
export interface Service {
|
||||||
|
readonly ask: (input: {
|
||||||
|
sessionID: SessionID
|
||||||
|
questions: Info[]
|
||||||
|
tool?: { messageID: MessageID; callID: string }
|
||||||
|
}) => Effect.Effect<Answer[], QuestionServiceError>
|
||||||
|
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void, QuestionServiceError>
|
||||||
|
readonly reject: (requestID: QuestionID) => Effect.Effect<void, QuestionServiceError>
|
||||||
|
readonly list: () => Effect.Effect<Request[], QuestionServiceError>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QuestionService extends ServiceMap.Service<QuestionService, QuestionService.Service>()(
|
||||||
|
"@opencode/Question",
|
||||||
|
) {
|
||||||
|
static readonly layer = Layer.effect(
|
||||||
|
QuestionService,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const instanceState = yield* InstanceState.make<Map<QuestionID, PendingEntry>, QuestionServiceError>(() =>
|
||||||
|
Effect.succeed(new Map<QuestionID, PendingEntry>()),
|
||||||
|
)
|
||||||
|
|
||||||
|
const getPending = InstanceState.get(instanceState)
|
||||||
|
|
||||||
|
const ask = Effect.fn("QuestionService.ask")(function* (input: {
|
||||||
|
sessionID: SessionID
|
||||||
|
questions: Info[]
|
||||||
|
tool?: { messageID: MessageID; callID: string }
|
||||||
|
}) {
|
||||||
|
const pending = yield* getPending
|
||||||
|
const id = QuestionID.ascending()
|
||||||
|
log.info("asking", { id, questions: input.questions.length })
|
||||||
|
|
||||||
|
const deferred = yield* Deferred.make<Answer[]>()
|
||||||
|
const info: Request = {
|
||||||
|
id,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
questions: input.questions,
|
||||||
|
tool: input.tool,
|
||||||
|
}
|
||||||
|
pending.set(id, { info, deferred })
|
||||||
|
Bus.publish(Event.Asked, info)
|
||||||
|
|
||||||
|
return yield* Deferred.await(deferred)
|
||||||
|
})
|
||||||
|
|
||||||
|
const reply = Effect.fn("QuestionService.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
|
||||||
|
const pending = yield* getPending
|
||||||
|
const existing = pending.get(input.requestID)
|
||||||
|
if (!existing) {
|
||||||
|
log.warn("reply for unknown request", { requestID: input.requestID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pending.delete(input.requestID)
|
||||||
|
log.info("replied", { requestID: input.requestID, answers: input.answers })
|
||||||
|
Bus.publish(Event.Replied, {
|
||||||
|
sessionID: existing.info.sessionID,
|
||||||
|
requestID: existing.info.id,
|
||||||
|
answers: input.answers,
|
||||||
|
})
|
||||||
|
yield* Deferred.succeed(existing.deferred, input.answers)
|
||||||
|
})
|
||||||
|
|
||||||
|
const reject = Effect.fn("QuestionService.reject")(function* (requestID: QuestionID) {
|
||||||
|
const pending = yield* getPending
|
||||||
|
const existing = pending.get(requestID)
|
||||||
|
if (!existing) {
|
||||||
|
log.warn("reject for unknown request", { requestID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pending.delete(requestID)
|
||||||
|
log.info("rejected", { requestID })
|
||||||
|
Bus.publish(Event.Rejected, {
|
||||||
|
sessionID: existing.info.sessionID,
|
||||||
|
requestID: existing.info.id,
|
||||||
|
})
|
||||||
|
yield* Deferred.die(existing.deferred, new RejectedError())
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Effect.fn("QuestionService.list")(function* () {
|
||||||
|
const pending = yield* getPending
|
||||||
|
return Array.from(pending.values(), (x) => x.info)
|
||||||
|
})
|
||||||
|
|
||||||
|
return QuestionService.of({ ask, reply, reject, list })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,34 +2,39 @@ import { Effect, ScopedCache, Scope } from "effect"
|
|||||||
|
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
|
|
||||||
const TypeId = Symbol.for("@opencode/InstanceState")
|
type Disposer = (directory: string) => Effect.Effect<void>
|
||||||
|
const disposers = new Set<Disposer>()
|
||||||
|
|
||||||
type Task = (key: string) => Effect.Effect<void>
|
const TypeId = "~opencode/InstanceState"
|
||||||
|
|
||||||
const tasks = new Set<Task>()
|
/**
|
||||||
|
* Effect version of `Instance.state` — lazily-initialized, per-directory
|
||||||
|
* cached state for Effect services.
|
||||||
|
*
|
||||||
|
* Values are created on first access for a given directory and cached for
|
||||||
|
* subsequent reads. Concurrent access shares a single initialization —
|
||||||
|
* no duplicate work or races. Use `Effect.acquireRelease` in `init` if
|
||||||
|
* the value needs cleanup on disposal.
|
||||||
|
*/
|
||||||
|
export interface InstanceState<A, E = never, R = never> {
|
||||||
|
readonly [TypeId]: typeof TypeId
|
||||||
|
readonly cache: ScopedCache.ScopedCache<string, A, E, R>
|
||||||
|
}
|
||||||
|
|
||||||
export namespace InstanceState {
|
export namespace InstanceState {
|
||||||
export interface State<A, E = never, R = never> {
|
/** Create a new InstanceState with the given initializer. */
|
||||||
readonly [TypeId]: typeof TypeId
|
export const make = <A, E = never, R = never>(
|
||||||
readonly cache: ScopedCache.ScopedCache<string, A, E, R>
|
init: (directory: string) => Effect.Effect<A, E, R | Scope.Scope>,
|
||||||
}
|
): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
|
||||||
|
|
||||||
export const make = <A, E = never, R = never>(input: {
|
|
||||||
lookup: (key: string) => Effect.Effect<A, E, R>
|
|
||||||
release?: (value: A, key: string) => Effect.Effect<void>
|
|
||||||
}): Effect.Effect<State<A, E, R>, never, R | Scope.Scope> =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const cache = yield* ScopedCache.make<string, A, E, R>({
|
const cache = yield* ScopedCache.make<string, A, E, R>({
|
||||||
capacity: Number.POSITIVE_INFINITY,
|
capacity: Number.POSITIVE_INFINITY,
|
||||||
lookup: (key) =>
|
lookup: init,
|
||||||
Effect.acquireRelease(input.lookup(key), (value) =>
|
|
||||||
input.release ? input.release(value, key) : Effect.void,
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const task: Task = (key) => ScopedCache.invalidate(cache, key)
|
const disposer: Disposer = (directory) => ScopedCache.invalidate(cache, directory)
|
||||||
tasks.add(task)
|
disposers.add(disposer)
|
||||||
yield* Effect.addFinalizer(() => Effect.sync(() => void tasks.delete(task)))
|
yield* Effect.addFinalizer(() => Effect.sync(() => void disposers.delete(disposer)))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
[TypeId]: TypeId,
|
[TypeId]: TypeId,
|
||||||
@@ -37,15 +42,20 @@ export namespace InstanceState {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const get = <A, E, R>(self: State<A, E, R>) => ScopedCache.get(self.cache, Instance.directory)
|
/** Get the cached value for the current directory, initializing it if needed. */
|
||||||
|
export const get = <A, E, R>(self: InstanceState<A, E, R>) => ScopedCache.get(self.cache, Instance.directory)
|
||||||
|
|
||||||
export const has = <A, E, R>(self: State<A, E, R>) => ScopedCache.has(self.cache, Instance.directory)
|
/** Check whether a value exists for the current directory. */
|
||||||
|
export const has = <A, E, R>(self: InstanceState<A, E, R>) => ScopedCache.has(self.cache, Instance.directory)
|
||||||
|
|
||||||
export const invalidate = <A, E, R>(self: State<A, E, R>) => ScopedCache.invalidate(self.cache, Instance.directory)
|
/** Invalidate the cached value for the current directory. */
|
||||||
|
export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
|
||||||
|
ScopedCache.invalidate(self.cache, Instance.directory)
|
||||||
|
|
||||||
export const dispose = (key: string) =>
|
/** Invalidate the given directory across all InstanceState caches. */
|
||||||
|
export const dispose = (directory: string) =>
|
||||||
Effect.all(
|
Effect.all(
|
||||||
[...tasks].map((task) => task(key)),
|
[...disposers].map((disposer) => disposer(directory)),
|
||||||
{ concurrency: "unbounded" },
|
{ concurrency: "unbounded" },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,3 +15,40 @@ export const withStatics =
|
|||||||
<S extends object, M extends Record<string, unknown>>(methods: (schema: S) => M) =>
|
<S extends object, M extends Record<string, unknown>>(methods: (schema: S) => M) =>
|
||||||
(schema: S): S & M =>
|
(schema: S): S & M =>
|
||||||
Object.assign(schema, methods(schema))
|
Object.assign(schema, methods(schema))
|
||||||
|
|
||||||
|
declare const NewtypeBrand: unique symbol
|
||||||
|
type NewtypeBrand<Tag extends string> = { readonly [NewtypeBrand]: Tag }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nominal wrapper for scalar types. The class itself is a valid schema —
|
||||||
|
* pass it directly to `Schema.decode`, `Schema.decodeEffect`, etc.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String) {
|
||||||
|
* static make(id: string): QuestionID {
|
||||||
|
* return this.makeUnsafe(id)
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Schema.decodeEffect(QuestionID)(input)
|
||||||
|
*/
|
||||||
|
export function Newtype<Self>() {
|
||||||
|
return <const Tag extends string, S extends Schema.Top>(tag: Tag, schema: S) => {
|
||||||
|
type Branded = NewtypeBrand<Tag>
|
||||||
|
|
||||||
|
abstract class Base {
|
||||||
|
declare readonly [NewtypeBrand]: Tag
|
||||||
|
|
||||||
|
static makeUnsafe(value: Schema.Schema.Type<S>): Self {
|
||||||
|
return value as unknown as Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.setPrototypeOf(Base, schema)
|
||||||
|
|
||||||
|
return Base as unknown as
|
||||||
|
& (abstract new (_: never) => Branded)
|
||||||
|
& { readonly makeUnsafe: (value: Schema.Schema.Type<S>) => Self }
|
||||||
|
& Omit<Schema.Opaque<Self, S, {}>, "makeUnsafe">
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ import { QuestionID } from "../../src/question/schema"
|
|||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { SessionID } from "../../src/session/schema"
|
import { SessionID } from "../../src/session/schema"
|
||||||
|
|
||||||
|
/** Reject all pending questions so dangling Deferred fibers don't hang the test. */
|
||||||
|
async function rejectAll() {
|
||||||
|
const pending = await Question.list()
|
||||||
|
for (const req of pending) {
|
||||||
|
await Question.reject(req.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test("ask - returns pending promise", async () => {
|
test("ask - returns pending promise", async () => {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
@@ -24,6 +32,8 @@ test("ask - returns pending promise", async () => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
expect(promise).toBeInstanceOf(Promise)
|
expect(promise).toBeInstanceOf(Promise)
|
||||||
|
await rejectAll()
|
||||||
|
await promise.catch(() => {})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -44,7 +54,7 @@ test("ask - adds to pending list", async () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
Question.ask({
|
const askPromise = Question.ask({
|
||||||
sessionID: SessionID.make("ses_test"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
questions,
|
questions,
|
||||||
})
|
})
|
||||||
@@ -52,6 +62,8 @@ test("ask - adds to pending list", async () => {
|
|||||||
const pending = await Question.list()
|
const pending = await Question.list()
|
||||||
expect(pending.length).toBe(1)
|
expect(pending.length).toBe(1)
|
||||||
expect(pending[0].questions).toEqual(questions)
|
expect(pending[0].questions).toEqual(questions)
|
||||||
|
await rejectAll()
|
||||||
|
await askPromise.catch(() => {})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -98,7 +110,7 @@ test("reply - removes from pending list", async () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
Question.ask({
|
const askPromise = Question.ask({
|
||||||
sessionID: SessionID.make("ses_test"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -119,6 +131,7 @@ test("reply - removes from pending list", async () => {
|
|||||||
requestID: pending[0].id,
|
requestID: pending[0].id,
|
||||||
answers: [["Option 1"]],
|
answers: [["Option 1"]],
|
||||||
})
|
})
|
||||||
|
await askPromise
|
||||||
|
|
||||||
const pendingAfter = await Question.list()
|
const pendingAfter = await Question.list()
|
||||||
expect(pendingAfter.length).toBe(0)
|
expect(pendingAfter.length).toBe(0)
|
||||||
@@ -262,7 +275,7 @@ test("list - returns all pending requests", async () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
Question.ask({
|
const p1 = Question.ask({
|
||||||
sessionID: SessionID.make("ses_test1"),
|
sessionID: SessionID.make("ses_test1"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -273,7 +286,7 @@ test("list - returns all pending requests", async () => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
Question.ask({
|
const p2 = Question.ask({
|
||||||
sessionID: SessionID.make("ses_test2"),
|
sessionID: SessionID.make("ses_test2"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -286,6 +299,9 @@ test("list - returns all pending requests", async () => {
|
|||||||
|
|
||||||
const pending = await Question.list()
|
const pending = await Question.list()
|
||||||
expect(pending.length).toBe(2)
|
expect(pending.length).toBe(2)
|
||||||
|
await rejectAll()
|
||||||
|
p1.catch(() => {})
|
||||||
|
p2.catch(() => {})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Instance } from "../../src/project/instance"
|
|||||||
import { InstanceState } from "../../src/util/instance-state"
|
import { InstanceState } from "../../src/util/instance-state"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
async function access<A, E>(state: InstanceState.State<A, E>, dir: string) {
|
async function access<A, E>(state: InstanceState<A, E>, dir: string) {
|
||||||
return Instance.provide({
|
return Instance.provide({
|
||||||
directory: dir,
|
directory: dir,
|
||||||
fn: () => Effect.runPromise(InstanceState.get(state)),
|
fn: () => Effect.runPromise(InstanceState.get(state)),
|
||||||
@@ -23,9 +23,7 @@ test("InstanceState caches values for the same instance", async () => {
|
|||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
Effect.scoped(
|
Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const state = yield* InstanceState.make({
|
const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n })))
|
||||||
lookup: () => Effect.sync(() => ({ n: ++n })),
|
|
||||||
})
|
|
||||||
|
|
||||||
const a = yield* Effect.promise(() => access(state, tmp.path))
|
const a = yield* Effect.promise(() => access(state, tmp.path))
|
||||||
const b = yield* Effect.promise(() => access(state, tmp.path))
|
const b = yield* Effect.promise(() => access(state, tmp.path))
|
||||||
@@ -45,9 +43,7 @@ test("InstanceState isolates values by directory", async () => {
|
|||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
Effect.scoped(
|
Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const state = yield* InstanceState.make({
|
const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n })))
|
||||||
lookup: (dir) => Effect.sync(() => ({ dir, n: ++n })),
|
|
||||||
})
|
|
||||||
|
|
||||||
const x = yield* Effect.promise(() => access(state, a.path))
|
const x = yield* Effect.promise(() => access(state, a.path))
|
||||||
const y = yield* Effect.promise(() => access(state, b.path))
|
const y = yield* Effect.promise(() => access(state, b.path))
|
||||||
@@ -69,13 +65,12 @@ test("InstanceState is disposed on instance reload", async () => {
|
|||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
Effect.scoped(
|
Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const state = yield* InstanceState.make({
|
const state = yield* InstanceState.make(() =>
|
||||||
lookup: () => Effect.sync(() => ({ n: ++n })),
|
Effect.acquireRelease(
|
||||||
release: (value) =>
|
Effect.sync(() => ({ n: ++n })),
|
||||||
Effect.sync(() => {
|
(value) => Effect.sync(() => { seen.push(String(value.n)) }),
|
||||||
seen.push(String(value.n))
|
),
|
||||||
}),
|
)
|
||||||
})
|
|
||||||
|
|
||||||
const a = yield* Effect.promise(() => access(state, tmp.path))
|
const a = yield* Effect.promise(() => access(state, tmp.path))
|
||||||
yield* Effect.promise(() => Instance.reload({ directory: tmp.path }))
|
yield* Effect.promise(() => Instance.reload({ directory: tmp.path }))
|
||||||
@@ -96,13 +91,12 @@ test("InstanceState is disposed on disposeAll", async () => {
|
|||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
Effect.scoped(
|
Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const state = yield* InstanceState.make({
|
const state = yield* InstanceState.make((dir) =>
|
||||||
lookup: (dir) => Effect.sync(() => ({ dir })),
|
Effect.acquireRelease(
|
||||||
release: (value) =>
|
Effect.sync(() => ({ dir })),
|
||||||
Effect.sync(() => {
|
(value) => Effect.sync(() => { seen.push(value.dir) }),
|
||||||
seen.push(value.dir)
|
),
|
||||||
}),
|
)
|
||||||
})
|
|
||||||
|
|
||||||
yield* Effect.promise(() => access(state, a.path))
|
yield* Effect.promise(() => access(state, a.path))
|
||||||
yield* Effect.promise(() => access(state, b.path))
|
yield* Effect.promise(() => access(state, b.path))
|
||||||
@@ -121,14 +115,13 @@ test("InstanceState dedupes concurrent lookups for the same directory", async ()
|
|||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
Effect.scoped(
|
Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const state = yield* InstanceState.make({
|
const state = yield* InstanceState.make(() =>
|
||||||
lookup: () =>
|
Effect.promise(async () => {
|
||||||
Effect.promise(async () => {
|
n += 1
|
||||||
n += 1
|
await Bun.sleep(10)
|
||||||
await Bun.sleep(10)
|
return { n }
|
||||||
return { n }
|
}),
|
||||||
}),
|
)
|
||||||
})
|
|
||||||
|
|
||||||
const [a, b] = yield* Effect.promise(() => Promise.all([access(state, tmp.path), access(state, tmp.path)]))
|
const [a, b] = yield* Effect.promise(() => Promise.all([access(state, tmp.path), access(state, tmp.path)]))
|
||||||
expect(a).toBe(b)
|
expect(a).toBe(b)
|
||||||
|
|||||||
Reference in New Issue
Block a user