Skip to content

Commit ad05a46

Browse files
authored
refactor(lifecycle): bootstrap as pure orchestration (#25510)
1 parent a6cadba commit ad05a46

4 files changed

Lines changed: 41 additions & 14 deletions

File tree

packages/opencode/src/file/watcher.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,9 @@ export const layer = Layer.effect(
123123
const cfgIgnores = cfg.watcher?.ignore ?? []
124124

125125
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
126-
yield* subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)])
126+
yield* Effect.forkScoped(
127+
subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)]),
128+
)
127129
}
128130

129131
if (ctx.project.vcs === "git") {
@@ -135,7 +137,7 @@ export const layer = Layer.effect(
135137
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
136138
(entry) => entry !== "HEAD",
137139
)
138-
yield* subscribe(vcsDir, ignore)
140+
yield* Effect.forkScoped(subscribe(vcsDir, ignore))
139141
}
140142
}
141143
},

packages/opencode/src/project/bootstrap.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { Snapshot } from "../snapshot"
66
import * as Project from "./project"
77
import * as Vcs from "./vcs"
88
import { Bus } from "../bus"
9-
import { Command } from "../command"
109
import { InstanceState } from "@/effect/instance-state"
1110
import { FileWatcher } from "@/file/watcher"
1211
import { ShareNext } from "@/share/share-next"
@@ -23,13 +22,13 @@ export const layer = Layer.effect(
2322
// Yield each bootstrap dep at layer init so `run` itself has R = never.
2423
// InstanceStore imports only the lightweight tag from bootstrap-service.ts,
2524
// so it can depend on bootstrap without importing this implementation graph.
26-
const bus = yield* Bus.Service
2725
const config = yield* Config.Service
2826
const file = yield* File.Service
2927
const fileWatcher = yield* FileWatcher.Service
3028
const format = yield* Format.Service
3129
const lsp = yield* LSP.Service
3230
const plugin = yield* Plugin.Service
31+
const project = yield* Project.Service
3332
const shareNext = yield* ShareNext.Service
3433
const snapshot = yield* Snapshot.Service
3534
const vcs = yield* Vcs.Service
@@ -41,16 +40,13 @@ export const layer = Layer.effect(
4140
yield* config.get()
4241
// Plugin can mutate config so it has to be initialized before anything else.
4342
yield* plugin.init()
44-
yield* Effect.all(
45-
[lsp, shareNext, format, file, fileWatcher, vcs, snapshot].map((s) => Effect.forkDetach(s.init())),
43+
// Each service self-manages its own slow work via Effect.forkScoped against
44+
// its per-instance state scope. We just await materialization here.
45+
yield* Effect.forEach(
46+
[lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project],
47+
(s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))),
48+
{ concurrency: "unbounded", discard: true },
4649
).pipe(Effect.withSpan("InstanceBootstrap.init"))
47-
48-
const projectID = ctx.project.id
49-
yield* bus.subscribeCallback(Command.Event.Executed, async (payload) => {
50-
if (payload.properties.name === Command.Default.INIT) {
51-
Project.setInitialized(projectID)
52-
}
53-
})
5450
}).pipe(Effect.withSpan("InstanceBootstrap"))
5551

5652
return Service.of({ run })

packages/opencode/src/project/project.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import { BusEvent } from "@/bus/bus-event"
1010
import { GlobalBus } from "@/bus/global"
1111
import { which } from "../util/which"
1212
import { ProjectID } from "./schema"
13+
import { Bus } from "@/bus"
14+
import { Command } from "@/command"
15+
import { InstanceState } from "@/effect/instance-state"
1316
import { Effect, Layer, Path, Scope, Context, Stream, Types, Schema } from "effect"
1417
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
1518
import { NodePath } from "@effect/platform-node"
@@ -108,6 +111,12 @@ export type UpdatePayload = Types.DeepMutable<Schema.Schema.Type<typeof UpdatePa
108111
// ---------------------------------------------------------------------------
109112

110113
export interface Interface {
114+
/**
115+
* Per-instance setup. Subscribes to the `/init` slash command for the
116+
* current instance and stamps the project's initialized timestamp when it
117+
* fires. Subscription lifetime is tied to the per-instance state scope.
118+
*/
119+
readonly init: () => Effect.Effect<void>
111120
readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }>
112121
readonly discover: (input: Info) => Effect.Effect<void>
113122
readonly list: () => Effect.Effect<Info[]>
@@ -127,13 +136,14 @@ type GitResult = { code: number; text: string; stderr: string }
127136
export const layer: Layer.Layer<
128137
Service,
129138
never,
130-
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner
139+
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Bus.Service
131140
> = Layer.effect(
132141
Service,
133142
Effect.gen(function* () {
134143
const fs = yield* AppFileSystem.Service
135144
const pathSvc = yield* Path.Path
136145
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
146+
const bus = yield* Bus.Service
137147

138148
const git = Effect.fnUntraced(
139149
function* (args: string[], opts?: { cwd?: string }) {
@@ -417,6 +427,21 @@ export const layer: Layer.Layer<
417427
)
418428
})
419429

430+
const initState = yield* InstanceState.make(
431+
Effect.fn("Project.initState")(function* (ctx) {
432+
yield* bus.subscribe(Command.Event.Executed).pipe(
433+
Stream.runForEach((payload) =>
434+
payload.properties.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void,
435+
),
436+
Effect.forkScoped,
437+
)
438+
}),
439+
)
440+
441+
const init = Effect.fn("Project.init")(function* () {
442+
yield* InstanceState.get(initState)
443+
})
444+
420445
const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) {
421446
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
422447
if (!row) return []
@@ -466,6 +491,7 @@ export const layer: Layer.Layer<
466491
})
467492

468493
return Service.of({
494+
init,
469495
fromDirectory,
470496
discover,
471497
list,
@@ -481,6 +507,7 @@ export const layer: Layer.Layer<
481507
)
482508

483509
export const defaultLayer = layer.pipe(
510+
Layer.provide(Bus.defaultLayer),
484511
Layer.provide(CrossSpawnSpawner.defaultLayer),
485512
Layer.provide(AppFileSystem.defaultLayer),
486513
Layer.provide(NodePath.layer),

packages/opencode/test/project/project.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, test } from "bun:test"
2+
import { Bus } from "@/bus"
23
import { Project } from "@/project/project"
34
import * as Log from "@opencode-ai/core/util/log"
45
import { $ } from "bun"
@@ -63,6 +64,7 @@ function mockGitFailure(failArg: string) {
6364
function projectLayerWithFailure(failArg: string) {
6465
return Project.layer.pipe(
6566
Layer.provide(mockGitFailure(failArg)),
67+
Layer.provide(Bus.defaultLayer),
6668
Layer.provide(AppFileSystem.defaultLayer),
6769
Layer.provide(NodePath.layer),
6870
)

0 commit comments

Comments
 (0)