From db7837814c00603d542422bf45ee69e3c687c1fb Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 25 Aug 2026 16:13:10 -0400 Subject: [PATCH] fix(project): refresh renamed projects across live clients (#45043) --- .../client/src/promise/generated/types.ts | 19 +++++ packages/client/src/solid/data.ts | 3 + packages/client/test/solid-data.test.ts | 84 ++++++++++++++++++- packages/core/src/project.ts | 10 +++ packages/core/test/project.test.ts | 66 ++++++++++++++- packages/schema/src/event-manifest.ts | 2 +- packages/schema/test/event-manifest.test.ts | 1 + 7 files changed, 181 insertions(+), 4 deletions(-) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index ea8a203fb71..2767da8b7c3 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1408,6 +1408,24 @@ export type Project = { sandboxes: Array } +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 + } +} + export type FormAnswer = { [x: string]: FormValue } export type PermissionRequest = { @@ -2167,6 +2185,7 @@ export type V2Event = | PermissionReplied | PluginAdded | PluginUpdated + | ProjectUpdated | WorktreeUpdated | WorktreeResolved | CommandUpdated diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index 2c3f35dc820..ef634104e70 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -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) diff --git a/packages/client/test/solid-data.test.ts b/packages/client/test/solid-data.test.ts index 5f41d92c071..267741f736f 100644 --- a/packages/client/test/solid-data.test.ts +++ b/packages/client/test/solid-data.test.ts @@ -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[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() const api = OpenCode.make({ diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 185d2491908..bde093f056e 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -96,7 +96,17 @@ const layer = Layer.effect( const announcing = new Set() 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 }, diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index 8a327db241b..814d6560400 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -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( diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index c8181e1c99b..574f2297383 100644 --- a/packages/schema/src/event-manifest.ts +++ b/packages/schema/src/event-manifest.ts @@ -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, diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 2d9a06e6ffc..47a731e01ed 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -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)