progress
This commit is contained in:
parent
6bd47e1bce
commit
34bade292d
11 changed files with 404 additions and 304 deletions
|
|
@ -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
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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* () {
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
})
|
||||
|
|
|
|||
130
packages/core/src/project.ts
Normal file
130
packages/core/src/project.ts
Normal 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,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
11
packages/core/src/workspace.ts
Normal file
11
packages/core/src/workspace.ts
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue