feat(http-recorder): prepare public beta release (#31018)

This commit is contained in:
Kit Langton
2026-06-06 03:01:26 +00:00
committed by GitHub
parent ba57718b05
commit 54f4974546
36 changed files with 2277 additions and 713 deletions
+57 -40
View File
@@ -1,8 +1,8 @@
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
import { Context, Effect, FileSystem, Layer, Schema, Semaphore } from "effect"
import * as fs from "node:fs"
import * as path from "node:path"
import { secretFindings, SecretFindingSchema, type SecretFinding } from "./redaction"
import { decodeCassette, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
import { secretFindings, SecretFindingSchema, type SecretFinding } from "./redaction.js"
import { CassetteSchema, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema.js"
const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
@@ -38,8 +38,19 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}
const cassettePath = (directory: string, name: string) => {
if (!name || path.isAbsolute(name) || path.win32.isAbsolute(name) || name.split(/[\\/]/).includes(".."))
throw new Error(`Invalid cassette name "${name}"`)
const root = path.resolve(directory)
const target = path.resolve(root, `${name}.json`)
const relative = path.relative(root, target)
if (!relative || relative.startsWith("..") || path.isAbsolute(relative))
throw new Error(`Invalid cassette name "${name}"`)
return target
}
export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) =>
fs.existsSync(path.join(options.directory ?? DEFAULT_RECORDINGS_DIR, `${name}.json`))
fs.existsSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name))
const buildCassette = (
name: string,
@@ -47,13 +58,13 @@ const buildCassette = (
metadata: CassetteMetadata | undefined,
): Cassette => ({
version: 1,
metadata: { name, recordedAt: new Date().toISOString(), ...(metadata ?? {}) },
metadata: { name, recordedAt: new Date().toISOString(), ...metadata },
interactions,
})
const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw))
const parseCassette = Schema.decodeUnknownSync(Schema.fromJsonString(CassetteSchema))
const failIfUnsafe = (name: string, findings: ReadonlyArray<SecretFinding>) =>
findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings }))
@@ -67,17 +78,9 @@ export const fileSystem = (
const fs = yield* FileSystem.FileSystem
const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>()
const directoriesEnsured = new Set<string>()
const appendLock = yield* Semaphore.make(1)
const cassettePath = (name: string) => path.join(directory, `${name}.json`)
const ensureDirectory = (name: string) =>
Effect.gen(function* () {
const dir = path.dirname(cassettePath(name))
if (directoriesEnsured.has(dir)) return
yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
directoriesEnsured.add(dir)
})
const pathFor = (name: string) => cassettePath(directory, name)
const walk = (current: string): Effect.Effect<ReadonlyArray<string>> =>
Effect.gen(function* () {
@@ -94,24 +97,32 @@ export const fileSystem = (
return Service.of({
read: (name) =>
fs.readFileString(cassettePath(name)).pipe(
fs.readFileString(pathFor(name)).pipe(
Effect.map((raw) => parseCassette(raw).interactions),
Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))),
),
append: (name, interaction, metadata) =>
Effect.gen(function* () {
const entry = recorded.get(name) ?? { interactions: [], findings: [] }
if (!recorded.has(name)) recorded.set(name, entry)
entry.interactions.push(interaction)
entry.findings.push(...secretFindings(interaction))
const cassette = buildCassette(name, entry.interactions, metadata)
const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
yield* failIfUnsafe(name, findings)
yield* ensureDirectory(name)
yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie)
}),
appendLock.withPermit(
Effect.gen(function* () {
const entry = recorded.get(name) ?? { interactions: [], findings: [] }
const interactions = [...entry.interactions, interaction]
const interactionFindings = [...entry.findings, ...secretFindings(interaction)]
const cassette = buildCassette(name, interactions, metadata)
const findings = [...interactionFindings, ...secretFindings(cassette.metadata ?? {})]
yield* failIfUnsafe(name, findings)
const target = pathFor(name)
yield* fs.makeDirectory(path.dirname(target), { recursive: true }).pipe(Effect.orDie)
const temporary = `${target}.${crypto.randomUUID()}.tmp`
yield* fs.writeFileString(temporary, formatCassette(cassette)).pipe(
Effect.flatMap(() => fs.rename(temporary, target)),
Effect.ensuring(fs.remove(temporary, { force: true }).pipe(Effect.catch(() => Effect.void))),
Effect.orDie,
)
recorded.set(name, { interactions, findings: interactionFindings })
}),
),
exists: (name) =>
fs.access(cassettePath(name)).pipe(
fs.access(pathFor(name)).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
),
@@ -139,23 +150,29 @@ export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {})
Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]),
)
const accumulatedFindings = new Map<string, SecretFinding[]>()
const appendLock = Semaphore.makeUnsafe(1)
return Service.of({
read: (name) =>
stored.has(name)
? Effect.succeed(stored.get(name) ?? [])
: Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
append: (name, interaction, metadata) => {
const existing = stored.get(name)
if (existing) existing.push(interaction)
else stored.set(name, [interaction])
const existingFindings = accumulatedFindings.get(name)
const findings = existingFindings ?? []
if (!existingFindings) accumulatedFindings.set(name, findings)
findings.push(...secretFindings(interaction))
if (metadata) findings.push(...secretFindings({ name, ...metadata }))
return failIfUnsafe(name, findings)
},
append: (name, interaction, metadata) =>
appendLock.withPermit(
Effect.suspend(() => {
const interactions = [...(stored.get(name) ?? []), interaction]
const findings = [...(accumulatedFindings.get(name) ?? []), ...secretFindings(interaction)]
const allFindings = metadata ? [...findings, ...secretFindings({ name, ...metadata })] : findings
return failIfUnsafe(name, allFindings).pipe(
Effect.tap(() =>
Effect.sync(() => {
stored.set(name, interactions)
accumulatedFindings.set(name, findings)
}),
),
)
}),
),
exists: (name) => Effect.sync(() => stored.has(name)),
list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
})