refactor(instance): move scoped services to LayerMap (#17544)

This commit is contained in:
Kit Langton
2026-03-16 12:55:14 -04:00
committed by GitHub
parent 4cb29967f6
commit 469c3a4204
15 changed files with 154 additions and 497 deletions

View File

@@ -1,18 +1,9 @@
import { runtime } from "@/effect/runtime"
import { runPromiseInstance } from "@/effect/runtime"
import { Config } from "@/config/config"
import { fn } from "@/util/fn"
import { Wildcard } from "@/util/wildcard"
import { Effect } from "effect"
import os from "os"
import * as S from "./service"
import type {
Action as ActionType,
PermissionError,
Reply as ReplyType,
Request as RequestType,
Rule as RuleType,
Ruleset as RulesetType,
} from "./service"
export namespace PermissionNext {
function expand(pattern: string): string {
@@ -23,20 +14,16 @@ export namespace PermissionNext {
return pattern
}
function runPromise<A>(f: (service: S.PermissionService.Api) => Effect.Effect<A, PermissionError>) {
return runtime.runPromise(S.PermissionService.use(f))
}
export const Action = S.Action
export type Action = ActionType
export type Action = S.Action
export const Rule = S.Rule
export type Rule = RuleType
export type Rule = S.Rule
export const Ruleset = S.Ruleset
export type Ruleset = RulesetType
export type Ruleset = S.Ruleset
export const Request = S.Request
export type Request = RequestType
export type Request = S.Request
export const Reply = S.Reply
export type Reply = ReplyType
export type Reply = S.Reply
export const Approval = S.Approval
export const Event = S.Event
export const Service = S.PermissionService
@@ -66,12 +53,16 @@ export namespace PermissionNext {
return rulesets.flat()
}
export const ask = fn(S.AskInput, async (input) => runPromise((service) => service.ask(input)))
export const ask = fn(S.AskInput, async (input) =>
runPromiseInstance(S.PermissionService.use((service) => service.ask(input))),
)
export const reply = fn(S.ReplyInput, async (input) => runPromise((service) => service.reply(input)))
export const reply = fn(S.ReplyInput, async (input) =>
runPromiseInstance(S.PermissionService.use((service) => service.reply(input))),
)
export async function list() {
return runPromise((service) => service.list())
return runPromiseInstance(S.PermissionService.use((service) => service.list()))
}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {

View File

@@ -1,11 +1,10 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Instance } from "@/project/instance"
import { InstanceContext } from "@/effect/instances"
import { ProjectID } from "@/project/schema"
import { MessageID, SessionID } from "@/session/schema"
import { PermissionTable } from "@/session/session.sql"
import { Database, eq } from "@/storage/db"
import { InstanceState } from "@/util/instance-state"
import { Log } from "@/util/log"
import { Wildcard } from "@/util/wildcard"
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
@@ -104,11 +103,6 @@ interface PendingEntry {
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
}
type State = {
pending: Map<PermissionID, PendingEntry>
approved: Ruleset
}
export const AskInput = Request.partial({ id: true }).extend({
ruleset: Ruleset,
})
@@ -133,25 +127,19 @@ export class PermissionService extends ServiceMap.Service<PermissionService, Per
static readonly layer = Layer.effect(
PermissionService,
Effect.gen(function* () {
const instanceState = yield* InstanceState.make<State>(() =>
Effect.sync(() => {
const row = Database.use((db) =>
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, Instance.project.id)).get(),
)
return {
pending: new Map<PermissionID, PendingEntry>(),
approved: row?.data ?? [],
}
}),
const { project } = yield* InstanceContext
const row = Database.use((db) =>
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(),
)
const pending = new Map<PermissionID, PendingEntry>()
const approved: Ruleset = row?.data ?? []
const ask = Effect.fn("PermissionService.ask")(function* (input: z.infer<typeof AskInput>) {
const state = yield* InstanceState.get(instanceState)
const { ruleset, ...request } = input
let pending = false
let needsAsk = false
for (const pattern of request.patterns) {
const rule = evaluate(request.permission, pattern, ruleset, state.approved)
const rule = evaluate(request.permission, pattern, ruleset, approved)
log.info("evaluated", { permission: request.permission, pattern, action: rule })
if (rule.action === "deny") {
return yield* new DeniedError({
@@ -159,10 +147,10 @@ export class PermissionService extends ServiceMap.Service<PermissionService, Per
})
}
if (rule.action === "allow") continue
pending = true
needsAsk = true
}
if (!pending) return
if (!needsAsk) return
const id = request.id ?? PermissionID.ascending()
const info: Request = {
@@ -172,22 +160,21 @@ export class PermissionService extends ServiceMap.Service<PermissionService, Per
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
state.pending.set(id, { info, deferred })
pending.set(id, { info, deferred })
void Bus.publish(Event.Asked, info)
return yield* Effect.ensuring(
Deferred.await(deferred),
Effect.sync(() => {
state.pending.delete(id)
pending.delete(id)
}),
)
})
const reply = Effect.fn("PermissionService.reply")(function* (input: z.infer<typeof ReplyInput>) {
const state = yield* InstanceState.get(instanceState)
const existing = state.pending.get(input.requestID)
const existing = pending.get(input.requestID)
if (!existing) return
state.pending.delete(input.requestID)
pending.delete(input.requestID)
void Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
@@ -200,9 +187,9 @@ export class PermissionService extends ServiceMap.Service<PermissionService, Per
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
)
for (const [id, item] of state.pending.entries()) {
for (const [id, item] of pending.entries()) {
if (item.info.sessionID !== existing.info.sessionID) continue
state.pending.delete(id)
pending.delete(id)
void Bus.publish(Event.Replied, {
sessionID: item.info.sessionID,
requestID: item.info.id,
@@ -217,20 +204,20 @@ export class PermissionService extends ServiceMap.Service<PermissionService, Per
if (input.reply === "once") return
for (const pattern of existing.info.always) {
state.approved.push({
approved.push({
permission: existing.info.permission,
pattern,
action: "allow",
})
}
for (const [id, item] of state.pending.entries()) {
for (const [id, item] of pending.entries()) {
if (item.info.sessionID !== existing.info.sessionID) continue
const ok = item.info.patterns.every(
(pattern) => evaluate(item.info.permission, pattern, state.approved).action === "allow",
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
)
if (!ok) continue
state.pending.delete(id)
pending.delete(id)
void Bus.publish(Event.Replied, {
sessionID: item.info.sessionID,
requestID: item.info.id,
@@ -246,8 +233,7 @@ export class PermissionService extends ServiceMap.Service<PermissionService, Per
})
const list = Effect.fn("PermissionService.list")(function* () {
const state = yield* InstanceState.get(instanceState)
return Array.from(state.pending.values(), (item) => item.info)
return Array.from(pending.values(), (item) => item.info)
})
return PermissionService.of({ ask, reply, list })