514 lines
19 KiB
TypeScript
514 lines
19 KiB
TypeScript
import { and } from "drizzle-orm"
|
|
import { Database } from "@/storage/db"
|
|
import { eq } from "drizzle-orm"
|
|
import { ProjectTable } from "./project.sql"
|
|
import { PermissionTable, SessionTable } from "../session/session.sql"
|
|
import { WorkspaceTable } from "../control-plane/workspace.sql"
|
|
import * as Log from "@opencode-ai/core/util/log"
|
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
|
import { BusEvent } from "@/bus/bus-event"
|
|
import { GlobalBus } from "@/bus/global"
|
|
import { which } from "../util/which"
|
|
import { ProjectID } from "./schema"
|
|
import { Bus } from "@/bus"
|
|
import { Command } from "@/command"
|
|
import { InstanceState } from "@/effect/instance-state"
|
|
import { Effect, Layer, Scope, Context, Stream, Types, Schema } from "effect"
|
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
|
import { AppProcess } from "@opencode-ai/core/process"
|
|
import { Project as ProjectV2 } from "@opencode-ai/core/project"
|
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
|
import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
|
|
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
|
|
|
const log = Log.create({ service: "project" })
|
|
|
|
const ProjectVcs = Schema.Literal("git")
|
|
|
|
const ProjectIcon = Schema.Struct({
|
|
url: optionalOmitUndefined(Schema.String),
|
|
override: optionalOmitUndefined(Schema.String),
|
|
color: optionalOmitUndefined(Schema.String),
|
|
})
|
|
|
|
const ProjectCommands = Schema.Struct({
|
|
start: optionalOmitUndefined(
|
|
Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }),
|
|
),
|
|
})
|
|
|
|
const ProjectTime = Schema.Struct({
|
|
created: NonNegativeInt,
|
|
updated: NonNegativeInt,
|
|
initialized: optionalOmitUndefined(NonNegativeInt),
|
|
})
|
|
|
|
export const Info = Schema.Struct({
|
|
id: ProjectID,
|
|
worktree: Schema.String,
|
|
vcs: optionalOmitUndefined(ProjectVcs),
|
|
name: optionalOmitUndefined(Schema.String),
|
|
icon: optionalOmitUndefined(ProjectIcon),
|
|
commands: optionalOmitUndefined(ProjectCommands),
|
|
time: ProjectTime,
|
|
sandboxes: Schema.Array(Schema.String),
|
|
}).annotate({ identifier: "Project" })
|
|
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
|
|
|
|
export const Event = {
|
|
Updated: BusEvent.define("project.updated", Info),
|
|
}
|
|
|
|
type Row = typeof ProjectTable.$inferSelect
|
|
|
|
export function fromRow(row: Row): Info {
|
|
const icon =
|
|
row.icon_url || row.icon_url_override || row.icon_color
|
|
? {
|
|
url: row.icon_url ?? undefined,
|
|
override: row.icon_url_override ?? undefined,
|
|
color: row.icon_color ?? undefined,
|
|
}
|
|
: undefined
|
|
return {
|
|
id: row.id,
|
|
worktree: row.worktree,
|
|
vcs: row.vcs ? Schema.decodeUnknownSync(ProjectVcs)(row.vcs) : undefined,
|
|
name: row.name ?? undefined,
|
|
icon,
|
|
time: {
|
|
created: row.time_created,
|
|
updated: row.time_updated,
|
|
initialized: row.time_initialized ?? undefined,
|
|
},
|
|
sandboxes: row.sandboxes,
|
|
commands: row.commands ?? undefined,
|
|
}
|
|
}
|
|
|
|
function mergePermissionRules<T extends readonly unknown[]>(oldRules: T, newRules: T): T {
|
|
return [...new Map([...oldRules, ...newRules].map((rule) => [JSON.stringify(rule), rule])).values()] as unknown as T
|
|
}
|
|
|
|
export const UpdateInput = Schema.Struct({
|
|
projectID: ProjectID,
|
|
name: Schema.optional(Schema.String),
|
|
icon: Schema.optional(ProjectIcon),
|
|
commands: Schema.optional(ProjectCommands),
|
|
})
|
|
export type UpdateInput = Types.DeepMutable<Schema.Schema.Type<typeof UpdateInput>>
|
|
|
|
export const UpdatePayload = Schema.Struct({
|
|
name: Schema.optional(Schema.String),
|
|
icon: Schema.optional(ProjectIcon),
|
|
commands: Schema.optional(ProjectCommands),
|
|
}).annotate({ identifier: "ProjectUpdateInput" })
|
|
export type UpdatePayload = Types.DeepMutable<Schema.Schema.Type<typeof UpdatePayload>>
|
|
|
|
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Project.NotFoundError", {
|
|
projectID: ProjectID,
|
|
}) {}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Effect service
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface Interface {
|
|
/**
|
|
* Per-instance setup. Subscribes to the `/init` slash command for the
|
|
* current instance and stamps the project's initialized timestamp when it
|
|
* fires. Subscription lifetime is tied to the per-instance state scope.
|
|
*/
|
|
readonly init: () => Effect.Effect<void>
|
|
readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }>
|
|
readonly discover: (input: Info) => Effect.Effect<void>
|
|
readonly list: () => Effect.Effect<Info[]>
|
|
readonly get: (id: ProjectID) => Effect.Effect<Info | undefined>
|
|
readonly update: (input: UpdateInput) => Effect.Effect<Info, NotFoundError>
|
|
readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect<Info>
|
|
readonly setInitialized: (id: ProjectID) => Effect.Effect<void>
|
|
readonly sandboxes: (id: ProjectID) => Effect.Effect<string[]>
|
|
readonly addSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
|
|
readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
|
|
}
|
|
|
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
|
|
|
|
type GitResult = { code: number; text: string; stderr: string }
|
|
|
|
export const layer = Layer.effect(
|
|
Service,
|
|
Effect.gen(function* () {
|
|
const fs = yield* AppFileSystem.Service
|
|
const proc = yield* AppProcess.Service
|
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
const projectV2 = yield* ProjectV2.Service
|
|
const bus = yield* Bus.Service
|
|
const flags = yield* RuntimeFlags.Service
|
|
|
|
const git = Effect.fnUntraced(
|
|
function* (args: string[], opts?: { cwd?: string }) {
|
|
const handle = yield* spawner.spawn(
|
|
ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
|
|
)
|
|
const [text, stderr] = yield* Effect.all(
|
|
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
|
{ concurrency: 2 },
|
|
)
|
|
const code = yield* handle.exitCode
|
|
return { code, text, stderr } satisfies GitResult
|
|
},
|
|
Effect.scoped,
|
|
Effect.catch(() => Effect.succeed({ code: 1, text: "", stderr: "" } satisfies GitResult)),
|
|
)
|
|
|
|
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
|
|
Effect.sync(() => Database.use(fn))
|
|
|
|
const emitUpdated = (data: Info) =>
|
|
Effect.sync(() =>
|
|
GlobalBus.emit("event", {
|
|
directory: "global",
|
|
project: data.id,
|
|
payload: { type: Event.Updated.type, properties: data },
|
|
}),
|
|
)
|
|
|
|
const fakeVcs = Schema.decodeUnknownSync(Schema.optional(ProjectVcs))(Flag.OPENCODE_FAKE_VCS)
|
|
|
|
const scope = yield* Scope.Scope
|
|
|
|
const migrateProjectId = Effect.fn("Project.migrateProjectId")(function* (
|
|
oldID: ProjectID | undefined,
|
|
newID: ProjectID,
|
|
) {
|
|
if (!oldID) return
|
|
if (oldID === ProjectID.global) return
|
|
if (oldID === newID) return
|
|
|
|
yield* Effect.sync(() =>
|
|
Database.transaction(
|
|
(d) => {
|
|
const oldProject = d.select().from(ProjectTable).where(eq(ProjectTable.id, oldID)).get()
|
|
const newProject = d.select().from(ProjectTable).where(eq(ProjectTable.id, newID)).get()
|
|
if (oldProject && !newProject) {
|
|
d.insert(ProjectTable)
|
|
.values({
|
|
...oldProject,
|
|
id: newID,
|
|
time_updated: Date.now(),
|
|
})
|
|
.run()
|
|
}
|
|
|
|
const oldPermission = d.select().from(PermissionTable).where(eq(PermissionTable.project_id, oldID)).get()
|
|
const newPermission = d.select().from(PermissionTable).where(eq(PermissionTable.project_id, newID)).get()
|
|
if (oldPermission && newPermission) {
|
|
d.update(PermissionTable)
|
|
.set({
|
|
data: mergePermissionRules(oldPermission.data, newPermission.data),
|
|
time_created: Math.min(oldPermission.time_created, newPermission.time_created),
|
|
time_updated: Date.now(),
|
|
})
|
|
.where(eq(PermissionTable.project_id, newID))
|
|
.run()
|
|
d.delete(PermissionTable).where(eq(PermissionTable.project_id, oldID)).run()
|
|
}
|
|
if (oldPermission && !newPermission) {
|
|
d.update(PermissionTable).set({ project_id: newID }).where(eq(PermissionTable.project_id, oldID)).run()
|
|
}
|
|
|
|
d.update(SessionTable).set({ project_id: newID }).where(eq(SessionTable.project_id, oldID)).run()
|
|
d.update(WorkspaceTable).set({ project_id: newID }).where(eq(WorkspaceTable.project_id, oldID)).run()
|
|
|
|
if (oldProject) d.delete(ProjectTable).where(eq(ProjectTable.id, oldID)).run()
|
|
},
|
|
{ behavior: "immediate" },
|
|
),
|
|
)
|
|
})
|
|
|
|
const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
|
|
log.info("fromDirectory", { directory })
|
|
|
|
const data = yield* projectV2.resolve(AbsolutePath.make(directory))
|
|
const worktree = data.id === ProjectV2.ID.make("global") && !data.vcs ? "/" : data.directory
|
|
|
|
// Phase 2: upsert
|
|
const projectID = ProjectID.make(data.id)
|
|
yield* migrateProjectId(data.previous ? ProjectID.make(data.previous) : undefined, projectID)
|
|
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get())
|
|
const existing = row
|
|
? fromRow(row)
|
|
: {
|
|
id: projectID,
|
|
worktree,
|
|
vcs: data.vcs?.type ?? fakeVcs,
|
|
sandboxes: [] as string[],
|
|
time: { created: Date.now(), updated: Date.now() },
|
|
}
|
|
|
|
if (flags.experimentalIconDiscovery) yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope))
|
|
|
|
const result: Info = {
|
|
...existing,
|
|
worktree: projectID === ProjectID.global ? worktree : existing.worktree,
|
|
vcs: data.vcs?.type ?? fakeVcs,
|
|
time: { ...existing.time, updated: Date.now() },
|
|
}
|
|
if (
|
|
projectID !== ProjectID.global &&
|
|
data.directory !== result.worktree &&
|
|
!result.sandboxes.includes(data.directory)
|
|
)
|
|
result.sandboxes.push(data.directory)
|
|
result.sandboxes = yield* Effect.forEach(
|
|
result.sandboxes,
|
|
(s) =>
|
|
fs.exists(s).pipe(
|
|
Effect.orDie,
|
|
Effect.map((exists) => (exists ? s : undefined)),
|
|
),
|
|
{ concurrency: "unbounded" },
|
|
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
|
|
|
|
yield* db((d) =>
|
|
d
|
|
.insert(ProjectTable)
|
|
.values({
|
|
id: result.id,
|
|
worktree: result.worktree,
|
|
vcs: result.vcs ?? null,
|
|
name: result.name,
|
|
icon_url: result.icon?.url,
|
|
icon_url_override: result.icon?.override,
|
|
icon_color: result.icon?.color,
|
|
time_created: result.time.created,
|
|
time_updated: result.time.updated,
|
|
time_initialized: result.time.initialized,
|
|
sandboxes: result.sandboxes,
|
|
commands: result.commands,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: ProjectTable.id,
|
|
set: {
|
|
worktree: result.worktree,
|
|
vcs: result.vcs ?? null,
|
|
name: result.name,
|
|
icon_url: result.icon?.url,
|
|
icon_url_override: result.icon?.override,
|
|
icon_color: result.icon?.color,
|
|
time_updated: result.time.updated,
|
|
time_initialized: result.time.initialized,
|
|
sandboxes: result.sandboxes,
|
|
commands: result.commands,
|
|
},
|
|
})
|
|
.run(),
|
|
)
|
|
|
|
if (projectID !== ProjectID.global) {
|
|
yield* db((d) =>
|
|
d
|
|
.update(SessionTable)
|
|
.set({ project_id: projectID })
|
|
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.directory)))
|
|
.run(),
|
|
)
|
|
}
|
|
|
|
yield* emitUpdated(result)
|
|
if (projectID !== ProjectID.global && data.vcs?.type === "git") {
|
|
yield* projectV2.commit({ store: data.vcs.store, id: data.id })
|
|
}
|
|
return { project: result, sandbox: data.vcs ? data.directory : worktree }
|
|
})
|
|
|
|
const discover = Effect.fn("Project.discover")(function* (input: Info) {
|
|
if (input.vcs !== "git") return
|
|
if (input.icon?.override) return
|
|
if (input.icon?.url) return
|
|
|
|
const matches = yield* fs
|
|
.glob("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
|
|
cwd: input.worktree,
|
|
absolute: true,
|
|
include: "file",
|
|
})
|
|
.pipe(Effect.orDie)
|
|
const shortest = matches.sort((a, b) => a.length - b.length)[0]
|
|
if (!shortest) return
|
|
|
|
const buffer = yield* fs.readFile(shortest).pipe(Effect.orDie)
|
|
const base64 = Buffer.from(buffer).toString("base64")
|
|
const mime = AppFileSystem.mimeType(shortest)
|
|
const url = `data:${mime};base64,${base64}`
|
|
yield* update({ projectID: input.id, icon: { url } }).pipe(
|
|
Effect.catchTag("Project.NotFoundError", () => Effect.void),
|
|
)
|
|
})
|
|
|
|
const list = Effect.fn("Project.list")(function* () {
|
|
return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow))
|
|
})
|
|
|
|
const get = Effect.fn("Project.get")(function* (id: ProjectID) {
|
|
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
|
return row ? fromRow(row) : undefined
|
|
})
|
|
|
|
const update = Effect.fn("Project.update")(function* (input: UpdateInput) {
|
|
const result = yield* db((d) =>
|
|
d
|
|
.update(ProjectTable)
|
|
.set({
|
|
name: input.name,
|
|
icon_url: input.icon?.url,
|
|
icon_url_override: input.icon?.override,
|
|
icon_color: input.icon?.color,
|
|
commands: input.commands,
|
|
time_updated: Date.now(),
|
|
})
|
|
.where(eq(ProjectTable.id, input.projectID))
|
|
.returning()
|
|
.get(),
|
|
)
|
|
if (!result) return yield* new NotFoundError({ projectID: input.projectID })
|
|
const data = fromRow(result)
|
|
yield* emitUpdated(data)
|
|
return data
|
|
})
|
|
|
|
const initGit = Effect.fn("Project.initGit")(function* (input: { directory: string; project: Info }) {
|
|
if (input.project.vcs === "git") return input.project
|
|
if (!(yield* Effect.sync(() => which("git")))) throw new Error("Git is not installed")
|
|
const result = yield* git(["init", "--quiet"], { cwd: input.directory })
|
|
if (result.code !== 0) {
|
|
throw new Error(result.stderr.trim() || result.text.trim() || "Failed to initialize git repository")
|
|
}
|
|
const { project } = yield* fromDirectory(input.directory)
|
|
return project
|
|
})
|
|
|
|
const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectID) {
|
|
yield* db((d) =>
|
|
d.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
|
|
)
|
|
})
|
|
|
|
const initState = yield* InstanceState.make(
|
|
Effect.fn("Project.initState")(function* (ctx) {
|
|
yield* (yield* bus.subscribe(Command.Event.Executed)).pipe(
|
|
Stream.runForEach((payload) =>
|
|
payload.properties.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void,
|
|
),
|
|
Effect.forkScoped,
|
|
)
|
|
}),
|
|
)
|
|
|
|
const init = Effect.fn("Project.init")(function* () {
|
|
yield* InstanceState.get(initState)
|
|
})
|
|
|
|
const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) {
|
|
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
|
if (!row) return []
|
|
const data = fromRow(row)
|
|
return yield* Effect.forEach(
|
|
data.sandboxes,
|
|
(dir) =>
|
|
fs.isDir(dir).pipe(
|
|
Effect.orDie,
|
|
Effect.map((ok) => (ok ? dir : undefined)),
|
|
),
|
|
{ concurrency: "unbounded" },
|
|
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
|
|
})
|
|
|
|
const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectID, directory: string) {
|
|
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
|
if (!row) throw new Error(`Project not found: ${id}`)
|
|
const sboxes = [...row.sandboxes]
|
|
if (!sboxes.includes(directory)) sboxes.push(directory)
|
|
const result = yield* db((d) =>
|
|
d
|
|
.update(ProjectTable)
|
|
.set({ sandboxes: sboxes, time_updated: Date.now() })
|
|
.where(eq(ProjectTable.id, id))
|
|
.returning()
|
|
.get(),
|
|
)
|
|
if (!result) throw new Error(`Project not found: ${id}`)
|
|
yield* emitUpdated(fromRow(result))
|
|
})
|
|
|
|
const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectID, directory: string) {
|
|
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
|
if (!row) throw new Error(`Project not found: ${id}`)
|
|
const sboxes = row.sandboxes.filter((s) => s !== directory)
|
|
const result = yield* db((d) =>
|
|
d
|
|
.update(ProjectTable)
|
|
.set({ sandboxes: sboxes, time_updated: Date.now() })
|
|
.where(eq(ProjectTable.id, id))
|
|
.returning()
|
|
.get(),
|
|
)
|
|
if (!result) throw new Error(`Project not found: ${id}`)
|
|
yield* emitUpdated(fromRow(result))
|
|
})
|
|
|
|
return Service.of({
|
|
init,
|
|
fromDirectory,
|
|
discover,
|
|
list,
|
|
get,
|
|
update,
|
|
initGit,
|
|
setInitialized,
|
|
sandboxes,
|
|
addSandbox,
|
|
removeSandbox,
|
|
})
|
|
}),
|
|
)
|
|
|
|
export const defaultLayer = layer.pipe(
|
|
Layer.provide(Bus.defaultLayer),
|
|
Layer.provide(ProjectV2.defaultLayer),
|
|
Layer.provide(AppProcess.defaultLayer),
|
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
|
Layer.provide(AppFileSystem.defaultLayer),
|
|
Layer.provide(RuntimeFlags.defaultLayer),
|
|
)
|
|
|
|
export const use = serviceUse(Service)
|
|
|
|
export function list() {
|
|
return Database.use((db) =>
|
|
db
|
|
.select()
|
|
.from(ProjectTable)
|
|
.all()
|
|
.map((row) => fromRow(row)),
|
|
)
|
|
}
|
|
|
|
export function get(id: ProjectID): Info | undefined {
|
|
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
|
if (!row) return undefined
|
|
return fromRow(row)
|
|
}
|
|
|
|
export function setInitialized(id: ProjectID) {
|
|
Database.use((db) =>
|
|
db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
|
|
)
|
|
}
|
|
|
|
export * as Project from "./project"
|