diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 263e0500ed..5117957625 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -1,10 +1,11 @@ -import { App } from "../app/app" import { Config } from "../config/config" import z from "zod" import { Provider } from "../provider/provider" import { generateObject, type ModelMessage } from "ai" import PROMPT_GENERATE from "./generate.txt" import { SystemPrompt } from "../session/system" +import { State } from "../project/state" +import { Session } from "../session" export namespace Agent { export const Info = z @@ -24,46 +25,49 @@ export namespace Agent { ref: "Agent", }) export type Info = z.infer - const state = App.state("agent", async () => { - const cfg = await Config.get() - const result: Record = { - general: { - name: "general", - description: - "General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.", - tools: { - todoread: false, - todowrite: false, - }, - }, - } - for (const [key, value] of Object.entries(cfg.agent ?? {})) { - if (value.disable) { - delete result[key] - continue - } - let item = result[key] - if (!item) - item = result[key] = { - name: key, - description: "", + const state = State.create( + () => Session.use().directory, + async () => { + const cfg = await Config.get() + const result: Record = { + general: { + name: "general", + description: + "General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.", tools: { - todowrite: false, todoread: false, + todowrite: false, }, + }, + } + for (const [key, value] of Object.entries(cfg.agent ?? {})) { + if (value.disable) { + delete result[key] + continue } - const model = value.model ?? cfg.model - if (model) item.model = Provider.parseModel(model) - if (value.prompt) item.prompt = value.prompt - if (value.tools) - item.tools = { - ...item.tools, - ...value.tools, - } - if (value.description) item.description = value.description - } - return result - }) + let item = result[key] + if (!item) + item = result[key] = { + name: key, + description: "", + tools: { + todowrite: false, + todoread: false, + }, + } + const model = value.model ?? cfg.model + if (model) item.model = Provider.parseModel(model) + if (value.prompt) item.prompt = value.prompt + if (value.tools) + item.tools = { + ...item.tools, + ...value.tools, + } + if (value.description) item.description = value.description + } + return result + }, + ) export async function get(agent: string) { return state().then((x) => x[agent]) diff --git a/packages/opencode/src/app/app.ts b/packages/opencode/src/app/app.ts index fc7f49cb95..504d726cc9 100644 --- a/packages/opencode/src/app/app.ts +++ b/packages/opencode/src/app/app.ts @@ -6,6 +6,8 @@ import { Global } from "../global" import path from "path" import os from "os" import { z } from "zod" +import { Project } from "../project/project" +import { Worktree } from "../project/worktree" export namespace App { const log = Log.create({ service: "app" }) @@ -87,17 +89,30 @@ export namespace App { info, } + const projects = await Project.list() + const project = projects + .toSorted((a, b) => a.worktree.length - b.worktree.length) + .find((x) => Filesystem.contains(x.worktree, input.cwd)) + + if (!project) { + throw new Error("No project found") + } + return ctx.provide(app, async () => { - try { - const result = await cb(app.info) - return result - } finally { - for (const [key, entry] of app.services.entries()) { - if (!entry.shutdown) continue - log.info("shutdown", { name: key }) - await entry.shutdown?.(await entry.state) - } - } + return Project.provide(project, async () => { + return Worktree.provide(project.worktree, async () => { + try { + const result = await cb(app.info) + return result + } finally { + for (const [key, entry] of app.services.entries()) { + if (!entry.shutdown) continue + log.info("shutdown", { name: key }) + await entry.shutdown?.(await entry.state) + } + } + }) + }) }) } diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts index 0353da9046..c13649aff4 100644 --- a/packages/opencode/src/bus/index.ts +++ b/packages/opencode/src/bus/index.ts @@ -37,6 +37,10 @@ export namespace Bus { .object({ type: z.literal(type), properties: def.properties, + context: z.object({ + projectID: z.string().optional(), + sessionID: z.string().optional(), + }), }) .openapi({ ref: "Event" + "." + def.type, diff --git a/packages/opencode/src/cli/cmd/debug/scrap.ts b/packages/opencode/src/cli/cmd/debug/scrap.ts index 3ba53e3f43..9ab3bb2ff3 100644 --- a/packages/opencode/src/cli/cmd/debug/scrap.ts +++ b/packages/opencode/src/cli/cmd/debug/scrap.ts @@ -1,7 +1,14 @@ +import { Project } from "../../../project/project" +import { Log } from "../../../util/log" import { cmd } from "../cmd" export const ScrapCommand = cmd({ command: "scrap", builder: (yargs) => yargs, - async handler() {}, + async handler() { + const timer = Log.Default.time("scrap") + const list = await Project.list() + console.log(list) + timer.stop() + }, }) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 3a9ab97237..91f5236b7e 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -2,7 +2,6 @@ import { Log } from "../util/log" import path from "path" import os from "os" import { z } from "zod" -import { App } from "../app/app" import { Filesystem } from "../util/filesystem" import { ModelsDev } from "../provider/models" import { mergeDeep, pipe } from "remeda" @@ -14,110 +13,115 @@ import matter from "gray-matter" import { Flag } from "../flag/flag" import { Auth } from "../auth" import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser" +import { State } from "../project/state" +import { Project } from "../project/project" export namespace Config { const log = Log.create({ service: "config" }) - export const state = App.state("config", async (app) => { - const auth = await Auth.all() - let result = await global() - for (const file of ["opencode.jsonc", "opencode.json"]) { - const found = await Filesystem.findUp(file, app.path.cwd, app.path.root) - for (const resolved of found.toReversed()) { - result = mergeDeep(result, await loadFile(resolved)) + export const state = State.create( + () => Project.use().worktree, + async () => { + const auth = await Auth.all() + let result = await global() + for (const file of ["opencode.jsonc", "opencode.json"]) { + const found = await Filesystem.findUp(file, app.path.cwd, app.path.root) + for (const resolved of found.toReversed()) { + result = mergeDeep(result, await loadFile(resolved)) + } } - } - // Override with custom config if provided - if (Flag.OPENCODE_CONFIG) { - result = mergeDeep(result, await loadFile(Flag.OPENCODE_CONFIG)) - log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) - } - - for (const [key, value] of Object.entries(auth)) { - if (value.type === "wellknown") { - process.env[value.key] = value.token - const wellknown = await fetch(`${key}/.well-known/opencode`).then((x) => x.json()) - result = mergeDeep(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd())) + // Override with custom config if provided + if (Flag.OPENCODE_CONFIG) { + result = mergeDeep(result, await loadFile(Flag.OPENCODE_CONFIG)) + log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) } - } - result.agent = result.agent || {} - const markdownAgents = [ - ...(await Filesystem.globUp("agent/*.md", Global.Path.config, Global.Path.config)), - ...(await Filesystem.globUp(".opencode/agent/*.md", app.path.cwd, app.path.root)), - ] - for (const item of markdownAgents) { - const content = await Bun.file(item).text() - const md = matter(content) - if (!md.data) continue - - const config = { - name: path.basename(item, ".md"), - ...md.data, - prompt: md.content.trim(), + for (const [key, value] of Object.entries(auth)) { + if (value.type === "wellknown") { + process.env[value.key] = value.token + const wellknown = await fetch(`${key}/.well-known/opencode`).then((x) => x.json()) + result = mergeDeep(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd())) + } } - const parsed = Agent.safeParse(config) - if (parsed.success) { - result.agent = mergeDeep(result.agent, { - [config.name]: parsed.data, - }) - continue + + result.agent = result.agent || {} + const markdownAgents = [ + ...(await Filesystem.globUp("agent/*.md", Global.Path.config, Global.Path.config)), + ...(await Filesystem.globUp(".opencode/agent/*.md", app.path.cwd, app.path.root)), + ] + for (const item of markdownAgents) { + const content = await Bun.file(item).text() + const md = matter(content) + if (!md.data) continue + + const config = { + name: path.basename(item, ".md"), + ...md.data, + prompt: md.content.trim(), + } + const parsed = Agent.safeParse(config) + if (parsed.success) { + result.agent = mergeDeep(result.agent, { + [config.name]: parsed.data, + }) + continue + } + throw new InvalidError({ path: item }, { cause: parsed.error }) } - throw new InvalidError({ path: item }, { cause: parsed.error }) - } - // Load mode markdown files - result.mode = result.mode || {} - const markdownModes = [ - ...(await Filesystem.globUp("mode/*.md", Global.Path.config, Global.Path.config)), - ...(await Filesystem.globUp(".opencode/mode/*.md", app.path.cwd, app.path.root)), - ] - for (const item of markdownModes) { - const content = await Bun.file(item).text() - const md = matter(content) - if (!md.data) continue + // Load mode markdown files + result.mode = result.mode || {} + const markdownModes = [ + ...(await Filesystem.globUp("mode/*.md", Global.Path.config, Global.Path.config)), + ...(await Filesystem.globUp(".opencode/mode/*.md", app.path.cwd, app.path.root)), + ] + for (const item of markdownModes) { + const content = await Bun.file(item).text() + const md = matter(content) + if (!md.data) continue - const config = { - name: path.basename(item, ".md"), - ...md.data, - prompt: md.content.trim(), + const config = { + name: path.basename(item, ".md"), + ...md.data, + prompt: md.content.trim(), + } + const parsed = Mode.safeParse(config) + if (parsed.success) { + result.mode = mergeDeep(result.mode, { + [config.name]: parsed.data, + }) + continue + } + throw new InvalidError({ path: item }, { cause: parsed.error }) } - const parsed = Mode.safeParse(config) - if (parsed.success) { - result.mode = mergeDeep(result.mode, { - [config.name]: parsed.data, - }) - continue + + result.plugin = result.plugin || [] + result.plugin.push( + ...[ + ...(await Filesystem.globUp("plugin/*.ts", Global.Path.config, Global.Path.config)), + ...(await Filesystem.globUp(".opencode/plugin/*.ts", app.path.cwd, app.path.root)), + ].map((x) => "file://" + x), + ) + + // Handle migration from autoshare to share field + if (result.autoshare === true && !result.share) { + result.share = "auto" + } + if (result.keybinds?.messages_revert && !result.keybinds.messages_undo) { + result.keybinds.messages_undo = result.keybinds.messages_revert } - throw new InvalidError({ path: item }, { cause: parsed.error }) - } - result.plugin = result.plugin || [] - result.plugin.push( - ...[ - ...(await Filesystem.globUp("plugin/*.ts", Global.Path.config, Global.Path.config)), - ...(await Filesystem.globUp(".opencode/plugin/*.ts", app.path.cwd, app.path.root)), - ].map((x) => "file://" + x), - ) + if (!result.username) { + const os = await import("os") + result.username = os.userInfo().username + } - // Handle migration from autoshare to share field - if (result.autoshare === true && !result.share) { - result.share = "auto" - } - if (result.keybinds?.messages_revert && !result.keybinds.messages_undo) { - result.keybinds.messages_undo = result.keybinds.messages_revert - } + log.info("loaded", result) - if (!result.username) { - const os = await import("os") - result.username = os.userInfo().username - } - - log.info("loaded", result) - - return result - }) + return result + }, + ) export const McpLocal = z .object({ diff --git a/packages/opencode/src/config/hooks.ts b/packages/opencode/src/config/hooks.ts index 8772c9c6c8..acb7f11171 100644 --- a/packages/opencode/src/config/hooks.ts +++ b/packages/opencode/src/config/hooks.ts @@ -11,7 +11,6 @@ export namespace ConfigHooks { export function init() { log.info("init") - const app = App.info() Bus.subscribe(File.Event.Edited, async (payload) => { const cfg = await Config.get() diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index fca80a3875..e07004e20d 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -6,6 +6,8 @@ import { LSPServer } from "./server" import { z } from "zod" import { Config } from "../config/config" import { spawn } from "child_process" +import { State } from "../project/state" +import { Worktree } from "../project/worktree" export namespace LSP { const log = Log.create({ service: "lsp" }) @@ -53,8 +55,8 @@ export namespace LSP { }) export type DocumentSymbol = z.infer - const state = App.state( - "lsp", + const state = State.create( + () => Worktree.use(), async () => { const clients: LSPClient.Info[] = [] const servers: Record = LSPServer diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts new file mode 100644 index 0000000000..b3cf55eca3 --- /dev/null +++ b/packages/opencode/src/project/project.ts @@ -0,0 +1,67 @@ +import z from "zod" +import { lazy } from "../util/lazy" +import { Filesystem } from "../util/filesystem" +import path from "path" +import { $ } from "bun" +import { StorageNext } from "../storage/storage-next" +import { Context } from "../util/context" + +export namespace Project { + export const Info = z.object({ + id: z.string(), + worktree: z.string(), + vcs: z.literal("git").optional(), + }) + export type Info = z.infer + + const context = Context.create("project") + + export const use = context.use + export const provide = context.provide + + const init = lazy(async () => { + const cwd = process.cwd() + const matches = Filesystem.up({ targets: [".git"], start: cwd }) + const git = await matches.next().then((x) => x.value) + await matches.return() + if (!git) { + await StorageNext.write(["project", "global"], { + id: "global", + worktree: "/", + }) + return + } + let worktree = path.dirname(git) + const [id] = await $`git rev-list --max-parents=0 --all` + .quiet() + .nothrow() + .cwd(worktree) + .text() + .then((x) => + x + .split("\n") + .filter(Boolean) + .map((x) => x.trim()) + .toSorted(), + ) + worktree = path.dirname( + await $`git rev-parse --path-format=absolute --git-common-dir` + .quiet() + .nothrow() + .cwd(worktree) + .text() + .then((x) => x.trim()), + ) + await StorageNext.write(["project", id], { + id, + worktree, + vcs: "git", + }) + }) + + export async function list() { + await init() + const keys = await StorageNext.list(["project"]) + return await Promise.all(keys.map((x) => StorageNext.read(x))) + } +} diff --git a/packages/opencode/src/project/state.ts b/packages/opencode/src/project/state.ts new file mode 100644 index 0000000000..ad38136332 --- /dev/null +++ b/packages/opencode/src/project/state.ts @@ -0,0 +1,29 @@ +export namespace State { + interface Entry { + state: any + dispose?: (state: any) => Promise + } + + const entries = new Map() + + export function create(root: () => string, init: () => S, dispose?: (state: Awaited) => Promise) { + return () => { + const key = root() + const exists = entries.get(key) + if (exists) return exists.state as S + const state = init() + entries.set(key, { + state, + dispose, + }) + return state + } + } + + export async function dispose() { + for (const [key, entry] of entries.entries()) { + if (!entry.dispose) continue + await entry.dispose(await entry.state) + } + } +} diff --git a/packages/opencode/src/project/worktree.ts b/packages/opencode/src/project/worktree.ts new file mode 100644 index 0000000000..d1ac14586f --- /dev/null +++ b/packages/opencode/src/project/worktree.ts @@ -0,0 +1,7 @@ +import { Context } from "../util/context" + +export namespace Worktree { + const context = Context.create("worktree") + export const use = context.use + export const provide = context.provide +} diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index c4d81a8c37..6c68e96511 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -1,4 +1,3 @@ -import path from "path" import { Decimal } from "decimal.js" import { z, ZodSchema } from "zod" import { @@ -30,7 +29,7 @@ import { ProviderTransform } from "../provider/transform" import type { ModelsDev } from "../provider/models" import { Share } from "../share/share" import { Snapshot } from "../snapshot" -import { Storage } from "../storage/storage" +import { StorageNext } from "../storage/storage-next" import { Log } from "../util/log" import { NamedError } from "../util/error" import { SystemPrompt } from "./system" @@ -42,10 +41,17 @@ import { ReadTool } from "../tool/read" import { mergeDeep, pipe, splitWhen } from "remeda" import { ToolRegistry } from "../tool/registry" import { Plugin } from "../plugin" +import { Project } from "../project/project" +import { Context } from "../util/context" export namespace Session { const log = Log.create({ service: "session" }) + const context = Context.create("session") + + export const use = context.use + export const provide = context.provide + const OUTPUT_TOKEN_MAX = 32_000 const parentSessionTitlePrefix = "New session - " @@ -62,6 +68,9 @@ export namespace Session { export const Info = z .object({ id: Identifier.schema("session"), + projectID: z.string(), + directory: z.string(), + worktree: z.string(), parentID: Identifier.schema("session").optional(), share: z .object({ @@ -129,8 +138,6 @@ export namespace Session { const state = App.state( "session", () => { - const sessions = new Map() - const messages = new Map() const pending = new Map() const autoCompacting = new Map() const queued = new Map< @@ -145,8 +152,6 @@ export namespace Session { >() return { - sessions, - messages, pending, autoCompacting, queued, @@ -160,19 +165,30 @@ export namespace Session { ) export async function create(parentID?: string) { - const result: Info = { - id: Identifier.descending("session"), - version: Installation.VERSION, + const app = App.info() + return createNext({ parentID, - title: createDefaultTitle(!!parentID), + directory: app.path.cwd, + }) + } + + export async function createNext(input: { id?: string; parentID?: string; directory: string }) { + const project = Project.use() + const result: Info = { + id: Identifier.descending("session", input.id), + version: Installation.VERSION, + projectID: project.id, + worktree: project.worktree, + directory: input.directory, + parentID: input.parentID, + title: createDefaultTitle(!!input.parentID), time: { created: Date.now(), updated: Date.now(), }, } log.info("created", result) - state().sessions.set(result.id, result) - await Storage.writeJSON("session/info/" + result.id, result) + await StorageNext.write(["session", project.id, result.id], result) const cfg = await Config.get() if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto")) share(result.id) @@ -191,17 +207,13 @@ export namespace Session { } export async function get(id: string) { - const result = state().sessions.get(id) - if (result) { - return result - } - const read = await Storage.readJSON("session/info/" + id) - state().sessions.set(id, read) + const project = Project.use() + const read = await StorageNext.read(["session", project.id, id]) return read as Info } export async function getShare(id: string) { - return Storage.readJSON("session/share/" + id) + return StorageNext.read(["share", id]) } export async function share(id: string) { @@ -218,7 +230,7 @@ export namespace Session { url: share.url, } }) - await Storage.writeJSON("session/share/" + id, share) + await StorageNext.write(["share", id], share) await Share.sync("session/info/" + id, session) for (const msg of await messages(id)) { await Share.sync("session/message/" + id + "/" + msg.info.id, msg.info) @@ -232,7 +244,7 @@ export namespace Session { export async function unshare(id: string) { const share = await getShare(id) if (!share) return - await Storage.remove("session/share/" + id) + await StorageNext.remove(["share", id]) await update(id, (draft) => { draft.share = undefined }) @@ -240,17 +252,15 @@ export namespace Session { } export async function update(id: string, editor: (session: Info) => void) { - const { sessions } = state() - const session = await get(id) - if (!session) return - editor(session) - session.time.updated = Date.now() - sessions.set(id, session) - await Storage.writeJSON("session/info/" + id, session) - Bus.publish(Event.Updated, { - info: session, + const project = Project.use() + const result = await StorageNext.update(["session", project.id, id], (draft) => { + editor(draft) + draft.time.updated = Date.now() }) - return session + Bus.publish(Event.Updated, { + info: result, + }) + return result } export async function messages(sessionID: string) { @@ -258,11 +268,11 @@ export namespace Session { info: MessageV2.Info parts: MessageV2.Part[] }[] - for (const p of await Storage.list("session/message/" + sessionID)) { - const read = await Storage.readJSON(p) + for (const p of await StorageNext.list(["message", sessionID])) { + const read = await StorageNext.read(p) result.push({ info: read, - parts: await getParts(sessionID, read.id), + parts: await getParts(read.id), }) } result.sort((a, b) => (a.info.id > b.info.id ? 1 : -1)) @@ -271,15 +281,15 @@ export namespace Session { export async function getMessage(sessionID: string, messageID: string) { return { - info: await Storage.readJSON("session/message/" + sessionID + "/" + messageID), - parts: await getParts(sessionID, messageID), + info: await StorageNext.read(["message", sessionID, messageID]), + parts: await getParts(messageID), } } - export async function getParts(sessionID: string, messageID: string) { + export async function getParts(messageID: string) { const result = [] as MessageV2.Part[] - for (const item of await Storage.list("session/part/" + sessionID + "/" + messageID)) { - const read = await Storage.readJSON(item) + for (const item of await StorageNext.list(["part", messageID])) { + const read = await StorageNext.read(item) result.push(read) } result.sort((a, b) => (a.id > b.id ? 1 : -1)) @@ -287,17 +297,17 @@ export namespace Session { } export async function* list() { - for (const item of await Storage.list("session/info")) { - const sessionID = path.basename(item, ".json") - yield get(sessionID) + const project = Project.use() + for (const item of await StorageNext.list(["session", project.id])) { + yield StorageNext.read(item) } } export async function children(parentID: string) { + const project = Project.use() const result = [] as Session.Info[] - for (const item of await Storage.list("session/info")) { - const sessionID = path.basename(item, ".json") - const session = await get(sessionID) + for (const item of await StorageNext.list(["session", project.id])) { + const session = await StorageNext.read(item) if (session.parentID !== parentID) continue result.push(session) } @@ -316,6 +326,7 @@ export namespace Session { } export async function remove(sessionID: string, emitEvent = true) { + const project = Project.use() try { abort(sessionID) const session = await get(sessionID) @@ -323,10 +334,13 @@ export namespace Session { await remove(child.id, false) } await unshare(sessionID).catch(() => {}) - await Storage.remove(`session/info/${sessionID}`).catch(() => {}) - await Storage.removeDir(`session/message/${sessionID}/`).catch(() => {}) - state().sessions.delete(sessionID) - state().messages.delete(sessionID) + for (const msg of await StorageNext.list(["message", sessionID])) { + for (const part of await StorageNext.list(["part", msg.at(-1)!])) { + await StorageNext.remove(part) + } + await StorageNext.remove(msg) + } + await StorageNext.remove(["session", project.id, sessionID]) if (emitEvent) { Bus.publish(Event.Deleted, { info: session, @@ -338,14 +352,14 @@ export namespace Session { } async function updateMessage(msg: MessageV2.Info) { - await Storage.writeJSON("session/message/" + msg.sessionID + "/" + msg.id, msg) + await StorageNext.write(["message", msg.sessionID, msg.id], msg) Bus.publish(MessageV2.Event.Updated, { info: msg, }) } async function updatePart(part: MessageV2.Part) { - await Storage.writeJSON(["session", "part", part.sessionID, part.messageID, part.id].join("/"), part) + await StorageNext.write(["part", part.messageID, part.id], part) Bus.publish(MessageV2.Event.PartUpdated, { part, }) @@ -403,7 +417,7 @@ export namespace Session { const [preserve, remove] = splitWhen(msgs, (x) => x.info.id === messageID) msgs = preserve for (const msg of remove) { - await Storage.remove(`session/message/${input.sessionID}/${msg.info.id}`) + await StorageNext.remove(["message", input.sessionID, msg.info.id]) await Bus.publish(MessageV2.Event.Removed, { sessionID: input.sessionID, messageID: msg.info.id }) } const last = preserve.at(-1) @@ -412,7 +426,7 @@ export namespace Session { const [preserveParts, removeParts] = splitWhen(last.parts, (x) => x.id === partID) last.parts = preserveParts for (const part of removeParts) { - await Storage.remove(`session/part/${input.sessionID}/${last.info.id}/${part.id}`) + await StorageNext.remove(["part", last.info.id, part.id]) await Bus.publish(MessageV2.Event.PartRemoved, { sessionID: input.sessionID, messageID: last.info.id, @@ -1164,7 +1178,7 @@ export namespace Session { error: assistantMsg.error, }) } - const p = await getParts(assistantMsg.sessionID, assistantMsg.id) + const p = await getParts(assistantMsg.id) for (const part of p) { if (part.type === "tool" && part.state.status !== "completed" && part.state.status !== "error") { updatePart({ diff --git a/packages/opencode/src/storage/storage-next.ts b/packages/opencode/src/storage/storage-next.ts new file mode 100644 index 0000000000..7992191a89 --- /dev/null +++ b/packages/opencode/src/storage/storage-next.ts @@ -0,0 +1,78 @@ +import { Log } from "../util/log" +import path from "path" +import fs from "fs/promises" +import { Global } from "../global" +import { lazy } from "../util/lazy" +import { Lock } from "../util/lock" + +export namespace StorageNext { + const log = Log.create({ service: "storage" }) + + type Migration = (dir: string) => Promise + + const MIGRATIONS: Migration[] = [] + + const state = lazy(async () => { + const dir = path.join(Global.Path.data, "storage") + const migration = await Bun.file(path.join(dir, "migration")) + .json() + .then((x) => parseInt(x)) + .catch(() => 0) + for (let index = migration; index < MIGRATIONS.length; index++) { + log.info("running migration", { index }) + const migration = MIGRATIONS[index] + await migration(dir) + await Bun.write(path.join(dir, "migration"), (index + 1).toString()) + } + return { + dir, + } + }) + + export async function remove(key: string[]) { + const dir = await state().then((x) => x.dir) + const target = path.join(dir, ...key) + ".json" + await fs.unlink(target).catch(() => {}) + } + + export async function read(key: string[]) { + const dir = await state().then((x) => x.dir) + const target = path.join(dir, ...key) + ".json" + using _ = await Lock.read(target) + return Bun.file(target).json() as Promise + } + + export async function update(key: string[], fn: (draft: T) => void) { + const dir = await state().then((x) => x.dir) + const target = path.join(dir, ...key) + ".json" + using _ = await Lock.write("storage") + const content = await Bun.file(target).json() + fn(content) + await Bun.write(target, JSON.stringify(content, null, 2)) + return content as T + } + + export async function write(key: string[], content: T) { + const dir = await state().then((x) => x.dir) + const target = path.join(dir, ...key) + ".json" + using _ = await Lock.write("storage") + await Bun.write(target, JSON.stringify(content, null, 2)) + } + + const glob = new Bun.Glob("**/*") + export async function list(prefix: string[]) { + const dir = await state().then((x) => x.dir) + try { + const result = await Array.fromAsync( + glob.scan({ + cwd: path.join(dir, ...prefix), + onlyFiles: true, + }), + ).then((results) => results.map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)])) + result.sort() + return result + } catch { + return [] + } + } +} diff --git a/packages/opencode/src/util/lock.ts b/packages/opencode/src/util/lock.ts new file mode 100644 index 0000000000..3aea64394f --- /dev/null +++ b/packages/opencode/src/util/lock.ts @@ -0,0 +1,98 @@ +export namespace Lock { + const locks = new Map< + string, + { + readers: number + writer: boolean + waitingReaders: (() => void)[] + waitingWriters: (() => void)[] + } + >() + + function get(key: string) { + if (!locks.has(key)) { + locks.set(key, { + readers: 0, + writer: false, + waitingReaders: [], + waitingWriters: [], + }) + } + return locks.get(key)! + } + + function process(key: string) { + const lock = locks.get(key) + if (!lock || lock.writer || lock.readers > 0) return + + // Prioritize writers to prevent starvation + if (lock.waitingWriters.length > 0) { + const nextWriter = lock.waitingWriters.shift()! + nextWriter() + return + } + + // Wake up all waiting readers + while (lock.waitingReaders.length > 0) { + const nextReader = lock.waitingReaders.shift()! + nextReader() + } + + // Clean up empty locks + if (lock.readers === 0 && !lock.writer && lock.waitingReaders.length === 0 && lock.waitingWriters.length === 0) { + locks.delete(key) + } + } + + export async function read(key: string): Promise { + const lock = get(key) + + return new Promise((resolve) => { + if (!lock.writer && lock.waitingWriters.length === 0) { + lock.readers++ + resolve({ + [Symbol.dispose]: () => { + lock.readers-- + process(key) + }, + }) + } else { + lock.waitingReaders.push(() => { + lock.readers++ + resolve({ + [Symbol.dispose]: () => { + lock.readers-- + process(key) + }, + }) + }) + } + }) + } + + export async function write(key: string): Promise { + const lock = get(key) + + return new Promise((resolve) => { + if (!lock.writer && lock.readers === 0) { + lock.writer = true + resolve({ + [Symbol.dispose]: () => { + lock.writer = false + process(key) + }, + }) + } else { + lock.waitingWriters.push(() => { + lock.writer = true + resolve({ + [Symbol.dispose]: () => { + lock.writer = false + process(key) + }, + }) + }) + } + }) + } +} diff --git a/specs/project.md b/specs/project.md new file mode 100644 index 0000000000..d6d1af082c --- /dev/null +++ b/specs/project.md @@ -0,0 +1,63 @@ +## project + +goal is to let a single instance of opencode be able to run sessions for +multiple projects and different worktrees per project + +### api + +``` +GET /project -> Project[] + +POST /project/init -> Project + + +GET /project/:projectID/session -> Session[] + +GET /project/:projectID/session/:sessionID -> Session + +POST /project/:projectID/session -> Session +{ + id?: string + parentID?: string + directory: string +} + +DELETE /project/:projectID/session/:sessionID + +POST /project/:projectID/session/:sessionID/init + +POST /project/:projectID/session/:sessionID/abort + +POST /project/:projectID/session/:sessionID/share + +DELETE /project/:projectID/session/:sessionID/share + +POST /project/:projectID/session/:sessionID/compact + +GET /project/:projectID/session/:sessionID/message -> { info: Message, parts: Part[] }[] + +GET /project/:projectID/session/:sessionID/message/:messageID -> { info: Message, parts: Part[] } + +POST /project/:projectID/session/:sessionID/message -> { info: Message, parts: Part[] } + +POST /project/:projectID/session/:sessionID/revert -> Session + +POST /project/:projectID/session/:sessionID/unrevert -> Session + +POST /project/:projectID/session/:sessionID/permission/:permissionID -> Session + +GET /project/:projectID/session/:sessionID/find/file -> string[] + +GET /project/:projectID/session/:sessionID/file -> { type: "raw" | "patch", content: string } + +GET /project/:projectID/session/:sessionID/file/status -> File[] + +POST /log + +// These are awkward + +GET /provider?directory= -> Provider +GET /config?directory= -> Config // think only tui uses this? +GET /agent?directory= -> Mode + +```