This commit is contained in:
Dax Raad 2026-05-22 09:41:02 -04:00
commit 34bade292d
11 changed files with 404 additions and 304 deletions

View file

@ -6,6 +6,7 @@ import { Context, Effect, Layer } from "effect"
import { Global } from "../global"
import { Flag } from "../flag/flag"
import path from "path"
import { DatabaseMigration } from "./migration"
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
type DatabaseShape = Effect.Success<typeof makeDatabase>
@ -24,6 +25,9 @@ const layer = Layer.effect(
yield* db.run("PRAGMA foreign_keys = ON")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
console.log(DatabaseMigration.ensure
return db
}),
)

View file

@ -1,9 +1,10 @@
import { Context, Schema } from "effect"
import { AbsolutePath } from "./schema"
export * as Location from "./location"
export const Ref = Schema.Struct({
directory: Schema.String,
directory: AbsolutePath,
workspaceID: Schema.optional(Schema.String),
}).annotate({ identifier: "Location.Ref" })
export type Ref = typeof Ref.Type

View file

@ -3,6 +3,8 @@ import { AccountV2 } from "../account"
import { EventV2 } from "../event"
import { PluginV2 } from "../plugin"
// Depending on what account is active, enable matching providers for that
// service
export const AccountPlugin = PluginV2.define({
id: PluginV2.ID.make("account"),
effect: Effect.gen(function* () {

View file

@ -56,7 +56,6 @@ export const ModelsDevPlugin = PluginV2.define({
const catalog = yield* Catalog.Service
const modelsDev = yield* ModelsDev.Service
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
const load = yield* catalog.loader()
const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () {
const data = yield* modelsDev.get()
@ -114,7 +113,7 @@ export const ModelsDevPlugin = PluginV2.define({
yield* refresh()
yield* events.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.runForEach(() => refresh()),
Effect.forkIn(scope, { startImmediately: true }),
Effect.forkScoped({ startImmediately: true }),
)
}).pipe(Effect.provide(ModelsDev.defaultLayer)),
})

View file

@ -0,0 +1,130 @@
export * as Project from "./project"
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { AppFileSystem } from "./filesystem"
import { AppProcess } from "./process"
import { AbsolutePath, withStatics } from "./schema"
import type { Location } from "./location"
export const ID = Schema.String.pipe(
Schema.brand("AccountV2.ID"),
withStatics((schema) => ({
global: schema.make("global"),
})),
)
export type ID = typeof ID.Type
export interface Interface {
readonly create: (input: AbsolutePath) => Promise<ID>
readonly locations: (projectID: ID) => Promise<Location.Ref[]>
// opencode -> ["~/dev/projects/anomalyco/opencode", "~/.gitworktrees/anomalyci/opencode"]
// global -> ["~/.config/nvim", "/etc/nixos"]
readonly resolve: (input: AbsolutePath) => Promise<ID>
// ~/dev/projects/anomalyco/opencode -> opencode
// ~/dev/projects/anomalyco/opencode/packages/core -> opencode
// ~/.gitworktrees/anomalyci/opencode -> opencode
// ~/.config/nvim -> global
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
interface GitResult {
readonly exitCode: number
readonly text: () => string
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const proc = yield* AppProcess.Service
const runGit = Effect.fn("Project.git")(
function* (args: string[], cwd: string) {
const result = yield* proc.run(
ChildProcess.make("git", args, {
cwd,
extendEnv: true,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
}),
)
return {
exitCode: result.exitCode,
text: () => result.stdout.toString("utf8"),
} satisfies GitResult
},
Effect.catch(() =>
Effect.succeed({
exitCode: 1,
text: () => "",
} satisfies GitResult),
),
)
const resolveGitPath = (cwd: string, value: string) => {
const trimmed = value.replace(/[\r\n]+$/, "")
if (!trimmed) return cwd
const normalized = AppFileSystem.windowsPath(trimmed)
if (path.isAbsolute(normalized)) return path.normalize(normalized)
return path.resolve(cwd, normalized)
}
const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
Effect.map((x) => x.trim()),
Effect.map((x) => ID.make(x)),
Effect.catch(() => Effect.void),
)
})
const resolve = async (input: AbsolutePath) =>
Effect.runPromise(
Effect.gen(function* () {
const repoPath = yield* fs.up({ targets: [".git"], start: input }).pipe(
Effect.map((matches) => matches[0]),
Effect.catch(() => Effect.void),
)
if (!repoPath) return ID.global
const cwd = path.dirname(repoPath)
const parsed = yield* runGit(["rev-parse", "--git-dir", "--git-common-dir"], cwd)
if (parsed.exitCode !== 0) return (yield* readCachedProjectId(repoPath)) ?? ID.global
const gitPaths = parsed
.text()
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)
const commonDir = gitPaths[1] ? resolveGitPath(cwd, gitPaths[1]) : undefined
if (!commonDir) return (yield* readCachedProjectId(repoPath)) ?? ID.global
const cached = (yield* readCachedProjectId(repoPath)) ?? (yield* readCachedProjectId(commonDir))
if (cached) return cached
const id = (yield* runGit(["rev-list", "--max-parents=0", "HEAD"], cwd))
.text()
.split("\n")
.map((item) => item.trim())
.filter(Boolean)
.toSorted()[0]
if (!id) return ID.global
yield* fs.writeFileString(path.join(commonDir, "opencode"), id).pipe(Effect.ignore)
return ID.make(id)
}),
)
return Service.of({
create: async () => {
throw new Error("Project.create is not implemented")
},
locations: async () => [],
resolve,
})
}),
)

