fix(project): refresh renamed projects across live clients (#45043)

This commit is contained in:
Kit Langton
2026-08-25 16:13:10 -04:00
committed by GitHub
Unverified
parent 543a4f4912
commit db7837814c
7 changed files with 181 additions and 4 deletions
@@ -1408,6 +1408,24 @@ export type Project = {
sandboxes: Array<string>
}
export type ProjectUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "project.updated"
location?: LocationRef
data: {
id: string
canonical: string
vcs?: ProjectVcs
name?: string
icon?: ProjectIcon
commands?: ProjectCommands
time: ProjectTime
sandboxes: Array<string>
}
}
export type FormAnswer = { [x: string]: FormValue }
export type PermissionRequest = {
@@ -2167,6 +2185,7 @@ export type V2Event =
| PermissionReplied
| PluginAdded
| PluginUpdated
| ProjectUpdated
| WorktreeUpdated
| WorktreeResolved
| CommandUpdated
+3
View File
@@ -521,6 +521,9 @@ export function createData(config: CreateDataInput) {
void result.location.vcs.sync().catch((error) => console.error("Failed to preload VCS info", error))
void result.project.sync().catch((error) => console.error("Failed to preload projects", error))
return
case "project.updated":
setStore("project", "info", event.data.id, reconcile(event.data))
return
case "session.created":
sessionOutbox.delete(event.data.sessionID)
result.session.invalidate(event.data.sessionID)
+83 -1
View File
@@ -1,7 +1,7 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
import { OpenCode, type OpenCodeEvent, type Project, type SessionInfo } from "../src/promise"
const session = (viewed: number): SessionInfo => ({
id: "ses_refresh",
@@ -72,6 +72,88 @@ test("revalidates after an event overtakes an active session read", async () =>
}
})
test("updates authoritative cached project metadata from live events", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const original: Project = {
id: "project_renamed",
canonical: "/projects/original",
name: "Original custom name",
time: { created: 1, updated: 1 },
sandboxes: [],
}
const unrelated: Project = {
id: "project_unrelated",
canonical: "/projects/unrelated",
name: "Unrelated project",
time: { created: 1, updated: 1 },
sandboxes: [],
}
let requests = 0
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
if (!request.url.endsWith("/api/project")) throw new Error(`Unexpected request: ${request.url}`)
requests++
return Response.json([original, unrelated])
},
})
const event: CreateDataInput["event"] = {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
}
const setup = createRoot((dispose) => ({
data: createData({ api: () => api, directory: "/projects/original", event }),
dispose,
}))
try {
await setup.data.project.sync()
expect(setup.data.project.get(original.id)).toEqual(original)
const updated: OpenCodeEvent = {
id: "evt_project_renamed",
created: 2,
type: "project.updated",
data: {
...original,
canonical: "/projects/renamed",
name: "Updated custom name",
time: { ...original.time, updated: 2 },
},
}
listeners.forEach((listener) => listener({ name: updated.type, details: updated }))
expect(setup.data.project.get(original.id)?.canonical).toBe("/projects/renamed")
expect(setup.data.project.get(original.id)?.name).toBe("Updated custom name")
expect(setup.data.project.get(unrelated.id)).toEqual(unrelated)
expect(requests).toBe(1)
const reset: OpenCodeEvent = {
id: "evt_project_name_reset",
created: 3,
type: "project.updated",
data: {
id: original.id,
canonical: "/projects/renamed-again",
time: { ...original.time, updated: 3 },
sandboxes: [],
},
}
listeners.forEach((listener) => listener({ name: reset.type, details: reset }))
expect(setup.data.project.get(original.id)?.canonical).toBe("/projects/renamed-again")
expect(setup.data.project.get(original.id)?.name).toBeUndefined()
expect(setup.data.project.get(unrelated.id)).toEqual(unrelated)
expect(requests).toBe(1)
} finally {
setup.dispose()
}
})
test("reports optimistic sessions as creating until the request settles", async () => {
const release = Promise.withResolvers<void>()
const api = OpenCode.make({
+10
View File
@@ -96,7 +96,17 @@ const layer = Layer.effect(
const announcing = new Set<string>()
const persist = Effect.fnUntraced(function* (project: Resolved) {
const previous = yield* db
.select({ canonical: ProjectTable.worktree })
.from(ProjectTable)
.where(eq(ProjectTable.id, project.id))
.get()
.pipe(Effect.orDie)
yield* upsertProject(db, project).pipe(Effect.orDie)
if (previous && previous.canonical !== project.canonical) {
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, project.id)).get().pipe(Effect.orDie)
if (row) yield* bus.publish(ProjectSchema.Event.Updated, fromRow(row))
}
if (!project.vcs) return project
const directories: Array<{ projectID: ID; directory: AbsolutePath; strategy?: string }> = [
{ projectID: project.id, directory: project.canonical },
+64 -2
View File
@@ -2,17 +2,20 @@ import { describe, expect } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { Effect, Layer } from "effect"
import { Effect, Stream } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { Project } from "@opencode-ai/core/project"
import { ProjectSchema } from "@opencode-ai/core/project/schema"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Hash } from "@opencode-ai/util/hash"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.merge(AppNodeBuilder.build(Project.node), AppNodeBuilder.build(Database.node)))
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Project.node, Database.node, Bus.node])))
describe("Project.list", () => {
it.effect("returns complete projects ordered by recent update", () =>
@@ -160,6 +163,65 @@ describe("Project.resolve", () => {
}),
)
it.live("does not publish project updates for first or repeated resolutions", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
const project = yield* Project.Service
const bus = yield* Bus.Service
const updates: Project.Info[] = []
yield* bus.subscribe(ProjectSchema.Event.Updated).pipe(
Stream.runForEach((event) => Effect.sync(() => updates.push(event.data))),
Effect.forkScoped({ startImmediately: true }),
)
yield* project.resolve(abs(tmp.path))
yield* project.resolve(abs(tmp.path))
yield* Effect.yieldNow
expect(updates).toEqual([])
}),
)
it.live("publishes preserved project metadata when its canonical directory is renamed", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const before = path.join(tmp.path, "before")
const after = path.join(tmp.path, "after")
yield* Effect.promise(() => fs.mkdir(before))
yield* Effect.promise(() => initRepo(before, { commit: true, remote: "git@github.com:owner/repo.git" }))
const project = yield* Project.Service
const bus = yield* Bus.Service
const initial = yield* project.resolve(abs(before))
yield* project.update({ projectID: initial.id, name: "Preserved name" })
const updates: Project.Info[] = []
yield* bus.subscribe(ProjectSchema.Event.Updated).pipe(
Stream.runForEach((event) => Effect.sync(() => updates.push(event.data))),
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.promise(() => fs.rename(before, after))
const renamed = yield* project.resolve(abs(after))
yield* Effect.yieldNow
expect(renamed.id).toBe(initial.id)
expect(renamed.canonical).toBe(yield* real(after))
expect(updates).toHaveLength(1)
expect(updates).toEqual((yield* project.list()).filter((item) => item.id === initial.id))
expect(updates[0]).toMatchObject({
id: initial.id,
canonical: yield* real(after),
name: "Preserved name",
})
}),
)
it.live("returns git global for repo with no commits and no remote", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
+1 -1
View File
@@ -50,6 +50,7 @@ const featureDefinitions = Event.inventory(
...Reference.Event.Definitions,
...Permission.Event.Definitions,
...Plugin.Event.Definitions,
...Project.Event.Definitions,
...Worktree.Event.Definitions,
...Command.Event.Definitions,
...Config.Event.Definitions,
@@ -85,7 +86,6 @@ export const Definitions = Event.inventory(
...McpEvent.Definitions,
...LegacyEventV1.Definitions,
...FileSystemV1.Event.Definitions,
...Project.Event.Definitions,
...SessionStatusEvent.Definitions,
...SessionCompactionEvent.Definitions,
...VcsEvent.Definitions,
@@ -41,6 +41,7 @@ describe("public event manifest", () => {
expect(EventManifest.Server.get("mcp.resources.changed")).toBe(McpEvent.ResourcesChanged)
expect(EventManifest.Server.get("session.created")).toBe(SessionEvent.Created)
expect(EventManifest.Server.get("session.deleted")).toBe(SessionEvent.Deleted)
expect(EventManifest.Server.get("project.updated")).toBe(Project.Event.Updated)
expect(EventManifest.Server.has("mcp.tools.changed")).toBe(false)
expect(EventManifest.Server.has("question.asked")).toBe(false)
expect(EventManifest.Server.has("question.replied")).toBe(false)