mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 11:39:41 +03:00
fix(project): refresh renamed projects across live clients (#45043)
This commit is contained in:
committed by
GitHub
Unverified
parent
543a4f4912
commit
db7837814c
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user