View file

@ -10,6 +10,18 @@ export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
*/
export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
/**
* Relative file path (e.g., `src/components/Button.tsx`).
*/
export const RelativePath = Schema.String.pipe(Schema.brand("RelativePath"))
export type RelativePath = Schema.Schema.Type<typeof RelativePath>
/**
* Absolute file path (e.g., `/home/user/projects/myapp/src/main.ts`).
*/
export const AbsolutePath = Schema.String.pipe(Schema.brand("AbsolutePath"))
export type AbsolutePath = Schema.Schema.Type<typeof AbsolutePath>
/**
* Optional public JSON field that can hold explicit `undefined` on the type
* side but encodes it as an omitted key, matching legacy `JSON.stringify`.

View file

@ -1,8 +1,20 @@
export * as Session from "."
import { Schema } from "effect"
import { withStatics } from "../schema"
import { Effect, Schema } from "effect"
import { AbsolutePath, RelativePath, withStatics } from "../schema"
import { Identifier } from "../util/identifier"
import { Project } from "../project"
import { Workspace } from "../workspace"
import type { ModelV2 } from "../model"
import { Location } from "../location"
import type { SessionMessage } from "./message"
import type { Prompt } from "./prompt"
import type { EventV2 } from "../event"
export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({
identifier: "Session.Delivery",
})
export type Delivery = Schema.Schema.Type<typeof Delivery>
export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
Schema.brand("SessionID"),
@ -11,3 +23,105 @@ export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
})),
)
export type ID = typeof ID.Type
export const Info = Schema.Struct({
id: ID,
location: Location.Ref,
subpath: RelativePath, // derived from location
project: Project.ID, // derived from location
})
export type Info = typeof Info.Type
// get project -> project.locations
//
// get all sessions
//
// - by project
// - by subpath
// - by workspace (home is special)
type Cursor = {}
type ListInput = {
workspaceID?: Workspace.ID
search?: string
cursor?: Cursor
limit?: number
order?: "asc" | "desc"
} & (
| {
project: Project.ID
subpath?: RelativePath
}
| {
directory?: AbsolutePath
}
)
type CreateInput = {
id?: ID
agent?: string
model?: ModelV2.Ref
location: Location.Ref
}
type MoveInput = {
sessionID: ID
location: Location.Ref
}
type CompactInput = {
sessionID: ID
prompt?: Prompt
}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Session.NotFoundError", {
sessionID: ID,
}) {}
export type Error = NotFoundError
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
readonly create: (input?: CreateInput) => Effect.Effect<Info>
readonly move: (input: MoveInput) => Effect.Effect<void, NotFoundError>
readonly get: (sessionID: ID) => Effect.Effect<Info, NotFoundError>
readonly messages: (input: {
sessionID: ID
limit?: number
order?: "asc" | "desc"
cursor?: {
id: SessionMessage.ID
time: number
direction: "previous" | "next"
}
}) => Effect.Effect<SessionMessage.Message[], NotFoundError>
readonly context: (sessionID: ID) => Effect.Effect<SessionMessage.Message[], NotFoundError>
readonly switchAgent: (input: { sessionID: ID; agent: string }) => Effect.Effect<void, never>
readonly switchModel: (input: { sessionID: ID; model: ModelV2.Ref }) => Effect.Effect<void, never>
readonly prompt: (input: {
id?: EventV2.ID
sessionID: ID
prompt: Prompt
delivery?: Delivery
resume?: boolean
}) => Effect.Effect<void, NotFoundError>
readonly shell: (input: {
id?: EventV2.ID
sessionID: ID
command: string
delivery?: Delivery
resume?: boolean
}) => Effect.Effect<void, never>
readonly skill: (input: {
id?: EventV2.ID
sessionID: ID
skill: string
delivery?: Delivery
resume?: boolean
}) => Effect.Effect<void, never>
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError>
readonly wait: (id: ID) => Effect.Effect<void, NotFoundError>
readonly resume: (sessionID: ID) => Effect.Effect<void>
}

View file

@ -0,0 +1,11 @@
export * as Workspace from "./workspace"
import { Schema } from "effect"
import { withStatics } from "./schema"
import { Identifier } from "./util/identifier"
export const ID = Schema.String.pipe(
Schema.brand("AccountV2.ID"),
withStatics((schema) => ({ create: () => schema.make("wrk_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type