mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
refactor(cli): simplify indexing safety changes
This commit is contained in:
@@ -72,6 +72,9 @@ describe("search target confinement", () => {
|
||||
const results = yield* Effect.all([Fiber.join(first), Fiber.join(second)])
|
||||
expect(calls).toBe(1)
|
||||
expect(results.map((items) => String(items[0].path))).toEqual(["scan-1.ts", "scan-1.ts"])
|
||||
const replaced = { ...target, ino: target.ino === 0 ? 1 : 0 }
|
||||
expect(Exit.isFailure(yield* list(replaced).pipe(Effect.scoped, Effect.exit))).toBe(true)
|
||||
expect(calls).toBe(1)
|
||||
yield* Scope.close(one, Exit.void)
|
||||
yield* Scope.close(two, Exit.void)
|
||||
const fresh = yield* list(target).pipe(Effect.scoped)
|
||||
@@ -109,37 +112,6 @@ describe("search target confinement", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not start a second listing for a replaced directory while one is active", () =>
|
||||
withTmp((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* FSUtil.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const target = yield* SearchTarget.inspect(fsys, tmp)
|
||||
let calls = 0
|
||||
const list = yield* SearchTarget.listing(
|
||||
fsys,
|
||||
{
|
||||
...ripgrep,
|
||||
find: () =>
|
||||
Effect.sync(() => {
|
||||
calls++
|
||||
return []
|
||||
}),
|
||||
},
|
||||
100_000,
|
||||
)
|
||||
const scope = yield* Scope.make()
|
||||
yield* Scope.provide(scope)(list(target))
|
||||
const replaced = { ...target, ino: target.ino === 0 ? 1 : 0 }
|
||||
expect(replaced.ino).not.toBe(target.ino)
|
||||
const changed = yield* list(replaced).pipe(Effect.scoped, Effect.exit)
|
||||
expect(Exit.isFailure(changed)).toBe(true)
|
||||
expect(calls).toBe(1)
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("recognizes only real managed output files", () =>
|
||||
withTmp((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -19,11 +19,8 @@ import { MemoryEvents } from "@/kilocode/memory/events"
|
||||
import { installMemoryRuntime } from "@/kilocode/memory/runtime"
|
||||
import { KiloToolRegistry } from "@/kilocode/tool/registry"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { registerDisposer } from "@/effect/instance-registry"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { KilocodeWatcher } from "@/kilocode/watcher"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change
|
||||
|
||||
const log = Log.create({ service: "kilocode-bootstrap" })
|
||||
|
||||
@@ -45,21 +42,10 @@ export namespace KilocodeBootstrap {
|
||||
const summary = yield* SessionSummary.Service
|
||||
const provider = yield* Provider.Service
|
||||
const memory = yield* MemoryService.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const off = registerDisposer((directory) =>
|
||||
Effect.runPromise(
|
||||
locations
|
||||
.invalidate(Location.Ref.make({ directory: AbsolutePath.make(directory) }))
|
||||
.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.sync(() => log.warn("location cleanup failed", { directory, err: Cause.squash(cause) })),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(off))
|
||||
const watcher = yield* KilocodeWatcher.Service
|
||||
|
||||
const init = Effect.fn("KilocodeBootstrap.init")(function* () {
|
||||
yield* watcher.init()
|
||||
yield* kilo.init()
|
||||
yield* MemoryLifecycle.subscribe({ bus, sessions, summary, provider, memory })
|
||||
// Invalidate enabled cache on every memory state mutation (properties.directory holds the memory root).
|
||||
@@ -108,17 +94,17 @@ export namespace KilocodeBootstrap {
|
||||
AppNodeBuilder.build(Provider.node),
|
||||
MemoryService.layer,
|
||||
Bus.defaultLayer,
|
||||
locationServiceMapLayer,
|
||||
KilocodeWatcher.defaultLayer,
|
||||
]),
|
||||
)
|
||||
|
||||
const memory = LayerNode.make({ service: MemoryService.Service, layer: MemoryService.layer, deps: [] })
|
||||
const locations = LayerNode.make({ service: LocationServiceMap.Service, layer: locationServiceMapLayer, deps: [] })
|
||||
const watcher = LayerNode.make({ service: KilocodeWatcher.Service, layer: KilocodeWatcher.defaultLayer, deps: [] })
|
||||
export const node = LayerNode.suspend(() =>
|
||||
LayerNode.make({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [KiloSessions.node, Session.node, SessionSummary.node, Provider.node, memory, Bus.node, locations],
|
||||
deps: [KiloSessions.node, Session.node, SessionSummary.node, Provider.node, memory, Bus.node, watcher],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { registerDisposer } from "@/effect/instance-registry"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Cause, Context, Effect, Exit, Layer, Scope } from "effect"
|
||||
|
||||
const log = Log.create({ service: "kilocode-watcher" })
|
||||
|
||||
export namespace KilocodeWatcher {
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@kilocode/Watcher") {}
|
||||
|
||||
// Embedded editor clients (VS Code, JetBrains) have their own file watching
|
||||
// and git integration and do not consume the CLI's vcs.branch.updated event,
|
||||
// so they must not eagerly warm the location stack. The standalone CLI/TUI
|
||||
// keeps this subscription for live branch-label updates.
|
||||
export function eager(client = Flag.KILO_CLIENT) {
|
||||
return client !== "vscode" && client !== "jetbrains"
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const active = new Map<string, Scope.Closeable>()
|
||||
const ref = (directory: string) => Location.Ref.make({ directory: AbsolutePath.make(directory) })
|
||||
|
||||
const off = registerDisposer((directory) =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const child = active.get(directory)
|
||||
if (child) {
|
||||
active.delete(directory)
|
||||
yield* Scope.close(child, Exit.void)
|
||||
}
|
||||
yield* locations.invalidate(ref(directory))
|
||||
}).pipe(Effect.ignore),
|
||||
),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(off))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.forEach(active.values(), (child) => Scope.close(child, Exit.void), { discard: true }).pipe(
|
||||
Effect.andThen(Effect.sync(() => active.clear())),
|
||||
),
|
||||
)
|
||||
|
||||
const warm = (ctx: InstanceContext, child: Scope.Closeable) =>
|
||||
Scope.provide(child)(locations.contextEffect(ref(ctx.directory)))
|
||||
|
||||
return Service.of({
|
||||
init: Effect.fn("KilocodeWatcher.init")(function* () {
|
||||
if (!eager() || (yield* Flag.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER.pipe(Effect.orElseSucceed(() => true))))
|
||||
return
|
||||
const ctx = yield* InstanceState.context
|
||||
if (ctx.project.vcs !== "git" || active.has(ctx.directory)) return
|
||||
|
||||
const child = yield* Scope.make()
|
||||
active.set(ctx.directory, child)
|
||||
yield* warm(ctx, child).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.gen(function* () {
|
||||
if (active.get(ctx.directory) === child) active.delete(ctx.directory)
|
||||
yield* Scope.close(child, Exit.void).pipe(Effect.ignore)
|
||||
yield* Effect.sync(() => log.warn("instance watcher init failed", { err: Cause.squash(cause) }))
|
||||
}),
|
||||
),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(locationServiceMapLayer))
|
||||
}
|
||||
@@ -2,41 +2,31 @@ import { describe, expect, test } from "bun:test"
|
||||
import type { VcsInfo } from "@kilocode/sdk/v2"
|
||||
import { create } from "../../../../src/kilocode/cli/cmd/tui/branch-refresh"
|
||||
|
||||
function setup(input: {
|
||||
workspace?: string
|
||||
directory: string
|
||||
project: string
|
||||
branch?: string
|
||||
bootstrap?: boolean
|
||||
}) {
|
||||
function setup(workspace?: string) {
|
||||
const state = {
|
||||
scope: { workspace: input.workspace, directory: input.directory, project: input.project },
|
||||
vcs: input.bootstrap === false ? undefined : ({ branch: input.branch, default_branch: "main" } as VcsInfo),
|
||||
scope: { workspace, directory: "/repo", project: "project" },
|
||||
vcs: { branch: "main", default_branch: "main" } as VcsInfo | undefined,
|
||||
}
|
||||
const calls: Array<{ workspace?: string; directory?: string }> = []
|
||||
const updates: VcsInfo[] = []
|
||||
const pending = Promise.withResolvers<{ data?: VcsInfo }>()
|
||||
const refresh = create({
|
||||
get: async (route) => {
|
||||
calls.push(route)
|
||||
return pending.promise
|
||||
},
|
||||
apply: (data) => {
|
||||
updates.push(data)
|
||||
state.vcs = data
|
||||
},
|
||||
apply: (data) => (state.vcs = data),
|
||||
scope: () => state.scope,
|
||||
ready: () => state.vcs !== undefined,
|
||||
})
|
||||
return { state, calls, updates, pending, refresh }
|
||||
return { state, calls, pending, refresh }
|
||||
}
|
||||
|
||||
describe("TUI branch refresh", () => {
|
||||
test("waits for bootstrap and then applies the complete VCS snapshot", async () => {
|
||||
const value = setup({ directory: "/repo", project: "project", bootstrap: false })
|
||||
const value = setup()
|
||||
value.state.vcs = undefined
|
||||
await value.refresh.refresh()
|
||||
expect(value.calls).toEqual([])
|
||||
expect(value.updates).toEqual([])
|
||||
|
||||
value.state.vcs = { branch: "main", default_branch: "main" }
|
||||
const run = value.refresh.refresh()
|
||||
@@ -45,64 +35,43 @@ describe("TUI branch refresh", () => {
|
||||
expect(value.state.vcs).toEqual({ branch: "feature", default_branch: "main" })
|
||||
})
|
||||
|
||||
test("routes workspace refreshes and preserves the default branch", async () => {
|
||||
const value = setup({ workspace: "ws", directory: "/repo/ws", project: "project" })
|
||||
const run = value.refresh.refresh()
|
||||
expect(value.calls).toEqual([{ workspace: "ws" }])
|
||||
value.pending.resolve({ data: { branch: "feature", default_branch: "main" } })
|
||||
await run
|
||||
|
||||
expect(value.updates).toEqual([{ branch: "feature", default_branch: "main" }])
|
||||
})
|
||||
|
||||
test("routes directory refreshes without a workspace", async () => {
|
||||
const value = setup({ directory: "/repo", project: "project" })
|
||||
const run = value.refresh.refresh()
|
||||
expect(value.calls).toEqual([{ directory: "/repo" }])
|
||||
value.pending.resolve({ data: { branch: "feature", default_branch: "main" } })
|
||||
await run
|
||||
|
||||
expect(value.updates).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("updates default branch metadata even when the current branch is unchanged", async () => {
|
||||
const value = setup({ directory: "/repo", project: "project", branch: "feature" })
|
||||
const run = value.refresh.refresh()
|
||||
value.pending.resolve({ data: { branch: "feature", default_branch: "develop" } })
|
||||
await run
|
||||
|
||||
expect(value.state.vcs).toEqual({ branch: "feature", default_branch: "develop" })
|
||||
})
|
||||
test.each(["ws", undefined])(
|
||||
"routes %s refreshes and updates metadata when the branch is unchanged",
|
||||
async (workspace) => {
|
||||
const value = setup(workspace)
|
||||
const run = value.refresh.refresh()
|
||||
expect(value.calls).toEqual([workspace ? { workspace } : { directory: "/repo" }])
|
||||
value.pending.resolve({ data: { branch: "main", default_branch: "develop" } })
|
||||
await run
|
||||
expect(value.state.vcs).toEqual({ branch: "main", default_branch: "develop" })
|
||||
},
|
||||
)
|
||||
|
||||
test("ignores responses after the scope changes", async () => {
|
||||
const value = setup({ workspace: "ws-a", directory: "/repo/a", project: "project" })
|
||||
const value = setup("ws-a")
|
||||
const run = value.refresh.refresh()
|
||||
value.state.scope = { workspace: "ws-b", directory: "/repo/b", project: "project" }
|
||||
value.pending.resolve({ data: { branch: "stale", default_branch: "main" } })
|
||||
await run
|
||||
|
||||
expect(value.updates).toEqual([])
|
||||
expect(value.state.vcs).toEqual({ branch: "main", default_branch: "main" })
|
||||
})
|
||||
|
||||
test("ignores responses after disposal", async () => {
|
||||
const value = setup({ directory: "/repo", project: "project" })
|
||||
const value = setup()
|
||||
const run = value.refresh.refresh()
|
||||
value.refresh.dispose()
|
||||
value.pending.resolve({ data: { branch: "stale", default_branch: "main" } })
|
||||
await run
|
||||
await value.refresh.refresh()
|
||||
|
||||
expect(value.calls).toHaveLength(1)
|
||||
expect(value.updates).toEqual([])
|
||||
expect(value.state.vcs).toEqual({ branch: "main", default_branch: "main" })
|
||||
})
|
||||
|
||||
test("keeps the current VCS snapshot when the response has no data", async () => {
|
||||
const value = setup({ directory: "/repo", project: "project", branch: "main" })
|
||||
const value = setup()
|
||||
const run = value.refresh.refresh()
|
||||
value.pending.resolve({})
|
||||
await run
|
||||
|
||||
expect(value.state.vcs).toEqual({ branch: "main", default_branch: "main" })
|
||||
expect(value.updates).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,85 +1,196 @@
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Deferred, Effect } from "effect"
|
||||
import { EventV2Bridge } from "../../src/event-v2-bridge"
|
||||
import { ConfigProvider, Context, Deferred, Effect, Fiber, Layer, LayerMap } from "effect"
|
||||
import * as TestConsole from "effect/testing/TestConsole"
|
||||
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
|
||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||
import { disposeInstance } from "../../src/effect/instance-registry"
|
||||
import { Git } from "../../src/git"
|
||||
import { EventV2Bridge } from "../../src/event-v2-bridge"
|
||||
import { Vcs } from "../../src/project/vcs"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { InstanceBootstrap } from "../../src/project/bootstrap"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { KilocodeWatcher } from "../../src/kilocode/watcher"
|
||||
import type { InstanceContext } from "../../src/project/instance-context"
|
||||
import { TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { awaitWithTimeout, testEffect } from "../lib/effect"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap, type LocationServices } from "@opencode-ai/core/location-services"
|
||||
|
||||
const layer = LayerNode.compile(LayerNode.group([Vcs.node, Git.node, EventV2Bridge.node, CrossSpawnSpawner.node]))
|
||||
const it = testEffect(layer)
|
||||
const layer = Layer.mergeAll(
|
||||
AppNodeBuilder.build(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]]),
|
||||
AppNodeBuilder.build(Git.node),
|
||||
AppNodeBuilder.build(CrossSpawnSpawner.node),
|
||||
)
|
||||
const config = ConfigProvider.layerAdd(ConfigProvider.fromUnknown({ KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "false" }), {
|
||||
asPrimary: true,
|
||||
})
|
||||
const it = testEffect(layer.pipe(Layer.provideMerge(config)))
|
||||
const direct = testEffect(
|
||||
LayerNode.compile(LayerNode.group([Vcs.node, Git.node, EventV2Bridge.node, CrossSpawnSpawner.node])),
|
||||
)
|
||||
|
||||
const git = Effect.fn("VcsWatcherTest.git")(function* (cwd: string, args: string[]) {
|
||||
const result = yield* Git.Service.use((git) => git.run(args, { cwd }))
|
||||
if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr.toString("utf8")}`)
|
||||
// The watcher is unreliable on Windows CI, so this test only runs on unix.
|
||||
const live = process.platform === "win32" ? it.live.skip : it.live
|
||||
|
||||
describe("KilocodeWatcher.eager", () => {
|
||||
test("skips eager location watchers for VS Code", () => {
|
||||
expect(KilocodeWatcher.eager("vscode")).toBe(false)
|
||||
})
|
||||
|
||||
test("skips eager location watchers for JetBrains", () => {
|
||||
expect(KilocodeWatcher.eager("jetbrains")).toBe(false)
|
||||
})
|
||||
|
||||
test("keeps eager location watchers for the standalone CLI", () => {
|
||||
expect(KilocodeWatcher.eager("cli")).toBe(true)
|
||||
expect(KilocodeWatcher.eager(undefined)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Vcs without native watchers", () => {
|
||||
it.instance(
|
||||
"branch reads the current git branch after a switch",
|
||||
() =>
|
||||
live(
|
||||
"instances publish branch updates after git switch",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const git = yield* Git.Service
|
||||
const store = yield* InstanceStore.Service
|
||||
const current = yield* git.branch(dir)
|
||||
if (!current) return yield* Effect.die("missing initial branch")
|
||||
|
||||
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
||||
const created = yield* git.run(["branch", branch], { cwd: dir })
|
||||
expect(created.exitCode).toBe(0)
|
||||
yield* store.load({ directory: dir })
|
||||
|
||||
const pending = yield* Deferred.make<string | undefined>()
|
||||
const handler = (event: GlobalEvent) => {
|
||||
if (event.directory !== dir || event.payload.type !== "vcs.branch.updated") return
|
||||
if (event.payload.properties.branch !== branch) return
|
||||
Deferred.doneUnsafe(pending, Effect.succeed(event.payload.properties.branch))
|
||||
}
|
||||
GlobalBus.on("event", handler)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", handler)))
|
||||
|
||||
// The watcher exposes no readiness signal (its .git subscription is forked
|
||||
// during instance warm-up), so keep generating HEAD churn in the background
|
||||
// and synchronize on the event itself with the full test budget.
|
||||
const churn = yield* Effect.gen(function* () {
|
||||
while (true) {
|
||||
yield* git.run(["switch", current], { cwd: dir })
|
||||
yield* Effect.sleep("50 millis")
|
||||
yield* git.run(["switch", branch], { cwd: dir })
|
||||
yield* Effect.sleep("100 millis")
|
||||
}
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
const updated = yield* awaitWithTimeout(
|
||||
Deferred.await(pending),
|
||||
"timed out waiting for vcs.branch.updated",
|
||||
"15 seconds",
|
||||
)
|
||||
yield* Fiber.interrupt(churn)
|
||||
expect(updated).toBe(branch)
|
||||
}),
|
||||
20_000,
|
||||
)
|
||||
|
||||
direct.instance(
|
||||
"refreshes branch reads, events, and diffs without native watchers",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const vcs = yield* Vcs.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const git = Effect.fn(function* (args: string[]) {
|
||||
const result = yield* Git.Service.use((git) => git.run(args, { cwd: test.directory }))
|
||||
expect(result.exitCode).toBe(0)
|
||||
})
|
||||
yield* git(["branch", "-M", "main"])
|
||||
expect(yield* vcs.branch()).toBe("main")
|
||||
|
||||
const updated = yield* Deferred.make<string | undefined>()
|
||||
const off = yield* events.listen((event) => {
|
||||
if (event.type === Vcs.Event.BranchUpdated.type)
|
||||
Deferred.doneUnsafe(updated, Effect.succeed((event.data as typeof Vcs.Event.BranchUpdated.data.Type).branch))
|
||||
return Effect.void
|
||||
})
|
||||
yield* Effect.addFinalizer(() => off)
|
||||
yield* git(["switch", "-c", "feature"])
|
||||
expect(yield* vcs.branch()).toBe("feature")
|
||||
expect(yield* awaitWithTimeout(Deferred.await(updated), "timed out waiting for branch update")).toBe("feature")
|
||||
|
||||
yield* git(["switch", "main"])
|
||||
expect(yield* vcs.branch()).toBe("main")
|
||||
yield* git(["switch", "feature"])
|
||||
yield* Effect.promise(() => Bun.write(`${test.directory}/branch.txt`, "branch\n"))
|
||||
yield* git(["add", "branch.txt"])
|
||||
yield* git(["commit", "--no-gpg-sign", "-m", "branch change"])
|
||||
const diff = yield* vcs.diff("branch")
|
||||
expect(diff.find((item) => item.file === "branch.txt")).toMatchObject({ status: "added" })
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
test.serial(
|
||||
"isolates location lifetimes between instances",
|
||||
async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const vcs = yield* Vcs.Service
|
||||
yield* vcs.branch()
|
||||
const branch = `fresh-${Math.random().toString(36).slice(2)}`
|
||||
yield* git(test.directory, ["branch", branch])
|
||||
yield* git(test.directory, ["switch", branch])
|
||||
const one = yield* tmpdirScoped()
|
||||
const two = yield* tmpdirScoped()
|
||||
const warmed = new Map<string, number>()
|
||||
const invalidated: string[] = []
|
||||
const map = yield* LayerMap.make((ref: Location.Ref) =>
|
||||
Layer.effectContext(
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
warmed.set(ref.directory, (warmed.get(ref.directory) ?? 0) + 1)
|
||||
return Context.empty() as Context.Context<LocationServices>
|
||||
}),
|
||||
() => Effect.sync(() => invalidated.push(ref.directory)),
|
||||
),
|
||||
),
|
||||
)
|
||||
const watcher = KilocodeWatcher.layer.pipe(Layer.provide(Layer.succeed(LocationServiceMap.Service, map)))
|
||||
const services = yield* Layer.build(watcher)
|
||||
const init = (directory: string) =>
|
||||
KilocodeWatcher.Service.use((service) => service.init()).pipe(
|
||||
Effect.provide(services),
|
||||
Effect.provideService(InstanceRef, {
|
||||
directory,
|
||||
worktree: directory,
|
||||
project: { vcs: "git" },
|
||||
} as InstanceContext),
|
||||
)
|
||||
|
||||
expect(yield* vcs.branch()).toBe(branch)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"branch diff reads the current branch after a switch",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const vcs = yield* Vcs.Service
|
||||
yield* git(test.directory, ["branch", "-M", "main"])
|
||||
expect(yield* vcs.branch()).toBe("main")
|
||||
|
||||
const branch = `diff-${Math.random().toString(36).slice(2)}`
|
||||
yield* git(test.directory, ["switch", "-c", branch])
|
||||
yield* Effect.promise(() => Bun.write(`${test.directory}/branch.txt`, "branch\n"))
|
||||
yield* git(test.directory, ["add", "branch.txt"])
|
||||
yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "branch change"])
|
||||
|
||||
const diff = yield* vcs.diff("branch")
|
||||
expect(diff.find((item) => item.file === "branch.txt")).toMatchObject({ status: "added" })
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"publishes a branch update when a fresh branch read changes",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const vcs = yield* Vcs.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
yield* vcs.branch()
|
||||
const branch = `event-${Math.random().toString(36).slice(2)}`
|
||||
yield* git(test.directory, ["branch", branch])
|
||||
|
||||
const updated = yield* Deferred.make<string | undefined>()
|
||||
const off = yield* events.listen((event) => {
|
||||
if (event.type === Vcs.Event.BranchUpdated.type)
|
||||
Deferred.doneUnsafe(
|
||||
updated,
|
||||
Effect.succeed((event.data as typeof Vcs.Event.BranchUpdated.data.Type).branch),
|
||||
)
|
||||
return Effect.void
|
||||
})
|
||||
yield* Effect.addFinalizer(() => off)
|
||||
yield* git(test.directory, ["switch", branch])
|
||||
|
||||
expect(yield* vcs.branch()).toBe(branch)
|
||||
expect(yield* awaitWithTimeout(Deferred.await(updated), "timed out waiting for branch update")).toBe(branch)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
yield* init(one).pipe(
|
||||
Effect.provide(
|
||||
ConfigProvider.layer(ConfigProvider.fromUnknown({ KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" })),
|
||||
),
|
||||
)
|
||||
expect(warmed.size).toBe(0)
|
||||
yield* init(one)
|
||||
yield* init(one)
|
||||
yield* init(two)
|
||||
yield* Effect.yieldNow
|
||||
expect(warmed).toEqual(
|
||||
new Map([
|
||||
[one, 1],
|
||||
[two, 1],
|
||||
]),
|
||||
)
|
||||
yield* Effect.promise(() => disposeInstance(one))
|
||||
expect(invalidated).toEqual([one])
|
||||
yield* Effect.promise(() => disposeInstance(two))
|
||||
expect(invalidated).toEqual([one, two])
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(Layer.mergeAll(AppNodeBuilder.build(CrossSpawnSpawner.node), TestConsole.layer, config)),
|
||||
),
|
||||
)
|
||||
},
|
||||
20_000,
|
||||
)
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LocationServiceMap, type LocationServices } from "@opencode-ai/core/location-services"
|
||||
import type { Location } from "@opencode-ai/core/location"
|
||||
import { afterEach, describe, expect, spyOn, test } from "bun:test"
|
||||
import { Effect, Layer, LayerMap, Schema, Stream } from "effect"
|
||||
import { Effect, Layer, Schema, Stream } from "effect"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { KiloIndexing } from "../../src/kilocode/indexing"
|
||||
import { KilocodeBootstrap } from "../../src/kilocode/bootstrap"
|
||||
import { KilocodeWatcher } from "../../src/kilocode/watcher"
|
||||
import { KiloSessions } from "../../src/kilo-sessions/kilo-sessions"
|
||||
import { KiloMemory } from "@kilocode/kilo-memory/effect"
|
||||
import { MemoryService } from "@kilocode/kilo-memory/effect/service"
|
||||
import { InstanceState } from "../../src/effect/instance-state"
|
||||
import { disposeInstance } from "../../src/effect/instance-registry"
|
||||
import { KiloToolRegistry } from "../../src/kilocode/tool/registry"
|
||||
import { Provider } from "../../src/provider/provider"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
@@ -21,7 +19,7 @@ import { Session } from "../../src/session/session"
|
||||
import { SessionSummary } from "../../src/session/summary"
|
||||
import { ToolRegistry } from "../../src/tool/registry"
|
||||
import type * as Tool from "../../src/tool/tool"
|
||||
import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../fixture/fixture"
|
||||
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
|
||||
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
@@ -456,8 +454,7 @@ describe("kilocode tool registry indexing", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("bootstraps sessions without indexing and preserves location cleanup", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
test("does not start indexing during session bootstrap", async () => {
|
||||
const platform = process.env["KILO_PLATFORM"]
|
||||
process.env["KILO_PLATFORM"] = "cli"
|
||||
const calls: string[] = []
|
||||
@@ -483,38 +480,22 @@ describe("kilocode tool registry indexing", () => {
|
||||
const session = Layer.succeed(Session.Service, {} as Session.Interface)
|
||||
const summary = Layer.succeed(SessionSummary.Service, {} as SessionSummary.Interface)
|
||||
const provider = Layer.succeed(Provider.Service, {} as Provider.Interface)
|
||||
const invalidated: string[] = []
|
||||
const locations = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
(_: Location.Ref): Layer.Layer<LocationServices> =>
|
||||
Layer.effectContext(Effect.die("Unexpected location warmup")),
|
||||
).pipe(
|
||||
Effect.map((map) => ({
|
||||
...map,
|
||||
invalidate: (ref: Location.Ref) => Effect.sync(() => void invalidated.push(String(ref.directory))),
|
||||
})),
|
||||
),
|
||||
)
|
||||
const watcher = Layer.succeed(KilocodeWatcher.Service, KilocodeWatcher.Service.of({ init: () => Effect.void }))
|
||||
const indexing = spyOn(KiloIndexing, "init").mockResolvedValue(undefined)
|
||||
|
||||
try {
|
||||
await Effect.runPromise(
|
||||
KilocodeBootstrap.Service.use((svc) => svc.init()).pipe(
|
||||
Effect.andThen(Effect.promise(() => disposeInstance(tmp.path))),
|
||||
Effect.provide(
|
||||
KilocodeBootstrap.layer.pipe(Layer.provide([sessions, bus, memory, session, summary, provider, locations])),
|
||||
KilocodeBootstrap.layer.pipe(Layer.provide([sessions, bus, memory, session, summary, provider, watcher])),
|
||||
),
|
||||
Effect.scoped,
|
||||
),
|
||||
)
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(calls).toEqual(["sessions"])
|
||||
expect(indexing).not.toHaveBeenCalled()
|
||||
expect(invalidated).toEqual([tmp.path])
|
||||
await disposeInstance(tmp.path)
|
||||
expect(invalidated).toEqual([tmp.path])
|
||||
} finally {
|
||||
if (platform === undefined) delete process.env["KILO_PLATFORM"]
|
||||
else process.env["KILO_PLATFORM"] = platform
|
||||
|
||||
Reference in New Issue
Block a user