Skip to content

Commit 0d76fdc

Browse files
authored
Merge branch 'dev' into min
2 parents bf33480 + 03d84f4 commit 0d76fdc

38 files changed

Lines changed: 1197 additions & 1030 deletions

packages/opencode/AGENTS.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Instructions to follow when writing Effect.
3434
- Use `Effect.gen(function* () { ... })` for composition.
3535
- Use `Effect.fn("ServiceName.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers.
3636
- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary `flow` or outer `.pipe()` wrappers.
37+
- **`Effect.callback`** (not `Effect.async`) for callback-based APIs. The classic `Effect.async` was renamed to `Effect.callback` in effect-smol/v4.
3738

3839
## Time
3940

@@ -42,3 +43,37 @@ Instructions to follow when writing Effect.
4243
## Errors
4344

4445
- In `Effect.gen/fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
46+
47+
## Instance-scoped Effect services
48+
49+
Services that need per-directory lifecycle (created/destroyed per instance) go through the `Instances` LayerMap:
50+
51+
1. Define a `ServiceMap.Service` with a `static readonly layer` (see `FileWatcherService`, `QuestionService`, `PermissionService`, `ProviderAuthService`).
52+
2. Add it to `InstanceServices` union and `Layer.mergeAll(...)` in `src/effect/instances.ts`.
53+
3. Use `InstanceContext` inside the layer to read `directory` and `project` instead of `Instance.*` globals.
54+
4. Call from legacy code via `runPromiseInstance(MyService.use((s) => s.method()))`.
55+
56+
### Instance.bind — ALS context for native callbacks
57+
58+
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and returns a wrapper that restores it synchronously when called.
59+
60+
**Use it** when passing callbacks to native C/C++ addons (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`.
61+
62+
**Don't need it** for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers — Node.js ALS propagates through those automatically.
63+
64+
```typescript
65+
// Native addon callback — needs Instance.bind
66+
const cb = Instance.bind((err, evts) => {
67+
Bus.publish(MyEvent, { ... })
68+
})
69+
nativeAddon.subscribe(dir, cb)
70+
```
71+
72+
## Flag → Effect.Config migration
73+
74+
Flags in `src/flag/flag.ts` are being migrated from static `truthy(...)` reads to `Config.boolean(...).pipe(Config.withDefault(false))` as their consumers get effectified.
75+
76+
- Effectful flags return `Config<boolean>` and are read with `yield*` inside `Effect.gen`.
77+
- The default `ConfigProvider` reads from `process.env`, so env vars keep working.
78+
- Tests can override via `ConfigProvider.layer(ConfigProvider.fromUnknown({ ... }))`.
79+
- Keep all flags in `flag.ts` as the single registry — just change the implementation from `truthy()` to `Config.boolean()` when the consumer moves to Effect.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { ServiceMap } from "effect"
2+
import type { Project } from "@/project/project"
3+
4+
export declare namespace InstanceContext {
5+
export interface Shape {
6+
readonly directory: string
7+
readonly project: Project.Info
8+
}
9+
}
10+
11+
export class InstanceContext extends ServiceMap.Service<InstanceContext, InstanceContext.Shape>()(
12+
"opencode/InstanceContext",
13+
) {}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
const disposers = new Set<(directory: string) => Promise<void>>()
2+
3+
export function registerDisposer(disposer: (directory: string) => Promise<void>) {
4+
disposers.add(disposer)
5+
return () => {
6+
disposers.delete(disposer)
7+
}
8+
}
9+
10+
export async function disposeInstance(directory: string) {
11+
await Promise.allSettled([...disposers].map((disposer) => disposer(directory)))
12+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
2+
import { registerDisposer } from "./instance-registry"
3+
import { InstanceContext } from "./instance-context"
4+
import { ProviderAuthService } from "@/provider/auth-service"
5+
import { QuestionService } from "@/question/service"
6+
import { PermissionService } from "@/permission/service"
7+
import { FileWatcherService } from "@/file/watcher"
8+
import { VcsService } from "@/project/vcs"
9+
import { FileTimeService } from "@/file/time"
10+
import { Instance } from "@/project/instance"
11+
12+
export { InstanceContext } from "./instance-context"
13+
14+
export type InstanceServices =
15+
| QuestionService
16+
| PermissionService
17+
| ProviderAuthService
18+
| FileWatcherService
19+
| VcsService
20+
| FileTimeService
21+
22+
function lookup(directory: string) {
23+
const project = Instance.project
24+
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of({ directory, project }))
25+
return Layer.mergeAll(
26+
Layer.fresh(QuestionService.layer),
27+
Layer.fresh(PermissionService.layer),
28+
Layer.fresh(ProviderAuthService.layer),
29+
Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie),
30+
Layer.fresh(VcsService.layer),
31+
Layer.fresh(FileTimeService.layer).pipe(Layer.orDie),
32+
).pipe(Layer.provide(ctx))
33+
}
34+
35+
export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<string, InstanceServices>>()(
36+
"opencode/Instances",
37+
) {
38+
static readonly layer = Layer.effect(
39+
Instances,
40+
Effect.gen(function* () {
41+
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
42+
const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
43+
yield* Effect.addFinalizer(() => Effect.sync(unregister))
44+
return Instances.of(layerMap)
45+
}),
46+
)
47+
48+
static get(directory: string): Layer.Layer<InstanceServices, never, Instances> {
49+
return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory))))
50+
}
51+
52+
static invalidate(directory: string): Effect.Effect<void, never, Instances> {
53+
return Instances.use((map) => map.invalidate(directory))
54+
}
55+
}
Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1-
import { Layer, ManagedRuntime } from "effect"
1+
import { Effect, Layer, ManagedRuntime } from "effect"
22
import { AccountService } from "@/account/service"
33
import { AuthService } from "@/auth/service"
4-
import { PermissionService } from "@/permission/service"
5-
import { QuestionService } from "@/question/service"
4+
import { Instances } from "@/effect/instances"
5+
import type { InstanceServices } from "@/effect/instances"
6+
import { Instance } from "@/project/instance"
67

78
export const runtime = ManagedRuntime.make(
8-
Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer, PermissionService.layer, QuestionService.layer),
9+
Layer.mergeAll(AccountService.defaultLayer, Instances.layer).pipe(Layer.provideMerge(AuthService.defaultLayer)),
910
)
11+
12+
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
13+
return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
14+
}

packages/opencode/src/file/time.ts

Lines changed: 103 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,115 @@
1-
import { Instance } from "../project/instance"
21
import { Log } from "../util/log"
3-
import { Flag } from "../flag/flag"
2+
import { Flag } from "@/flag/flag"
43
import { Filesystem } from "../util/filesystem"
4+
import { Effect, Layer, ServiceMap, Semaphore } from "effect"
5+
import { runPromiseInstance } from "@/effect/runtime"
6+
import type { SessionID } from "@/session/schema"
57

6-
export namespace FileTime {
7-
const log = Log.create({ service: "file.time" })
8-
// Per-session read times plus per-file write locks.
9-
// All tools that overwrite existing files should run their
10-
// assert/read/write/update sequence inside withLock(filepath, ...)
11-
// so concurrent writes to the same file are serialized.
12-
export const state = Instance.state(() => {
13-
const read: {
14-
[sessionID: string]: {
15-
[path: string]: Date | undefined
16-
}
17-
} = {}
18-
const locks = new Map<string, Promise<void>>()
19-
return {
20-
read,
21-
locks,
22-
}
23-
})
24-
25-
export function read(sessionID: string, file: string) {
26-
log.info("read", { sessionID, file })
27-
const { read } = state()
28-
read[sessionID] = read[sessionID] || {}
29-
read[sessionID][file] = new Date()
8+
const log = Log.create({ service: "file.time" })
9+
10+
export namespace FileTimeService {
11+
export interface Service {
12+
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
13+
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
14+
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
15+
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
3016
}
17+
}
3118

32-
export function get(sessionID: string, file: string) {
33-
return state().read[sessionID]?.[file]
19+
type Stamp = {
20+
readonly read: Date
21+
readonly mtime: number | undefined
22+
readonly ctime: number | undefined
23+
readonly size: number | undefined
24+
}
25+
26+
function stamp(file: string): Stamp {
27+
const stat = Filesystem.stat(file)
28+
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
29+
return {
30+
read: new Date(),
31+
mtime: stat?.mtime?.getTime(),
32+
ctime: stat?.ctime?.getTime(),
33+
size,
3434
}
35+
}
3536

36-
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
37-
const current = state()
38-
const currentLock = current.locks.get(filepath) ?? Promise.resolve()
39-
let release: () => void = () => {}
40-
const nextLock = new Promise<void>((resolve) => {
41-
release = resolve
42-
})
43-
const chained = currentLock.then(() => nextLock)
44-
current.locks.set(filepath, chained)
45-
await currentLock
46-
try {
47-
return await fn()
48-
} finally {
49-
release()
50-
if (current.locks.get(filepath) === chained) {
51-
current.locks.delete(filepath)
37+
function session(reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) {
38+
let value = reads.get(sessionID)
39+
if (!value) {
40+
value = new Map<string, Stamp>()
41+
reads.set(sessionID, value)
42+
}
43+
return value
44+
}
45+
46+
export class FileTimeService extends ServiceMap.Service<FileTimeService, FileTimeService.Service>()(
47+
"@opencode/FileTime",
48+
) {
49+
static readonly layer = Layer.effect(
50+
FileTimeService,
51+
Effect.gen(function* () {
52+
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
53+
const reads = new Map<SessionID, Map<string, Stamp>>()
54+
const locks = new Map<string, Semaphore.Semaphore>()
55+
56+
function getLock(filepath: string) {
57+
let lock = locks.get(filepath)
58+
if (!lock) {
59+
lock = Semaphore.makeUnsafe(1)
60+
locks.set(filepath, lock)
61+
}
62+
return lock
5263
}
53-
}
64+
65+
return FileTimeService.of({
66+
read: Effect.fn("FileTimeService.read")(function* (sessionID: SessionID, file: string) {
67+
log.info("read", { sessionID, file })
68+
session(reads, sessionID).set(file, stamp(file))
69+
}),
70+
71+
get: Effect.fn("FileTimeService.get")(function* (sessionID: SessionID, file: string) {
72+
return reads.get(sessionID)?.get(file)?.read
73+
}),
74+
75+
assert: Effect.fn("FileTimeService.assert")(function* (sessionID: SessionID, filepath: string) {
76+
if (disableCheck) return
77+
78+
const time = reads.get(sessionID)?.get(filepath)
79+
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
80+
const next = stamp(filepath)
81+
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
82+
83+
if (changed) {
84+
throw new Error(
85+
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
86+
)
87+
}
88+
}),
89+
90+
withLock: Effect.fn("FileTimeService.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
91+
const lock = getLock(filepath)
92+
return yield* Effect.promise(fn).pipe(lock.withPermits(1))
93+
}),
94+
})
95+
}),
96+
)
97+
}
98+
99+
export namespace FileTime {
100+
export function read(sessionID: SessionID, file: string) {
101+
return runPromiseInstance(FileTimeService.use((s) => s.read(sessionID, file)))
102+
}
103+
104+
export function get(sessionID: SessionID, file: string) {
105+
return runPromiseInstance(FileTimeService.use((s) => s.get(sessionID, file)))
54106
}
55107

56-
export async function assert(sessionID: string, filepath: string) {
57-
if (Flag.OPENCODE_DISABLE_FILETIME_CHECK === true) {
58-
return
59-
}
60-
61-
const time = get(sessionID, filepath)
62-
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
63-
const mtime = Filesystem.stat(filepath)?.mtime
64-
// Allow a 50ms tolerance for Windows NTFS timestamp fuzziness / async flushing
65-
if (mtime && mtime.getTime() > time.getTime() + 50) {
66-
throw new Error(
67-
`File ${filepath} has been modified since it was last read.\nLast modification: ${mtime.toISOString()}\nLast read: ${time.toISOString()}\n\nPlease read the file again before modifying it.`,
68-
)
69-
}
108+
export async function assert(sessionID: SessionID, filepath: string) {
109+
return runPromiseInstance(FileTimeService.use((s) => s.assert(sessionID, filepath)))
110+
}
111+
112+
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
113+
return runPromiseInstance(FileTimeService.use((s) => s.withLock(filepath, fn)))
70114
}
71115
}

0 commit comments

Comments
 (0)