diff options
| author | Dax <[email protected]> | 2026-04-25 10:59:17 -0400 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-04-25 10:59:17 -0400 |
| commit | 62ef2a220723a6d6cb050e523fcdfaa974dafdda (patch) | |
| tree | 214b03d016e18e4d8fe1bfc7209c1edd86547bbd /packages/shared | |
| parent | 37aa8442dc023fad250f2573c8235a544789900c (diff) | |
| download | opencode-62ef2a220723a6d6cb050e523fcdfaa974dafdda.tar.gz opencode-62ef2a220723a6d6cb050e523fcdfaa974dafdda.zip | |
refactor: rename shared package to core (#24309)
Diffstat (limited to 'packages/shared')
28 files changed, 0 insertions, 2808 deletions
diff --git a/packages/shared/package.json b/packages/shared/package.json deleted file mode 100644 index beb0d50ed..000000000 --- a/packages/shared/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.25", - "name": "@opencode-ai/shared", - "type": "module", - "license": "MIT", - "private": true, - "scripts": { - "test": "bun test", - "typecheck": "tsgo --noEmit" - }, - "bin": { - "opencode": "./bin/opencode" - }, - "exports": { - "./*": "./src/*.ts" - }, - "imports": {}, - "devDependencies": { - "@tsconfig/bun": "catalog:", - "@types/semver": "catalog:", - "@types/bun": "catalog:", - "@types/npmcli__arborist": "6.3.3" - }, - "dependencies": { - "@effect/platform-node": "catalog:", - "@npmcli/arborist": "catalog:", - "effect": "catalog:", - "glob": "13.0.5", - "mime-types": "3.0.2", - "minimatch": "10.2.5", - "semver": "catalog:", - "xdg-basedir": "5.1.0", - "zod": "catalog:" - }, - "overrides": { - "drizzle-orm": "catalog:" - } -} diff --git a/packages/shared/src/filesystem.ts b/packages/shared/src/filesystem.ts deleted file mode 100644 index 44346be8f..000000000 --- a/packages/shared/src/filesystem.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { NodeFileSystem } from "@effect/platform-node" -import { dirname, join, relative, resolve as pathResolve } from "path" -import { realpathSync } from "fs" -import * as NFS from "fs/promises" -import { lookup } from "mime-types" -import { Effect, FileSystem, Layer, Schema, Context } from "effect" -import type { PlatformError } from "effect/PlatformError" -import { Glob } from "./util/glob" - -export namespace AppFileSystem { - export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", { - method: Schema.String, - cause: Schema.optional(Schema.Defect), - }) {} - - export type Error = PlatformError | FileSystemError - - export interface DirEntry { - readonly name: string - readonly type: "file" | "directory" | "symlink" | "other" - } - - export interface Interface extends FileSystem.FileSystem { - readonly isDir: (path: string) => Effect.Effect<boolean> - readonly isFile: (path: string) => Effect.Effect<boolean> - readonly existsSafe: (path: string) => Effect.Effect<boolean> - readonly readJson: (path: string) => Effect.Effect<unknown, Error> - readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error> - readonly ensureDir: (path: string) => Effect.Effect<void, Error> - readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error> - readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error> - readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error> - readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error> - readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error> - readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error> - readonly globMatch: (pattern: string, filepath: string) => boolean - } - - export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - - const existsSafe = Effect.fn("FileSystem.existsSafe")(function* (path: string) { - return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)) - }) - - const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) { - const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void)) - return info?.type === "Directory" - }) - - const isFile = Effect.fn("FileSystem.isFile")(function* (path: string) { - const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void)) - return info?.type === "File" - }) - - const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) { - return yield* Effect.tryPromise({ - try: async () => { - const entries = await NFS.readdir(dirPath, { withFileTypes: true }) - return entries.map( - (e): DirEntry => ({ - name: e.name, - type: e.isDirectory() ? "directory" : e.isSymbolicLink() ? "symlink" : e.isFile() ? "file" : "other", - }), - ) - }, - catch: (cause) => new FileSystemError({ method: "readDirectoryEntries", cause }), - }) - }) - - const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) { - const text = yield* fs.readFileString(path) - return JSON.parse(text) - }) - - const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) { - const content = JSON.stringify(data, null, 2) - yield* fs.writeFileString(path, content) - if (mode) yield* fs.chmod(path, mode) - }) - - const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) { - yield* fs.makeDirectory(path, { recursive: true }) - }) - - const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* ( - path: string, - content: string | Uint8Array, - mode?: number, - ) { - const write = typeof content === "string" ? fs.writeFileString(path, content) : fs.writeFile(path, content) - - yield* write.pipe( - Effect.catchIf( - (e) => e.reason._tag === "NotFound", - () => - Effect.gen(function* () { - yield* fs.makeDirectory(dirname(path), { recursive: true }) - yield* write - }), - ), - ) - if (mode) yield* fs.chmod(path, mode) - }) - - const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) { - return yield* Effect.tryPromise({ - try: () => Glob.scan(pattern, options), - catch: (cause) => new FileSystemError({ method: "glob", cause }), - }) - }) - - const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) { - const result: string[] = [] - let current = start - while (true) { - const search = join(current, target) - if (yield* fs.exists(search)) result.push(search) - if (stop === current) break - const parent = dirname(current) - if (parent === current) break - current = parent - } - return result - }) - - const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) { - const result: string[] = [] - let current = options.start - while (true) { - for (const target of options.targets) { - const search = join(current, target) - if (yield* fs.exists(search)) result.push(search) - } - if (options.stop === current) break - const parent = dirname(current) - if (parent === current) break - current = parent - } - return result - }) - - const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) { - const result: string[] = [] - let current = start - while (true) { - const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe( - Effect.catch(() => Effect.succeed([] as string[])), - ) - result.push(...matches) - if (stop === current) break - const parent = dirname(current) - if (parent === current) break - current = parent - } - return result - }) - - return Service.of({ - ...fs, - existsSafe, - isDir, - isFile, - readDirectoryEntries, - readJson, - writeJson, - ensureDir, - writeWithDirs, - findUp, - up, - globUp, - glob, - globMatch: Glob.match, - }) - }), - ) - - export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer)) - - // Pure helpers that don't need Effect (path manipulation, sync operations) - export function mimeType(p: string): string { - return lookup(p) || "application/octet-stream" - } - - export function normalizePath(p: string): string { - if (process.platform !== "win32") return p - const resolved = pathResolve(windowsPath(p)) - try { - return realpathSync.native(resolved) - } catch { - return resolved - } - } - - export function normalizePathPattern(p: string): string { - if (process.platform !== "win32") return p - if (p === "*") return p - const match = p.match(/^(.*)[\\/]\*$/) - if (!match) return normalizePath(p) - const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1] - return join(normalizePath(dir), "*") - } - - export function resolve(p: string): string { - const resolved = pathResolve(windowsPath(p)) - try { - return normalizePath(realpathSync(resolved)) - } catch (e: any) { - if (e?.code === "ENOENT") return normalizePath(resolved) - throw e - } - } - - export function windowsPath(p: string): string { - if (process.platform !== "win32") return p - return p - .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`) - .replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) - .replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) - .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) - } - - export function overlaps(a: string, b: string) { - const relA = relative(a, b) - const relB = relative(b, a) - return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..") - } - - export function contains(parent: string, child: string) { - return !relative(parent, child).startsWith("..") - } -} diff --git a/packages/shared/src/global.ts b/packages/shared/src/global.ts deleted file mode 100644 index 538cc091b..000000000 --- a/packages/shared/src/global.ts +++ /dev/null @@ -1,42 +0,0 @@ -import path from "path" -import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir" -import os from "os" -import { Context, Effect, Layer } from "effect" - -export namespace Global { - export class Service extends Context.Service<Service, Interface>()("@opencode/Global") {} - - export interface Interface { - readonly home: string - readonly data: string - readonly cache: string - readonly config: string - readonly state: string - readonly bin: string - readonly log: string - } - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const app = "opencode" - const home = process.env.OPENCODE_TEST_HOME ?? os.homedir() - const data = path.join(xdgData!, app) - const cache = path.join(xdgCache!, app) - const cfg = path.join(xdgConfig!, app) - const state = path.join(xdgState!, app) - const bin = path.join(cache, "bin") - const log = path.join(data, "log") - - return Service.of({ - home, - data, - cache, - config: cfg, - state, - bin, - log, - }) - }), - ) -} diff --git a/packages/shared/src/types.d.ts b/packages/shared/src/types.d.ts deleted file mode 100644 index 60e1639ad..000000000 --- a/packages/shared/src/types.d.ts +++ /dev/null @@ -1,46 +0,0 @@ -declare module "@npmcli/arborist" { - export interface ArboristOptions { - path: string - binLinks?: boolean - progress?: boolean - savePrefix?: string - ignoreScripts?: boolean - [key: string]: unknown - } - - export interface ArboristNode { - name: string - path: string - } - - export interface ArboristEdge { - to?: ArboristNode - } - - export interface ArboristTree { - edgesOut: Map<string, ArboristEdge> - } - - export interface ReifyOptions { - add?: string[] - save?: boolean - saveType?: "prod" | "dev" | "optional" | "peer" - [key: string]: unknown - } - - export class Arborist { - constructor(options: ArboristOptions) - loadVirtual(): Promise<ArboristTree | undefined> - reify(options?: ReifyOptions): Promise<ArboristTree> - } -} - -declare var Bun: - | { - file(path: string): { - text(): Promise<string> - json(): Promise<unknown> - } - write(path: string, content: string | Uint8Array): Promise<void> - } - | undefined diff --git a/packages/shared/src/util/array.ts b/packages/shared/src/util/array.ts deleted file mode 100644 index 1fb8ac69e..000000000 --- a/packages/shared/src/util/array.ts +++ /dev/null @@ -1,10 +0,0 @@ -export function findLast<T>( - items: readonly T[], - predicate: (item: T, index: number, items: readonly T[]) => boolean, -): T | undefined { - for (let i = items.length - 1; i >= 0; i -= 1) { - const item = items[i] - if (predicate(item, i, items)) return item - } - return undefined -} diff --git a/packages/shared/src/util/binary.ts b/packages/shared/src/util/binary.ts deleted file mode 100644 index 3d8f61851..000000000 --- a/packages/shared/src/util/binary.ts +++ /dev/null @@ -1,41 +0,0 @@ -export namespace Binary { - export function search<T>(array: T[], id: string, compare: (item: T) => string): { found: boolean; index: number } { - let left = 0 - let right = array.length - 1 - - while (left <= right) { - const mid = Math.floor((left + right) / 2) - const midId = compare(array[mid]) - - if (midId === id) { - return { found: true, index: mid } - } else if (midId < id) { - left = mid + 1 - } else { - right = mid - 1 - } - } - - return { found: false, index: left } - } - - export function insert<T>(array: T[], item: T, compare: (item: T) => string): T[] { - const id = compare(item) - let left = 0 - let right = array.length - - while (left < right) { - const mid = Math.floor((left + right) / 2) - const midId = compare(array[mid]) - - if (midId < id) { - left = mid + 1 - } else { - right = mid - } - } - - array.splice(left, 0, item) - return array - } -} diff --git a/packages/shared/src/util/effect-flock.ts b/packages/shared/src/util/effect-flock.ts deleted file mode 100644 index 16bcf091b..000000000 --- a/packages/shared/src/util/effect-flock.ts +++ /dev/null @@ -1,283 +0,0 @@ -import path from "path" -import os from "os" -import { randomUUID } from "crypto" -import { Context, Effect, Function, Layer, Option, Schedule, Schema } from "effect" -import type { FileSystem, Scope } from "effect" -import type { PlatformError } from "effect/PlatformError" -import { AppFileSystem } from "../filesystem" -import { Global } from "../global" -import { Hash } from "./hash" - -export namespace EffectFlock { - // --------------------------------------------------------------------------- - // Errors - // --------------------------------------------------------------------------- - - export class LockTimeoutError extends Schema.TaggedErrorClass<LockTimeoutError>()("LockTimeoutError", { - key: Schema.String, - }) {} - - export class LockCompromisedError extends Schema.TaggedErrorClass<LockCompromisedError>()("LockCompromisedError", { - detail: Schema.String, - }) {} - - class ReleaseError extends Schema.TaggedErrorClass<ReleaseError>()("ReleaseError", { - detail: Schema.String, - cause: Schema.optional(Schema.Defect), - }) { - override get message() { - return this.detail - } - } - - /** Internal: signals "lock is held, retry later". Never leaks to callers. */ - class NotAcquired extends Schema.TaggedErrorClass<NotAcquired>()("NotAcquired", {}) {} - - export type LockError = LockTimeoutError | LockCompromisedError - - // --------------------------------------------------------------------------- - // Timing (baked in — no caller ever overrides these) - // --------------------------------------------------------------------------- - - const STALE_MS = 60_000 - const TIMEOUT_MS = 5 * 60_000 - const BASE_DELAY_MS = 100 - const MAX_DELAY_MS = 2_000 - const HEARTBEAT_MS = Math.max(100, Math.floor(STALE_MS / 3)) - - const retrySchedule = Schedule.exponential(BASE_DELAY_MS, 1.7).pipe( - Schedule.either(Schedule.spaced(MAX_DELAY_MS)), - Schedule.jittered, - Schedule.while((meta) => meta.elapsed < TIMEOUT_MS), - ) - - // --------------------------------------------------------------------------- - // Lock metadata schema - // --------------------------------------------------------------------------- - - const LockMetaJson = Schema.fromJsonString( - Schema.Struct({ - token: Schema.String, - pid: Schema.Number, - hostname: Schema.String, - createdAt: Schema.String, - }), - ) - - const decodeMeta = Schema.decodeUnknownSync(LockMetaJson) - const encodeMeta = Schema.encodeSync(LockMetaJson) - - // --------------------------------------------------------------------------- - // Service - // --------------------------------------------------------------------------- - - export interface Interface { - readonly acquire: (key: string, dir?: string) => Effect.Effect<void, LockError, Scope.Scope> - readonly withLock: { - (key: string, dir?: string): <A, E, R>(body: Effect.Effect<A, E, R>) => Effect.Effect<A, E | LockError, R> - <A, E, R>(body: Effect.Effect<A, E, R>, key: string, dir?: string): Effect.Effect<A, E | LockError, R> - } - } - - export class Service extends Context.Service<Service, Interface>()("EffectFlock") {} - - // --------------------------------------------------------------------------- - // Layer - // --------------------------------------------------------------------------- - - function wall() { - return performance.timeOrigin + performance.now() - } - - const mtimeMs = (info: FileSystem.File.Info) => Option.getOrElse(info.mtime, () => new Date(0)).getTime() - - const isPathGone = (e: PlatformError) => e.reason._tag === "NotFound" || e.reason._tag === "Unknown" - - export const layer: Layer.Layer<Service, never, Global.Service | AppFileSystem.Service> = Layer.effect( - Service, - Effect.gen(function* () { - const global = yield* Global.Service - const fs = yield* AppFileSystem.Service - const lockRoot = path.join(global.state, "locks") - const hostname = os.hostname() - const ensuredDirs = new Set<string>() - - // -- helpers (close over fs) -- - - const safeStat = (file: string) => - fs.stat(file).pipe( - Effect.catchIf(isPathGone, () => Effect.void), - Effect.orDie, - ) - - const forceRemove = (target: string) => fs.remove(target, { recursive: true }).pipe(Effect.ignore) - - /** Atomic mkdir — returns true if created, false if already exists, dies on other errors. */ - const atomicMkdir = (dir: string) => - fs.makeDirectory(dir, { mode: 0o700 }).pipe( - Effect.as(true), - Effect.catchIf( - (e) => e.reason._tag === "AlreadyExists", - () => Effect.succeed(false), - ), - Effect.orDie, - ) - - /** Write with exclusive create — compromised error if file already exists. */ - const exclusiveWrite = (filePath: string, content: string, lockDir: string, detail: string) => - fs.writeFileString(filePath, content, { flag: "wx" }).pipe( - Effect.catch(() => - Effect.gen(function* () { - yield* forceRemove(lockDir) - return yield* new LockCompromisedError({ detail }) - }), - ), - ) - - const cleanStaleBreaker = Effect.fnUntraced(function* (breakerPath: string) { - const bs = yield* safeStat(breakerPath) - if (bs && wall() - mtimeMs(bs) > STALE_MS) yield* forceRemove(breakerPath) - return false - }) - - const ensureDir = Effect.fnUntraced(function* (dir: string) { - if (ensuredDirs.has(dir)) return - yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie) - ensuredDirs.add(dir) - }) - - const isStale = Effect.fnUntraced(function* (lockDir: string, heartbeatPath: string, metaPath: string) { - const now = wall() - - const hb = yield* safeStat(heartbeatPath) - if (hb) return now - mtimeMs(hb) > STALE_MS - - const meta = yield* safeStat(metaPath) - if (meta) return now - mtimeMs(meta) > STALE_MS - - const dir = yield* safeStat(lockDir) - if (!dir) return false - - return now - mtimeMs(dir) > STALE_MS - }) - - // -- single lock attempt -- - - type Handle = { token: string; metaPath: string; heartbeatPath: string; lockDir: string } - - const tryAcquireLockDir = (lockDir: string, key: string) => - Effect.gen(function* () { - const token = randomUUID() - const metaPath = path.join(lockDir, "meta.json") - const heartbeatPath = path.join(lockDir, "heartbeat") - - // Atomic mkdir — the POSIX lock primitive - const created = yield* atomicMkdir(lockDir) - - if (!created) { - if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired() - - // Stale — race for breaker ownership - const breakerPath = lockDir + ".breaker" - - const claimed = yield* fs.makeDirectory(breakerPath, { mode: 0o700 }).pipe( - Effect.as(true), - Effect.catchIf( - (e) => e.reason._tag === "AlreadyExists", - () => cleanStaleBreaker(breakerPath), - ), - Effect.catchIf(isPathGone, () => Effect.succeed(false)), - Effect.orDie, - ) - - if (!claimed) return yield* new NotAcquired() - - // We own the breaker — double-check staleness, nuke, recreate - const recreated = yield* Effect.gen(function* () { - if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return false - yield* forceRemove(lockDir) - return yield* atomicMkdir(lockDir) - }).pipe(Effect.ensuring(forceRemove(breakerPath))) - - if (!recreated) return yield* new NotAcquired() - } - - // We own the lock dir — write heartbeat + meta with exclusive create - yield* exclusiveWrite(heartbeatPath, "", lockDir, "heartbeat already existed") - - const metaJson = encodeMeta({ token, pid: process.pid, hostname, createdAt: new Date().toISOString() }) - yield* exclusiveWrite(metaPath, metaJson, lockDir, "meta.json already existed") - - return { token, metaPath, heartbeatPath, lockDir } satisfies Handle - }).pipe( - Effect.withSpan("EffectFlock.tryAcquire", { - attributes: { key }, - }), - ) - - // -- retry wrapper (preserves Handle type) -- - - const acquireHandle = (lockfile: string, key: string): Effect.Effect<Handle, LockError> => - tryAcquireLockDir(lockfile, key).pipe( - Effect.retry({ - while: (err) => err._tag === "NotAcquired", - schedule: retrySchedule, - }), - Effect.catchTag("NotAcquired", () => Effect.fail(new LockTimeoutError({ key }))), - ) - - // -- release -- - - const release = (handle: Handle) => - Effect.gen(function* () { - const raw = yield* fs.readFileString(handle.metaPath).pipe( - Effect.catch((err) => { - if (isPathGone(err)) return Effect.die(new ReleaseError({ detail: "metadata missing" })) - return Effect.die(err) - }), - ) - - const parsed = yield* Effect.try({ - try: () => decodeMeta(raw), - catch: (cause) => new ReleaseError({ detail: "metadata invalid", cause }), - }).pipe(Effect.orDie) - - if (parsed.token !== handle.token) return yield* Effect.die(new ReleaseError({ detail: "token mismatch" })) - - yield* forceRemove(handle.lockDir) - }) - - // -- build service -- - - const acquire = Effect.fn("EffectFlock.acquire")(function* (key: string, dir?: string) { - const lockDir = dir ?? lockRoot - yield* ensureDir(lockDir) - - const lockfile = path.join(lockDir, Hash.fast(key) + ".lock") - - // acquireRelease: acquire is uninterruptible, release is guaranteed - const handle = yield* Effect.acquireRelease(acquireHandle(lockfile, key), (handle) => release(handle)) - - // Heartbeat fiber — scoped, so it's interrupted before release runs - yield* fs - .utimes(handle.heartbeatPath, new Date(), new Date()) - .pipe(Effect.ignore, Effect.repeat(Schedule.spaced(HEARTBEAT_MS)), Effect.forkScoped) - }) - - const withLock: Interface["withLock"] = Function.dual( - (args) => Effect.isEffect(args[0]), - <A, E, R>(body: Effect.Effect<A, E, R>, key: string, dir?: string): Effect.Effect<A, E | LockError, R> => - Effect.scoped( - Effect.gen(function* () { - yield* acquire(key, dir) - return yield* body - }), - ), - ) - - return Service.of({ acquire, withLock }) - }), - ) - - export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.layer)) -} diff --git a/packages/shared/src/util/encode.ts b/packages/shared/src/util/encode.ts deleted file mode 100644 index e4c6e70ac..000000000 --- a/packages/shared/src/util/encode.ts +++ /dev/null @@ -1,51 +0,0 @@ -export function base64Encode(value: string) { - const bytes = new TextEncoder().encode(value) - const binary = Array.from(bytes, (b) => String.fromCharCode(b)).join("") - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") -} - -export function base64Decode(value: string) { - const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/")) - const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)) - return new TextDecoder().decode(bytes) -} - -export async function hash(content: string, algorithm = "SHA-256"): Promise<string> { - const encoder = new TextEncoder() - const data = encoder.encode(content) - const hashBuffer = await crypto.subtle.digest(algorithm, data) - const hashArray = Array.from(new Uint8Array(hashBuffer)) - const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("") - return hashHex -} - -export function checksum(content: string): string | undefined { - if (!content) return undefined - let hash = 0x811c9dc5 - for (let i = 0; i < content.length; i++) { - hash ^= content.charCodeAt(i) - hash = Math.imul(hash, 0x01000193) - } - return (hash >>> 0).toString(36) -} - -export function sampledChecksum(content: string, limit = 500_000): string | undefined { - if (!content) return undefined - if (content.length <= limit) return checksum(content) - - const size = 4096 - const points = [ - 0, - Math.floor(content.length * 0.25), - Math.floor(content.length * 0.5), - Math.floor(content.length * 0.75), - content.length - size, - ] - const hashes = points - .map((point) => { - const start = Math.max(0, Math.min(content.length - size, point - Math.floor(size / 2))) - return checksum(content.slice(start, start + size)) ?? "" - }) - .join(":") - return `${content.length}:${hashes}` -} diff --git a/packages/shared/src/util/error.ts b/packages/shared/src/util/error.ts deleted file mode 100644 index 9d3b7c661..000000000 --- a/packages/shared/src/util/error.ts +++ /dev/null @@ -1,60 +0,0 @@ -import z from "zod" - -export abstract class NamedError extends Error { - abstract schema(): z.core.$ZodType - abstract toObject(): { name: string; data: any } - - static hasName(error: unknown, name: string): boolean { - return ( - typeof error === "object" && error !== null && "name" in error && (error as Record<string, unknown>).name === name - ) - } - - static create<Name extends string, Data extends z.core.$ZodType>(name: Name, data: Data) { - const schema = z - .object({ - name: z.literal(name), - data, - }) - .meta({ - ref: name, - }) - const result = class extends NamedError { - public static readonly Schema = schema - - public override readonly name = name as Name - - constructor( - public readonly data: z.input<Data>, - options?: ErrorOptions, - ) { - super(name, options) - this.name = name - } - - static isInstance(input: any): input is InstanceType<typeof result> { - return typeof input === "object" && "name" in input && input.name === name - } - - schema() { - return schema - } - - toObject() { - return { - name: name, - data: this.data, - } - } - } - Object.defineProperty(result, "name", { value: name }) - return result - } - - public static readonly Unknown = NamedError.create( - "UnknownError", - z.object({ - message: z.string(), - }), - ) -} diff --git a/packages/shared/src/util/flock.ts b/packages/shared/src/util/flock.ts deleted file mode 100644 index 958bd9fd1..000000000 --- a/packages/shared/src/util/flock.ts +++ /dev/null @@ -1,358 +0,0 @@ -import path from "path" -import os from "os" -import { randomBytes, randomUUID } from "crypto" -import { mkdir, readFile, rm, stat, utimes, writeFile } from "fs/promises" -import { Hash } from "./hash" -import { Effect } from "effect" - -export type FlockGlobal = { - state: string -} - -export namespace Flock { - let global: FlockGlobal | undefined - - export function setGlobal(g: FlockGlobal) { - global = g - } - - const root = () => { - if (!global) throw new Error("Flock global not set") - return path.join(global.state, "locks") - } - - // Defaults for callers that do not provide timing options. - const defaultOpts = { - staleMs: 60_000, - timeoutMs: 5 * 60_000, - baseDelayMs: 100, - maxDelayMs: 2_000, - } - - export interface WaitEvent { - key: string - attempt: number - delay: number - waited: number - } - - export type Wait = (input: WaitEvent) => void | Promise<void> - - export interface Options { - dir?: string - signal?: AbortSignal - staleMs?: number - timeoutMs?: number - baseDelayMs?: number - maxDelayMs?: number - onWait?: Wait - } - - type Opts = { - staleMs: number - timeoutMs: number - baseDelayMs: number - maxDelayMs: number - } - - type Owned = { - acquired: true - startHeartbeat: (intervalMs?: number) => void - release: () => Promise<void> - } - - export interface Lease { - release: () => Promise<void> - [Symbol.asyncDispose]: () => Promise<void> - } - - function code(err: unknown) { - if (typeof err !== "object" || err === null || !("code" in err)) return - const value = err.code - if (typeof value !== "string") return - return value - } - - function sleep(ms: number, signal?: AbortSignal) { - return new Promise<void>((resolve, reject) => { - if (signal?.aborted) { - reject(signal.reason ?? new Error("Aborted")) - return - } - - let timer: NodeJS.Timeout | undefined - - const done = () => { - signal?.removeEventListener("abort", abort) - resolve() - } - - const abort = () => { - if (timer) { - clearTimeout(timer) - } - signal?.removeEventListener("abort", abort) - reject(signal?.reason ?? new Error("Aborted")) - } - - signal?.addEventListener("abort", abort, { once: true }) - timer = setTimeout(done, ms) - }) - } - - function jitter(ms: number) { - const j = Math.floor(ms * 0.3) - const d = Math.floor(Math.random() * (2 * j + 1)) - j - return Math.max(0, ms + d) - } - - function mono() { - return performance.now() - } - - function wall() { - return performance.timeOrigin + mono() - } - - async function stats(file: string) { - try { - return await stat(file) - } catch (err) { - const errCode = code(err) - if (errCode === "ENOENT" || errCode === "ENOTDIR") return - throw err - } - } - - async function stale(lockDir: string, heartbeatPath: string, metaPath: string, staleMs: number) { - // Stale detection allows automatic recovery after crashed owners. - const now = wall() - const heartbeat = await stats(heartbeatPath) - if (heartbeat) { - return now - heartbeat.mtimeMs > staleMs - } - - const meta = await stats(metaPath) - if (meta) { - return now - meta.mtimeMs > staleMs - } - - const dir = await stats(lockDir) - if (!dir) { - return false - } - - return now - dir.mtimeMs > staleMs - } - - async function tryAcquireLockDir(lockDir: string, opts: Opts): Promise<Owned | { acquired: false }> { - const token = randomUUID?.() ?? randomBytes(16).toString("hex") - const metaPath = path.join(lockDir, "meta.json") - const heartbeatPath = path.join(lockDir, "heartbeat") - - try { - await mkdir(lockDir, { mode: 0o700 }) - } catch (err) { - if (code(err) !== "EEXIST") { - throw err - } - - if (!(await stale(lockDir, heartbeatPath, metaPath, opts.staleMs))) { - return { acquired: false } - } - - const breakerPath = lockDir + ".breaker" - try { - await mkdir(breakerPath, { mode: 0o700 }) - } catch (claimErr) { - const errCode = code(claimErr) - if (errCode === "EEXIST") { - const breaker = await stats(breakerPath) - if (breaker && wall() - breaker.mtimeMs > opts.staleMs) { - await rm(breakerPath, { recursive: true, force: true }).catch(() => undefined) - } - return { acquired: false } - } - - if (errCode === "ENOENT" || errCode === "ENOTDIR") { - return { acquired: false } - } - - throw claimErr - } - - try { - // Breaker ownership ensures only one contender performs stale cleanup. - if (!(await stale(lockDir, heartbeatPath, metaPath, opts.staleMs))) { - return { acquired: false } - } - - await rm(lockDir, { recursive: true, force: true }) - - try { - await mkdir(lockDir, { mode: 0o700 }) - } catch (retryErr) { - const errCode = code(retryErr) - if (errCode === "EEXIST" || errCode === "ENOTEMPTY") { - return { acquired: false } - } - throw retryErr - } - } finally { - await rm(breakerPath, { recursive: true, force: true }).catch(() => undefined) - } - } - - const meta = { - token, - pid: process.pid, - hostname: os.hostname(), - createdAt: new Date().toISOString(), - } - - await writeFile(heartbeatPath, "", { flag: "wx" }).catch(async () => { - await rm(lockDir, { recursive: true, force: true }) - throw new Error("Lock acquired but heartbeat already existed (possible compromise).") - }) - - await writeFile(metaPath, JSON.stringify(meta, null, 2), { flag: "wx" }).catch(async () => { - await rm(lockDir, { recursive: true, force: true }) - throw new Error("Lock acquired but meta.json already existed (possible compromise).") - }) - - let timer: NodeJS.Timeout | undefined - - const startHeartbeat = (intervalMs = Math.max(100, Math.floor(opts.staleMs / 3))) => { - if (timer) return - // Heartbeat prevents long critical sections from being evicted as stale. - timer = setInterval(() => { - const t = new Date() - void utimes(heartbeatPath, t, t).catch(() => undefined) - }, intervalMs) - timer.unref?.() - } - - const release = async () => { - if (timer) { - clearInterval(timer) - timer = undefined - } - - const current = await readFile(metaPath, "utf8") - .then((raw) => { - const parsed = JSON.parse(raw) - if (!parsed || typeof parsed !== "object") return {} - return { - token: "token" in parsed && typeof parsed.token === "string" ? parsed.token : undefined, - } - }) - .catch((err) => { - const errCode = code(err) - if (errCode === "ENOENT" || errCode === "ENOTDIR") { - throw new Error("Refusing to release: lock is compromised (metadata missing).") - } - if (err instanceof SyntaxError) { - throw new Error("Refusing to release: lock is compromised (metadata invalid).") - } - throw err - }) - // Token check prevents deleting a lock that was re-acquired by another process. - if (current.token !== token) { - throw new Error("Refusing to release: lock token mismatch (not the owner).") - } - - await rm(lockDir, { recursive: true, force: true }) - } - - return { - acquired: true, - startHeartbeat, - release, - } - } - - async function acquireLockDir( - lockDir: string, - input: { key: string; onWait?: Wait; signal?: AbortSignal }, - opts: Opts, - ) { - const stop = mono() + opts.timeoutMs - let attempt = 0 - let waited = 0 - let delay = opts.baseDelayMs - - while (true) { - input.signal?.throwIfAborted() - - const res = await tryAcquireLockDir(lockDir, opts) - if (res.acquired) { - return res - } - - if (mono() > stop) { - throw new Error(`Timed out waiting for lock: ${input.key}`) - } - - attempt += 1 - const ms = jitter(delay) - await input.onWait?.({ - key: input.key, - attempt, - delay: ms, - waited, - }) - await sleep(ms, input.signal) - waited += ms - delay = Math.min(opts.maxDelayMs, Math.floor(delay * 1.7)) - } - } - - export async function acquire(key: string, input: Options = {}): Promise<Lease> { - input.signal?.throwIfAborted() - const cfg: Opts = { - staleMs: input.staleMs ?? defaultOpts.staleMs, - timeoutMs: input.timeoutMs ?? defaultOpts.timeoutMs, - baseDelayMs: input.baseDelayMs ?? defaultOpts.baseDelayMs, - maxDelayMs: input.maxDelayMs ?? defaultOpts.maxDelayMs, - } - const dir = input.dir ?? root() - - await mkdir(dir, { recursive: true }) - const lockfile = path.join(dir, Hash.fast(key) + ".lock") - const lock = await acquireLockDir( - lockfile, - { - key, - onWait: input.onWait, - signal: input.signal, - }, - cfg, - ) - lock.startHeartbeat() - - const release = () => lock.release() - return { - release, - [Symbol.asyncDispose]() { - return release() - }, - } - } - - export async function withLock<T>(key: string, fn: () => Promise<T>, input: Options = {}) { - await using _ = await acquire(key, input) - input.signal?.throwIfAborted() - return await fn() - } - - export const effect = Effect.fn("Flock.effect")(function* (key: string, input: Options = {}) { - return yield* Effect.acquireRelease( - Effect.promise((signal) => Flock.acquire(key, { ...input, signal })).pipe( - Effect.withSpan("Flock.acquire", { - attributes: { key }, - }), - ), - (lock) => Effect.promise(() => lock.release()).pipe(Effect.withSpan("Flock.release")), - ).pipe(Effect.asVoid) - }) -} diff --git a/packages/shared/src/util/fn.ts b/packages/shared/src/util/fn.ts deleted file mode 100644 index 9efe4622f..000000000 --- a/packages/shared/src/util/fn.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { z } from "zod" - -export function fn<T extends z.ZodType, Result>(schema: T, cb: (input: z.infer<T>) => Result) { - const result = (input: z.infer<T>) => { - const parsed = schema.parse(input) - return cb(parsed) - } - result.force = (input: z.infer<T>) => cb(input) - result.schema = schema - return result -} diff --git a/packages/shared/src/util/glob.ts b/packages/shared/src/util/glob.ts deleted file mode 100644 index febf062da..000000000 --- a/packages/shared/src/util/glob.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { glob, globSync, type GlobOptions } from "glob" -import { minimatch } from "minimatch" - -export namespace Glob { - export interface Options { - cwd?: string - absolute?: boolean - include?: "file" | "all" - dot?: boolean - symlink?: boolean - } - - function toGlobOptions(options: Options): GlobOptions { - return { - cwd: options.cwd, - absolute: options.absolute, - dot: options.dot, - follow: options.symlink ?? false, - nodir: options.include !== "all", - } - } - - export async function scan(pattern: string, options: Options = {}): Promise<string[]> { - return glob(pattern, toGlobOptions(options)) as Promise<string[]> - } - - export function scanSync(pattern: string, options: Options = {}): string[] { - return globSync(pattern, toGlobOptions(options)) as string[] - } - - export function match(pattern: string, filepath: string): boolean { - return minimatch(filepath, pattern, { dot: true }) - } -} diff --git a/packages/shared/src/util/hash.ts b/packages/shared/src/util/hash.ts deleted file mode 100644 index 680e0f40b..000000000 --- a/packages/shared/src/util/hash.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createHash } from "crypto" - -export namespace Hash { - export function fast(input: string | Buffer): string { - return createHash("sha1").update(input).digest("hex") - } -} diff --git a/packages/shared/src/util/identifier.ts b/packages/shared/src/util/identifier.ts deleted file mode 100644 index ba28a351b..000000000 --- a/packages/shared/src/util/identifier.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { randomBytes } from "crypto" - -export namespace Identifier { - const LENGTH = 26 - - // State for monotonic ID generation - let lastTimestamp = 0 - let counter = 0 - - export function ascending() { - return create(false) - } - - export function descending() { - return create(true) - } - - function randomBase62(length: number): string { - const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - let result = "" - const bytes = randomBytes(length) - for (let i = 0; i < length; i++) { - result += chars[bytes[i] % 62] - } - return result - } - - export function create(descending: boolean, timestamp?: number): string { - const currentTimestamp = timestamp ?? Date.now() - - if (currentTimestamp !== lastTimestamp) { - lastTimestamp = currentTimestamp - counter = 0 - } - counter++ - - let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter) - - now = descending ? ~now : now - - const timeBytes = Buffer.alloc(6) - for (let i = 0; i < 6; i++) { - timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff)) - } - - return timeBytes.toString("hex") + randomBase62(LENGTH - 12) - } -} diff --git a/packages/shared/src/util/iife.ts b/packages/shared/src/util/iife.ts deleted file mode 100644 index ca9ae6c10..000000000 --- a/packages/shared/src/util/iife.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function iife<T>(fn: () => T) { - return fn() -} diff --git a/packages/shared/src/util/lazy.ts b/packages/shared/src/util/lazy.ts deleted file mode 100644 index 935ebe0f9..000000000 --- a/packages/shared/src/util/lazy.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function lazy<T>(fn: () => T) { - let value: T | undefined - let loaded = false - - return (): T => { - if (loaded) return value as T - loaded = true - value = fn() - return value as T - } -} diff --git a/packages/shared/src/util/module.ts b/packages/shared/src/util/module.ts deleted file mode 100644 index 6ed3b23d7..000000000 --- a/packages/shared/src/util/module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createRequire } from "node:module" -import path from "node:path" - -export namespace Module { - export function resolve(id: string, dir: string) { - try { - return createRequire(path.join(dir, "package.json")).resolve(id) - } catch {} - } -} diff --git a/packages/shared/src/util/path.ts b/packages/shared/src/util/path.ts deleted file mode 100644 index b87316358..000000000 --- a/packages/shared/src/util/path.ts +++ /dev/null @@ -1,37 +0,0 @@ -export function getFilename(path: string | undefined) { - if (!path) return "" - const trimmed = path.replace(/[/\\]+$/, "") - const parts = trimmed.split(/[/\\]/) - return parts[parts.length - 1] ?? "" -} - -export function getDirectory(path: string | undefined) { - if (!path) return "" - const trimmed = path.replace(/[/\\]+$/, "") - const parts = trimmed.split(/[/\\]/) - return parts.slice(0, parts.length - 1).join("/") + "/" -} - -export function getFileExtension(path: string | undefined) { - if (!path) return "" - const parts = path.split(".") - return parts[parts.length - 1] -} - -export function getFilenameTruncated(path: string | undefined, maxLength: number = 20) { - const filename = getFilename(path) - if (filename.length <= maxLength) return filename - const lastDot = filename.lastIndexOf(".") - const ext = lastDot <= 0 ? "" : filename.slice(lastDot) - const available = maxLength - ext.length - 1 // -1 for ellipsis - if (available <= 0) return filename.slice(0, maxLength - 1) + "…" - return filename.slice(0, available) + "…" + ext -} - -export function truncateMiddle(text: string, maxLength: number = 20) { - if (text.length <= maxLength) return text - const available = maxLength - 1 // -1 for ellipsis - const start = Math.ceil(available / 2) - const end = Math.floor(available / 2) - return text.slice(0, start) + "…" + text.slice(-end) -} diff --git a/packages/shared/src/util/retry.ts b/packages/shared/src/util/retry.ts deleted file mode 100644 index 831d23800..000000000 --- a/packages/shared/src/util/retry.ts +++ /dev/null @@ -1,42 +0,0 @@ -export interface RetryOptions { - attempts?: number - delay?: number - factor?: number - maxDelay?: number - retryIf?: (error: unknown) => boolean -} - -const TRANSIENT_MESSAGES = [ - "load failed", - "network connection was lost", - "network request failed", - "failed to fetch", - "econnreset", - "econnrefused", - "etimedout", - "socket hang up", -] - -function isTransientError(error: unknown): boolean { - if (!error) return false - // oxlint-disable-next-line no-base-to-string -- error is unknown, intentional coercion for message matching - const message = String(error instanceof Error ? error.message : error).toLowerCase() - return TRANSIENT_MESSAGES.some((m) => message.includes(m)) -} - -export async function retry<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> { - const { attempts = 3, delay = 500, factor = 2, maxDelay = 10000, retryIf = isTransientError } = options - - let lastError: unknown - for (let attempt = 0; attempt < attempts; attempt++) { - try { - return await fn() - } catch (error) { - lastError = error - if (attempt === attempts - 1 || !retryIf(error)) throw error - const wait = Math.min(delay * Math.pow(factor, attempt), maxDelay) - await new Promise((resolve) => setTimeout(resolve, wait)) - } - } - throw lastError -} diff --git a/packages/shared/src/util/slug.ts b/packages/shared/src/util/slug.ts deleted file mode 100644 index 62cf0e57b..000000000 --- a/packages/shared/src/util/slug.ts +++ /dev/null @@ -1,74 +0,0 @@ -export namespace Slug { - const ADJECTIVES = [ - "brave", - "calm", - "clever", - "cosmic", - "crisp", - "curious", - "eager", - "gentle", - "glowing", - "happy", - "hidden", - "jolly", - "kind", - "lucky", - "mighty", - "misty", - "neon", - "nimble", - "playful", - "proud", - "quick", - "quiet", - "shiny", - "silent", - "stellar", - "sunny", - "swift", - "tidy", - "witty", - ] as const - - const NOUNS = [ - "cabin", - "cactus", - "canyon", - "circuit", - "comet", - "eagle", - "engine", - "falcon", - "forest", - "garden", - "harbor", - "island", - "knight", - "lagoon", - "meadow", - "moon", - "mountain", - "nebula", - "orchid", - "otter", - "panda", - "pixel", - "planet", - "river", - "rocket", - "sailor", - "squid", - "star", - "tiger", - "wizard", - "wolf", - ] as const - - export function create() { - return [ - ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)], - NOUNS[Math.floor(Math.random() * NOUNS.length)], - ].join("-") - } -} diff --git a/packages/shared/sst-env.d.ts b/packages/shared/sst-env.d.ts deleted file mode 100644 index 64441936d..000000000 --- a/packages/shared/sst-env.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* This file is auto-generated by SST. Do not edit. */ -/* tslint:disable */ -/* eslint-disable */ -/* deno-fmt-ignore-file */ -/* biome-ignore-all lint: auto-generated */ - -/// <reference path="../../sst-env.d.ts" /> - -import "sst" -export {}
\ No newline at end of file diff --git a/packages/shared/test/filesystem/filesystem.test.ts b/packages/shared/test/filesystem/filesystem.test.ts deleted file mode 100644 index b49026bcb..000000000 --- a/packages/shared/test/filesystem/filesystem.test.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { describe, test, expect } from "bun:test" -import { Effect, Layer, FileSystem } from "effect" -import { NodeFileSystem } from "@effect/platform-node" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { testEffect } from "../lib/effect" -import path from "path" - -const live = AppFileSystem.layer.pipe(Layer.provideMerge(NodeFileSystem.layer)) -const { effect: it } = testEffect(live) - -describe("AppFileSystem", () => { - describe("isDir", () => { - it( - "returns true for directories", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - expect(yield* fs.isDir(tmp)).toBe(true) - }), - ) - - it( - "returns false for files", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - const file = path.join(tmp, "test.txt") - yield* filesys.writeFileString(file, "hello") - expect(yield* fs.isDir(file)).toBe(false) - }), - ) - - it( - "returns false for non-existent paths", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - expect(yield* fs.isDir("/tmp/nonexistent-" + Math.random())).toBe(false) - }), - ) - }) - - describe("isFile", () => { - it( - "returns true for files", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - const file = path.join(tmp, "test.txt") - yield* filesys.writeFileString(file, "hello") - expect(yield* fs.isFile(file)).toBe(true) - }), - ) - - it( - "returns false for directories", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - expect(yield* fs.isFile(tmp)).toBe(false) - }), - ) - }) - - describe("readJson / writeJson", () => { - it( - "round-trips JSON data", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - const file = path.join(tmp, "data.json") - const data = { name: "test", count: 42, nested: { ok: true } } - - yield* fs.writeJson(file, data) - const result = yield* fs.readJson(file) - - expect(result).toEqual(data) - }), - ) - }) - - describe("ensureDir", () => { - it( - "creates nested directories", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - const nested = path.join(tmp, "a", "b", "c") - - yield* fs.ensureDir(nested) - - const info = yield* filesys.stat(nested) - expect(info.type).toBe("Directory") - }), - ) - - it( - "is idempotent", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - const dir = path.join(tmp, "existing") - yield* filesys.makeDirectory(dir) - - yield* fs.ensureDir(dir) - - const info = yield* filesys.stat(dir) - expect(info.type).toBe("Directory") - }), - ) - }) - - describe("writeWithDirs", () => { - it( - "creates parent directories if missing", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - const file = path.join(tmp, "deep", "nested", "file.txt") - - yield* fs.writeWithDirs(file, "hello") - - expect(yield* filesys.readFileString(file)).toBe("hello") - }), - ) - - it( - "writes directly when parent exists", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - const file = path.join(tmp, "direct.txt") - - yield* fs.writeWithDirs(file, "world") - - expect(yield* filesys.readFileString(file)).toBe("world") - }), - ) - - it( - "writes Uint8Array content", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - const file = path.join(tmp, "binary.bin") - const content = new Uint8Array([0x00, 0x01, 0x02, 0x03]) - - yield* fs.writeWithDirs(file, content) - - const result = yield* filesys.readFile(file) - expect(new Uint8Array(result)).toEqual(content) - }), - ) - }) - - describe("findUp", () => { - it( - "finds target in start directory", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - yield* filesys.writeFileString(path.join(tmp, "target.txt"), "found") - - const result = yield* fs.findUp("target.txt", tmp) - expect(result).toEqual([path.join(tmp, "target.txt")]) - }), - ) - - it( - "finds target in parent directories", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - yield* filesys.writeFileString(path.join(tmp, "marker"), "root") - const child = path.join(tmp, "a", "b") - yield* filesys.makeDirectory(child, { recursive: true }) - - const result = yield* fs.findUp("marker", child, tmp) - expect(result).toEqual([path.join(tmp, "marker")]) - }), - ) - - it( - "returns empty array when not found", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - const result = yield* fs.findUp("nonexistent", tmp, tmp) - expect(result).toEqual([]) - }), - ) - }) - - describe("up", () => { - it( - "finds multiple targets walking up", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - yield* filesys.writeFileString(path.join(tmp, "a.txt"), "a") - yield* filesys.writeFileString(path.join(tmp, "b.txt"), "b") - const child = path.join(tmp, "sub") - yield* filesys.makeDirectory(child) - yield* filesys.writeFileString(path.join(child, "a.txt"), "a-child") - - const result = yield* fs.up({ targets: ["a.txt", "b.txt"], start: child, stop: tmp }) - - expect(result).toContain(path.join(child, "a.txt")) - expect(result).toContain(path.join(tmp, "a.txt")) - expect(result).toContain(path.join(tmp, "b.txt")) - }), - ) - }) - - describe("glob", () => { - it( - "finds files matching pattern", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - yield* filesys.writeFileString(path.join(tmp, "a.ts"), "a") - yield* filesys.writeFileString(path.join(tmp, "b.ts"), "b") - yield* filesys.writeFileString(path.join(tmp, "c.json"), "c") - - const result = yield* fs.glob("*.ts", { cwd: tmp }) - expect(result.sort()).toEqual(["a.ts", "b.ts"]) - }), - ) - - it( - "supports absolute paths", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - yield* filesys.writeFileString(path.join(tmp, "file.txt"), "hello") - - const result = yield* fs.glob("*.txt", { cwd: tmp, absolute: true }) - expect(result).toEqual([path.join(tmp, "file.txt")]) - }), - ) - }) - - describe("globMatch", () => { - it( - "matches patterns", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - expect(fs.globMatch("*.ts", "foo.ts")).toBe(true) - expect(fs.globMatch("*.ts", "foo.json")).toBe(false) - expect(fs.globMatch("src/**", "src/a/b.ts")).toBe(true) - }), - ) - }) - - describe("globUp", () => { - it( - "finds files walking up directories", - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - yield* filesys.writeFileString(path.join(tmp, "root.md"), "root") - const child = path.join(tmp, "a", "b") - yield* filesys.makeDirectory(child, { recursive: true }) - yield* filesys.writeFileString(path.join(child, "leaf.md"), "leaf") - - const result = yield* fs.globUp("*.md", child, tmp) - expect(result).toContain(path.join(child, "leaf.md")) - expect(result).toContain(path.join(tmp, "root.md")) - }), - ) - }) - - describe("built-in passthrough", () => { - it( - "exists works", - Effect.gen(function* () { - yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - const file = path.join(tmp, "exists.txt") - yield* filesys.writeFileString(file, "yes") - - expect(yield* filesys.exists(file)).toBe(true) - expect(yield* filesys.exists(file + ".nope")).toBe(false) - }), - ) - - it( - "remove works", - Effect.gen(function* () { - yield* AppFileSystem.Service - const filesys = yield* FileSystem.FileSystem - const tmp = yield* filesys.makeTempDirectoryScoped() - const file = path.join(tmp, "delete-me.txt") - yield* filesys.writeFileString(file, "bye") - - yield* filesys.remove(file) - - expect(yield* filesys.exists(file)).toBe(false) - }), - ) - }) - - describe("pure helpers", () => { - test("mimeType returns correct types", () => { - expect(AppFileSystem.mimeType("file.json")).toBe("application/json") - expect(AppFileSystem.mimeType("image.png")).toBe("image/png") - expect(AppFileSystem.mimeType("unknown.qzx")).toBe("application/octet-stream") - }) - - test("contains checks path containment", () => { - expect(AppFileSystem.contains("/a/b", "/a/b/c")).toBe(true) - expect(AppFileSystem.contains("/a/b", "/a/c")).toBe(false) - }) - - test("overlaps detects overlapping paths", () => { - expect(AppFileSystem.overlaps("/a/b", "/a/b/c")).toBe(true) - expect(AppFileSystem.overlaps("/a/b/c", "/a/b")).toBe(true) - expect(AppFileSystem.overlaps("/a", "/b")).toBe(false) - }) - }) -}) diff --git a/packages/shared/test/fixture/effect-flock-worker.ts b/packages/shared/test/fixture/effect-flock-worker.ts deleted file mode 100644 index c9116c2d5..000000000 --- a/packages/shared/test/fixture/effect-flock-worker.ts +++ /dev/null @@ -1,63 +0,0 @@ -import fs from "fs/promises" -import os from "os" -import { Effect, Layer } from "effect" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" -import { Global } from "@opencode-ai/shared/global" - -type Msg = { - key: string - dir: string - holdMs?: number - ready?: string - active?: string - done?: string -} - -function sleep(ms: number) { - return new Promise<void>((resolve) => setTimeout(resolve, ms)) -} - -const msg: Msg = JSON.parse(process.argv[2]!) - -const testGlobal = Layer.succeed( - Global.Service, - Global.Service.of({ - home: os.homedir(), - data: os.tmpdir(), - cache: os.tmpdir(), - config: os.tmpdir(), - state: os.tmpdir(), - bin: os.tmpdir(), - log: os.tmpdir(), - }), -) - -const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer)) - -async function job() { - if (msg.ready) await fs.writeFile(msg.ready, String(process.pid)) - if (msg.active) await fs.writeFile(msg.active, String(process.pid), { flag: "wx" }) - - try { - if (msg.holdMs && msg.holdMs > 0) await sleep(msg.holdMs) - if (msg.done) await fs.appendFile(msg.done, "1\n") - } finally { - if (msg.active) await fs.rm(msg.active, { force: true }) - } -} - -await Effect.runPromise( - Effect.gen(function* () { - const flock = yield* EffectFlock.Service - yield* flock.withLock( - Effect.promise(() => job()), - msg.key, - msg.dir, - ) - }).pipe(Effect.provide(testLayer)), -).catch((err) => { - const text = err instanceof Error ? (err.stack ?? err.message) : String(err) - process.stderr.write(text) - process.exit(1) -}) diff --git a/packages/shared/test/fixture/flock-worker.ts b/packages/shared/test/fixture/flock-worker.ts deleted file mode 100644 index 9954d290c..000000000 --- a/packages/shared/test/fixture/flock-worker.ts +++ /dev/null @@ -1,72 +0,0 @@ -import fs from "fs/promises" -import { Flock } from "@opencode-ai/shared/util/flock" - -type Msg = { - key: string - dir: string - staleMs?: number - timeoutMs?: number - baseDelayMs?: number - maxDelayMs?: number - holdMs?: number - ready?: string - active?: string - done?: string -} - -function sleep(ms: number) { - return new Promise<void>((resolve) => { - setTimeout(resolve, ms) - }) -} - -function input() { - const raw = process.argv[2] - if (!raw) { - throw new Error("Missing flock worker input") - } - - return JSON.parse(raw) as Msg -} - -async function job(input: Msg) { - if (input.ready) { - await fs.writeFile(input.ready, String(process.pid)) - } - - if (input.active) { - await fs.writeFile(input.active, String(process.pid), { flag: "wx" }) - } - - try { - if (input.holdMs && input.holdMs > 0) { - await sleep(input.holdMs) - } - - if (input.done) { - await fs.appendFile(input.done, "1\n") - } - } finally { - if (input.active) { - await fs.rm(input.active, { force: true }) - } - } -} - -async function main() { - const msg = input() - - await Flock.withLock(msg.key, () => job(msg), { - dir: msg.dir, - staleMs: msg.staleMs, - timeoutMs: msg.timeoutMs, - baseDelayMs: msg.baseDelayMs, - maxDelayMs: msg.maxDelayMs, - }) -} - -await main().catch((err) => { - const text = err instanceof Error ? (err.stack ?? err.message) : String(err) - process.stderr.write(text) - process.exit(1) -}) diff --git a/packages/shared/test/lib/effect.ts b/packages/shared/test/lib/effect.ts deleted file mode 100644 index 131ec5cc6..000000000 --- a/packages/shared/test/lib/effect.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { test, type TestOptions } from "bun:test" -import { Cause, Effect, Exit, Layer } from "effect" -import type * as Scope from "effect/Scope" -import * as TestClock from "effect/testing/TestClock" -import * as TestConsole from "effect/testing/TestConsole" - -type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>) - -const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value)) - -const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2>) => - Effect.gen(function* () { - const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit) - if (Exit.isFailure(exit)) { - for (const err of Cause.prettyErrors(exit.cause)) { - yield* Effect.logError(err) - } - } - return yield* exit - }).pipe(Effect.runPromise) - -const make = <R, E>(testLayer: Layer.Layer<R, E>, liveLayer: Layer.Layer<R, E>) => { - const effect = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) => - test(name, () => run(value, testLayer), opts) - - effect.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) => - test.only(name, () => run(value, testLayer), opts) - - effect.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) => - test.skip(name, () => run(value, testLayer), opts) - - const live = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) => - test(name, () => run(value, liveLayer), opts) - - live.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) => - test.only(name, () => run(value, liveLayer), opts) - - live.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) => - test.skip(name, () => run(value, liveLayer), opts) - - return { effect, live } -} - -// Test environment with TestClock and TestConsole -const testEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer()) - -// Live environment - uses real clock, but keeps TestConsole for output capture -const liveEnv = TestConsole.layer - -export const it = make(testEnv, liveEnv) - -export const testEffect = <R, E>(layer: Layer.Layer<R, E>) => - make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv)) diff --git a/packages/shared/test/util/effect-flock.test.ts b/packages/shared/test/util/effect-flock.test.ts deleted file mode 100644 index bd71e4f02..000000000 --- a/packages/shared/test/util/effect-flock.test.ts +++ /dev/null @@ -1,389 +0,0 @@ -import { describe, expect } from "bun:test" -import { spawn } from "child_process" -import fs from "fs/promises" -import path from "path" -import os from "os" -import { Cause, Effect, Exit, Layer } from "effect" -import { testEffect } from "../lib/effect" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" -import { Global } from "@opencode-ai/shared/global" -import { Hash } from "@opencode-ai/shared/util/hash" - -function lock(dir: string, key: string) { - return path.join(dir, Hash.fast(key) + ".lock") -} - -function sleep(ms: number) { - return new Promise<void>((resolve) => setTimeout(resolve, ms)) -} - -async function exists(file: string) { - return fs - .stat(file) - .then(() => true) - .catch(() => false) -} - -async function readJson<T>(p: string): Promise<T> { - return JSON.parse(await fs.readFile(p, "utf8")) -} - -// --------------------------------------------------------------------------- -// Worker subprocess helpers -// --------------------------------------------------------------------------- - -type Msg = { - key: string - dir: string - holdMs?: number - ready?: string - active?: string - done?: string -} - -const root = path.join(import.meta.dir, "../..") -const worker = path.join(import.meta.dir, "../fixture/effect-flock-worker.ts") - -function run(msg: Msg) { - return new Promise<{ code: number; stdout: Buffer; stderr: Buffer }>((resolve) => { - const proc = spawn(process.execPath, [worker, JSON.stringify(msg)], { cwd: root }) - const stdout: Buffer[] = [] - const stderr: Buffer[] = [] - proc.stdout?.on("data", (data) => stdout.push(Buffer.from(data))) - proc.stderr?.on("data", (data) => stderr.push(Buffer.from(data))) - proc.on("close", (code) => { - resolve({ code: code ?? 1, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) }) - }) - }) -} - -function spawnWorker(msg: Msg) { - return spawn(process.execPath, [worker, JSON.stringify(msg)], { - cwd: root, - stdio: ["ignore", "pipe", "pipe"], - }) -} - -function stopWorker(proc: ReturnType<typeof spawnWorker>) { - if (proc.exitCode !== null || proc.signalCode !== null) return Promise.resolve() - if (process.platform !== "win32" || !proc.pid) { - proc.kill() - return Promise.resolve() - } - return new Promise<void>((resolve) => { - const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"]) - killProc.on("close", () => { - proc.kill() - resolve() - }) - }) -} - -async function waitForFile(file: string, timeout = 3_000) { - const stop = Date.now() + timeout - while (Date.now() < stop) { - if (await exists(file)) return - await sleep(20) - } - throw new Error(`Timed out waiting for file: ${file}`) -} - -// --------------------------------------------------------------------------- -// Test layer -// --------------------------------------------------------------------------- - -const testGlobal = Layer.succeed( - Global.Service, - Global.Service.of({ - home: os.homedir(), - data: os.tmpdir(), - cache: os.tmpdir(), - config: os.tmpdir(), - state: os.tmpdir(), - bin: os.tmpdir(), - log: os.tmpdir(), - }), -) - -const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer)) - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe("util.effect-flock", () => { - const it = testEffect(testLayer) - - it.live( - "acquire and release via scoped Effect", - Effect.gen(function* () { - const flock = yield* EffectFlock.Service - const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) - const dir = path.join(tmp, "locks") - const lockDir = lock(dir, "eflock:acquire") - - yield* Effect.scoped(flock.acquire("eflock:acquire", dir)) - - expect(yield* Effect.promise(() => exists(lockDir))).toBe(false) - yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) - }), - ) - - it.live( - "withLock data-first", - Effect.gen(function* () { - const flock = yield* EffectFlock.Service - const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) - const dir = path.join(tmp, "locks") - - let hit = false - yield* flock.withLock( - Effect.sync(() => { - hit = true - }), - "eflock:df", - dir, - ) - expect(hit).toBe(true) - yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) - }), - ) - - it.live( - "withLock pipeable", - Effect.gen(function* () { - const flock = yield* EffectFlock.Service - const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) - const dir = path.join(tmp, "locks") - - let hit = false - yield* Effect.sync(() => { - hit = true - }).pipe(flock.withLock("eflock:pipe", dir)) - expect(hit).toBe(true) - yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) - }), - ) - - it.live( - "writes owner metadata", - Effect.gen(function* () { - const flock = yield* EffectFlock.Service - const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) - const dir = path.join(tmp, "locks") - const key = "eflock:meta" - const file = path.join(lock(dir, key), "meta.json") - - yield* Effect.scoped( - Effect.gen(function* () { - yield* flock.acquire(key, dir) - const json = yield* Effect.promise(() => - readJson<{ token?: unknown; pid?: unknown; hostname?: unknown; createdAt?: unknown }>(file), - ) - expect(typeof json.token).toBe("string") - expect(typeof json.pid).toBe("number") - expect(typeof json.hostname).toBe("string") - expect(typeof json.createdAt).toBe("string") - }), - ) - yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) - }), - ) - - it.live( - "breaks stale lock dirs", - Effect.gen(function* () { - const flock = yield* EffectFlock.Service - const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) - const dir = path.join(tmp, "locks") - const key = "eflock:stale" - const lockDir = lock(dir, key) - - yield* Effect.promise(async () => { - await fs.mkdir(lockDir, { recursive: true }) - const old = new Date(Date.now() - 120_000) - await fs.utimes(lockDir, old, old) - }) - - let hit = false - yield* flock.withLock( - Effect.sync(() => { - hit = true - }), - key, - dir, - ) - expect(hit).toBe(true) - yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) - }), - ) - - it.live( - "recovers from stale breaker", - Effect.gen(function* () { - const flock = yield* EffectFlock.Service - const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) - const dir = path.join(tmp, "locks") - const key = "eflock:stale-breaker" - const lockDir = lock(dir, key) - const breaker = lockDir + ".breaker" - - yield* Effect.promise(async () => { - await fs.mkdir(lockDir, { recursive: true }) - await fs.mkdir(breaker) - const old = new Date(Date.now() - 120_000) - await fs.utimes(lockDir, old, old) - await fs.utimes(breaker, old, old) - }) - - let hit = false - yield* flock.withLock( - Effect.sync(() => { - hit = true - }), - key, - dir, - ) - expect(hit).toBe(true) - expect(yield* Effect.promise(() => exists(breaker))).toBe(false) - yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) - }), - ) - - it.live( - "detects compromise when lock dir removed", - Effect.gen(function* () { - const flock = yield* EffectFlock.Service - const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) - const dir = path.join(tmp, "locks") - const key = "eflock:compromised" - const lockDir = lock(dir, key) - - const result = yield* flock - .withLock( - Effect.promise(() => fs.rm(lockDir, { recursive: true, force: true })), - key, - dir, - ) - .pipe(Effect.exit) - - expect(Exit.isFailure(result)).toBe(true) - expect(Exit.isFailure(result) ? Cause.pretty(result.cause) : "").toContain("missing") - yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) - }), - ) - - it.live( - "detects token mismatch", - Effect.gen(function* () { - const flock = yield* EffectFlock.Service - const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) - const dir = path.join(tmp, "locks") - const key = "eflock:token" - const lockDir = lock(dir, key) - const meta = path.join(lockDir, "meta.json") - - const result = yield* flock - .withLock( - Effect.promise(async () => { - const json = await readJson<{ token?: string }>(meta) - json.token = "tampered" - await fs.writeFile(meta, JSON.stringify(json, null, 2)) - }), - key, - dir, - ) - .pipe(Effect.exit) - - expect(Exit.isFailure(result)).toBe(true) - expect(Exit.isFailure(result) ? Cause.pretty(result.cause) : "").toContain("token mismatch") - expect(yield* Effect.promise(() => exists(lockDir))).toBe(true) - yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) - }), - ) - - it.live( - "fails on unwritable lock roots", - Effect.gen(function* () { - if (process.platform === "win32") return - const flock = yield* EffectFlock.Service - const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) - const dir = path.join(tmp, "locks") - - yield* Effect.promise(async () => { - await fs.mkdir(dir, { recursive: true }) - await fs.chmod(dir, 0o500) - }) - - const result = yield* flock.withLock(Effect.void, "eflock:perm", dir).pipe(Effect.exit) - // oxlint-disable-next-line no-base-to-string -- Exit has a useful toString for test assertions - expect(String(result)).toContain("PermissionDenied") - yield* Effect.promise(() => fs.chmod(dir, 0o700).then(() => fs.rm(tmp, { recursive: true, force: true }))) - }), - ) - - it.live( - "enforces mutual exclusion under process contention", - () => - Effect.promise(async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "eflock-stress-")) - const dir = path.join(tmp, "locks") - const done = path.join(tmp, "done.log") - const active = path.join(tmp, "active") - const n = 16 - - try { - const out = await Promise.all( - Array.from({ length: n }, () => run({ key: "eflock:stress", dir, done, active, holdMs: 30 })), - ) - - expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0)) - expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([]) - - const lines = (await fs.readFile(done, "utf8")) - .split("\n") - .map((x) => x.trim()) - .filter(Boolean) - expect(lines.length).toBe(n) - } finally { - await fs.rm(tmp, { recursive: true, force: true }) - } - }), - 60_000, - ) - - it.live( - "recovers after a crashed lock owner", - () => - Effect.promise(async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "eflock-crash-")) - const dir = path.join(tmp, "locks") - const ready = path.join(tmp, "ready") - - const proc = spawnWorker({ key: "eflock:crash", dir, ready, holdMs: 120_000 }) - - try { - await waitForFile(ready, 5_000) - await stopWorker(proc) - await new Promise((resolve) => proc.on("close", resolve)) - - // Backdate lock files so they're past STALE_MS (60s) - const lockDir = lock(dir, "eflock:crash") - const old = new Date(Date.now() - 120_000) - await fs.utimes(lockDir, old, old).catch(() => {}) - await fs.utimes(path.join(lockDir, "heartbeat"), old, old).catch(() => {}) - await fs.utimes(path.join(lockDir, "meta.json"), old, old).catch(() => {}) - - const done = path.join(tmp, "done.log") - const result = await run({ key: "eflock:crash", dir, done, holdMs: 10 }) - expect(result.code).toBe(0) - expect(result.stderr.toString()).toBe("") - } finally { - await stopWorker(proc).catch(() => {}) - await fs.rm(tmp, { recursive: true, force: true }) - } - }), - 30_000, - ) -}) diff --git a/packages/shared/test/util/flock.test.ts b/packages/shared/test/util/flock.test.ts deleted file mode 100644 index f1053dfd2..000000000 --- a/packages/shared/test/util/flock.test.ts +++ /dev/null @@ -1,426 +0,0 @@ -import { describe, expect, test } from "bun:test" -import fs from "fs/promises" -import { spawn } from "child_process" -import path from "path" -import os from "os" -import { Flock } from "@opencode-ai/shared/util/flock" -import { Hash } from "@opencode-ai/shared/util/hash" - -type Msg = { - key: string - dir: string - staleMs?: number - timeoutMs?: number - baseDelayMs?: number - maxDelayMs?: number - holdMs?: number - ready?: string - active?: string - done?: string -} - -const root = path.join(import.meta.dir, "../..") -const worker = path.join(import.meta.dir, "../fixture/flock-worker.ts") - -async function tmpdir() { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "flock-test-")) - return { - path: dir, - async [Symbol.asyncDispose]() { - await fs.rm(dir, { recursive: true, force: true }) - }, - } -} - -function lock(dir: string, key: string) { - return path.join(dir, Hash.fast(key) + ".lock") -} - -function sleep(ms: number) { - return new Promise<void>((resolve) => { - setTimeout(resolve, ms) - }) -} - -async function exists(file: string) { - return fs - .stat(file) - .then(() => true) - .catch(() => false) -} - -async function wait(file: string, timeout = 3_000) { - const stop = Date.now() + timeout - while (Date.now() < stop) { - if (await exists(file)) return - await sleep(20) - } - - throw new Error(`Timed out waiting for file: ${file}`) -} - -function run(msg: Msg) { - return new Promise<{ code: number; stdout: Buffer; stderr: Buffer }>((resolve) => { - const proc = spawn(process.execPath, [worker, JSON.stringify(msg)], { - cwd: root, - }) - - const stdout: Buffer[] = [] - const stderr: Buffer[] = [] - - proc.stdout?.on("data", (data) => stdout.push(Buffer.from(data))) - proc.stderr?.on("data", (data) => stderr.push(Buffer.from(data))) - - proc.on("close", (code) => { - resolve({ - code: code ?? 1, - stdout: Buffer.concat(stdout), - stderr: Buffer.concat(stderr), - }) - }) - }) -} - -function spawnWorker(msg: Msg) { - return spawn(process.execPath, [worker, JSON.stringify(msg)], { - cwd: root, - stdio: ["ignore", "pipe", "pipe"], - }) -} - -function stopWorker(proc: ReturnType<typeof spawnWorker>) { - if (proc.exitCode !== null || proc.signalCode !== null) return Promise.resolve() - - if (process.platform !== "win32" || !proc.pid) { - proc.kill() - return Promise.resolve() - } - - return new Promise<void>((resolve) => { - const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"]) - killProc.on("close", () => { - proc.kill() - resolve() - }) - }) -} - -async function readJson<T>(p: string): Promise<T> { - return JSON.parse(await fs.readFile(p, "utf8")) -} - -describe("util.flock", () => { - test("enforces mutual exclusion under process contention", async () => { - await using tmp = await tmpdir() - const dir = path.join(tmp.path, "locks") - const done = path.join(tmp.path, "done.log") - const active = path.join(tmp.path, "active") - const key = "flock:stress" - const n = 16 - - const out = await Promise.all( - Array.from({ length: n }, () => - run({ - key, - dir, - done, - active, - holdMs: 30, - staleMs: 1_000, - timeoutMs: 15_000, - }), - ), - ) - - expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0)) - expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([]) - - const lines = (await fs.readFile(done, "utf8")) - .split("\n") - .map((x) => x.trim()) - .filter(Boolean) - expect(lines.length).toBe(n) - }, 20_000) - - test("times out while waiting when lock is still healthy", async () => { - await using tmp = await tmpdir() - const dir = path.join(tmp.path, "locks") - const key = "flock:timeout" - const ready = path.join(tmp.path, "ready") - const proc = spawnWorker({ - key, - dir, - ready, - holdMs: 20_000, - staleMs: 10_000, - timeoutMs: 30_000, - }) - - try { - await wait(ready, 5_000) - const seen: string[] = [] - const err = await Flock.withLock(key, async () => {}, { - dir, - staleMs: 10_000, - timeoutMs: 1_000, - onWait: (tick) => { - seen.push(tick.key) - }, - }).catch((err) => err) - - expect(err).toBeInstanceOf(Error) - if (!(err instanceof Error)) throw err - expect(err.message).toContain("Timed out waiting for lock") - expect(seen.length).toBeGreaterThan(0) - expect(seen.every((x) => x === key)).toBe(true) - } finally { - await stopWorker(proc).catch(() => undefined) - await new Promise((resolve) => proc.on("close", resolve)) - } - }, 15_000) - - test("recovers after a crashed lock owner", async () => { - await using tmp = await tmpdir() - const dir = path.join(tmp.path, "locks") - const key = "flock:crash" - const ready = path.join(tmp.path, "ready") - const proc = spawnWorker({ - key, - dir, - ready, - holdMs: 20_000, - staleMs: 500, - timeoutMs: 30_000, - }) - - await wait(ready, 5_000) - await stopWorker(proc) - await new Promise((resolve) => proc.on("close", resolve)) - - let hit = false - await Flock.withLock( - key, - async () => { - hit = true - }, - { - dir, - staleMs: 500, - timeoutMs: 8_000, - }, - ) - - expect(hit).toBe(true) - }, 20_000) - - test("breaks stale lock dirs when heartbeat is missing", async () => { - await using tmp = await tmpdir() - const dir = path.join(tmp.path, "locks") - const key = "flock:missing-heartbeat" - const lockDir = lock(dir, key) - - await fs.mkdir(lockDir, { recursive: true }) - const old = new Date(Date.now() - 2_000) - await fs.utimes(lockDir, old, old) - - let hit = false - await Flock.withLock( - key, - async () => { - hit = true - }, - { - dir, - staleMs: 200, - timeoutMs: 3_000, - }, - ) - - expect(hit).toBe(true) - }) - - test("recovers when a stale breaker claim was left behind", async () => { - await using tmp = await tmpdir() - const dir = path.join(tmp.path, "locks") - const key = "flock:stale-breaker" - const lockDir = lock(dir, key) - const breaker = lockDir + ".breaker" - - await fs.mkdir(lockDir, { recursive: true }) - await fs.mkdir(breaker) - - const old = new Date(Date.now() - 2_000) - await fs.utimes(lockDir, old, old) - await fs.utimes(breaker, old, old) - - let hit = false - await Flock.withLock( - key, - async () => { - hit = true - }, - { - dir, - staleMs: 200, - timeoutMs: 3_000, - }, - ) - - expect(hit).toBe(true) - expect(await exists(breaker)).toBe(false) - }) - - test("fails clearly if lock dir is removed while held", async () => { - await using tmp = await tmpdir() - const dir = path.join(tmp.path, "locks") - const key = "flock:compromised" - const lockDir = lock(dir, key) - - const err = await Flock.withLock( - key, - async () => { - await fs.rm(lockDir, { - recursive: true, - force: true, - }) - }, - { - dir, - staleMs: 1_000, - timeoutMs: 3_000, - }, - ).catch((err) => err) - - expect(err).toBeInstanceOf(Error) - if (!(err instanceof Error)) throw err - expect(err.message).toContain("compromised") - - let hit = false - await Flock.withLock( - key, - async () => { - hit = true - }, - { - dir, - staleMs: 200, - timeoutMs: 3_000, - }, - ) - expect(hit).toBe(true) - }) - - test("writes owner metadata while lock is held", async () => { - await using tmp = await tmpdir() - const dir = path.join(tmp.path, "locks") - const key = "flock:meta" - const file = path.join(lock(dir, key), "meta.json") - - await Flock.withLock( - key, - async () => { - const json = await readJson<{ - token?: unknown - pid?: unknown - hostname?: unknown - createdAt?: unknown - }>(file) - - expect(typeof json.token).toBe("string") - expect(typeof json.pid).toBe("number") - expect(typeof json.hostname).toBe("string") - expect(typeof json.createdAt).toBe("string") - }, - { - dir, - staleMs: 1_000, - timeoutMs: 3_000, - }, - ) - }) - - test("supports acquire with await using", async () => { - await using tmp = await tmpdir() - const dir = path.join(tmp.path, "locks") - const key = "flock:acquire" - const lockDir = lock(dir, key) - - { - await using _ = await Flock.acquire(key, { - dir, - staleMs: 1_000, - timeoutMs: 3_000, - }) - expect(await exists(lockDir)).toBe(true) - } - - expect(await exists(lockDir)).toBe(false) - }) - - test("refuses token mismatch release and recovers from stale", async () => { - await using tmp = await tmpdir() - const dir = path.join(tmp.path, "locks") - const key = "flock:token" - const lockDir = lock(dir, key) - const meta = path.join(lockDir, "meta.json") - - const err = await Flock.withLock( - key, - async () => { - const json = await readJson<{ token?: string }>(meta) - json.token = "tampered" - await fs.writeFile(meta, JSON.stringify(json, null, 2)) - }, - { - dir, - staleMs: 500, - timeoutMs: 3_000, - }, - ).catch((err) => err) - - expect(err).toBeInstanceOf(Error) - if (!(err instanceof Error)) throw err - expect(err.message).toContain("token mismatch") - expect(await exists(lockDir)).toBe(true) - - let hit = false - await Flock.withLock( - key, - async () => { - hit = true - }, - { - dir, - staleMs: 500, - timeoutMs: 6_000, - }, - ) - expect(hit).toBe(true) - }) - - test("fails clearly on unwritable lock roots", async () => { - if (process.platform === "win32") return - - await using tmp = await tmpdir() - const dir = path.join(tmp.path, "locks") - const key = "flock:perm" - - await fs.mkdir(dir, { recursive: true }) - await fs.chmod(dir, 0o500) - - try { - const err = await Flock.withLock(key, async () => {}, { - dir, - staleMs: 100, - timeoutMs: 500, - }).catch((err) => err) - - expect(err).toBeInstanceOf(Error) - if (!(err instanceof Error)) throw err - const text = err.message - expect(text.includes("EACCES") || text.includes("EPERM")).toBe(true) - } finally { - await fs.chmod(dir, 0o700) - } - }) -}) diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json deleted file mode 100644 index d7745d755..000000000 --- a/packages/shared/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "@tsconfig/bun/tsconfig.json", - "compilerOptions": { - "noUncheckedIndexedAccess": false, - "plugins": [ - { - "name": "@effect/language-service", - "transform": "@effect/language-service/transform", - "namespaceImportPackages": ["effect", "@effect/*"] - } - ] - } -} |
