mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-12 20:50:01 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8baa69b899 | |||
| d31a994c27 | |||
| 76640a5c9c | |||
| 76dbaf20ad | |||
| 9dd0e39867 | |||
| 69a465d33b |
@@ -0,0 +1,49 @@
|
||||
name: deploy-lab-catalog
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [v2]
|
||||
paths:
|
||||
- ".github/workflows/deploy-lab-catalog.yml"
|
||||
- "bun.lock"
|
||||
- "package.json"
|
||||
- "packages/drive/**"
|
||||
- "packages/protocol/src/simulation.ts"
|
||||
- "packages/simulation/**"
|
||||
- "packages/lab/catalog/**"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-lab-catalog-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Install ffmpeg
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --yes ffmpeg
|
||||
|
||||
- name: Validate
|
||||
run: |
|
||||
bun --cwd packages/protocol typecheck
|
||||
bun --cwd packages/simulation typecheck
|
||||
bun --cwd packages/drive run check
|
||||
bun --cwd packages/drive run test
|
||||
bun --cwd packages/lab/catalog run check
|
||||
|
||||
- name: Deploy
|
||||
working-directory: packages/lab/catalog
|
||||
run: bun run deploy
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
@@ -55,6 +55,12 @@ jobs:
|
||||
git config --global user.email "bot@opencode.ai"
|
||||
git config --global user.name "opencode"
|
||||
|
||||
- name: Install ffmpeg
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --yes ffmpeg
|
||||
|
||||
- name: Cache Turbo
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
@@ -66,7 +72,7 @@ jobs:
|
||||
|
||||
- name: Run unit tests
|
||||
timeout-minutes: 20
|
||||
run: GITHUB_ACTIONS=false bun turbo test
|
||||
run: GITHUB_ACTIONS=false bun turbo test ${{ runner.os == 'Windows' && '--filter=!opencode-drive' || '' }}
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ files. If the script is unsuccessful, automatically fix the script and run it ag
|
||||
Scripts use one typed definition object. `setup` runs before OpenCode starts,
|
||||
and `fs.writeFile` always writes inside the simulated project.
|
||||
|
||||
You can read the full typed API here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/src/script/types.ts
|
||||
You can read the full typed API here: https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/src/script/types.ts
|
||||
|
||||
```ts
|
||||
import { defineScript } from "opencode-drive"
|
||||
@@ -83,7 +83,7 @@ itself (this is extremely rare, do not use this unless explicitly asked). In thi
|
||||
mode `ui` is typed as `null`; call `server.launch()` exactly
|
||||
once before launching clients. Each `clients.launch(name)` result provides the
|
||||
same UI methods as the automatic client. You can see an example of this API
|
||||
here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/multiple-clients.ts
|
||||
here: https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/multiple-clients.ts
|
||||
|
||||
Use the exported `wait(milliseconds)` utility for an unconditional delay.
|
||||
|
||||
@@ -114,8 +114,8 @@ completion are automatic.
|
||||
|
||||
You can see some example scripts here:
|
||||
|
||||
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/simple.ts
|
||||
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/serve.ts
|
||||
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/simple.ts
|
||||
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/serve.ts
|
||||
|
||||
## Prune
|
||||
|
||||
|
||||
+3
-1
@@ -33,6 +33,7 @@
|
||||
"packages": [
|
||||
"packages/*",
|
||||
"packages/console/*",
|
||||
"packages/lab/*",
|
||||
"packages/stats/*",
|
||||
"packages/slack"
|
||||
],
|
||||
@@ -120,7 +121,8 @@
|
||||
"prettier": "3.6.2",
|
||||
"semver": "^7.6.0",
|
||||
"sst": "catalog:",
|
||||
"turbo": "2.10.2"
|
||||
"turbo": "2.10.2",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.933.0",
|
||||
|
||||
@@ -19,7 +19,7 @@ import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { ProjectCopy } from "@opencode-ai/schema/project-copy"
|
||||
import { AgentAttachment, FileAttachment, Prompt, PromptMention } from "@opencode-ai/schema/prompt"
|
||||
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
@@ -60,7 +60,7 @@ const effectTypeReferences = [
|
||||
...namespaceTypes("PermissionSaved", "@opencode-ai/schema/permission-saved", PermissionSaved),
|
||||
...namespaceTypes("Plugin", "@opencode-ai/schema/plugin", Plugin),
|
||||
...namespaceTypes("Project", "@opencode-ai/schema/project", Project),
|
||||
...namespaceTypes("Worktree", "@opencode-ai/schema/worktree", Worktree),
|
||||
...namespaceTypes("ProjectCopy", "@opencode-ai/schema/project-copy", ProjectCopy),
|
||||
...namespaceTypes("PromptInput", "@opencode-ai/schema/prompt-input", PromptInput),
|
||||
...namespaceTypes("Provider", "@opencode-ai/schema/provider", Provider),
|
||||
...namespaceTypes("Pty", "@opencode-ai/schema/pty", Pty),
|
||||
|
||||
@@ -34,7 +34,7 @@ import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||
import type { Pty } from "@opencode-ai/schema/pty"
|
||||
import type { Question } from "@opencode-ai/schema/question"
|
||||
import type { Reference } from "@opencode-ai/schema/reference"
|
||||
import type { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import type { ProjectCopy } from "@opencode-ai/schema/project-copy"
|
||||
import type { Vcs } from "@opencode-ai/schema/vcs"
|
||||
import type { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import type { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
@@ -1230,9 +1230,17 @@ export type Endpoint13_1Input = {
|
||||
export type Endpoint13_1Output = Project.Current
|
||||
export type ProjectCurrentOperation<E = never> = (input?: Endpoint13_1Input) => Effect.Effect<Endpoint13_1Output, E>
|
||||
|
||||
export type Endpoint13_2Input = {
|
||||
readonly projectID: Project.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint13_2Output = Project.Directories
|
||||
export type ProjectDirectoriesOperation<E = never> = (input: Endpoint13_2Input) => Effect.Effect<Endpoint13_2Output, E>
|
||||
|
||||
export interface ProjectApi<E = never> {
|
||||
readonly list: ProjectListOperation<E>
|
||||
readonly current: ProjectCurrentOperation<E>
|
||||
readonly directories: ProjectDirectoriesOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint14_0Input = {
|
||||
@@ -1541,36 +1549,36 @@ export interface ReferenceApi<E = never> {
|
||||
readonly list: ReferenceListOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint24_0Input = { readonly projectID: Project.ID }
|
||||
export type Endpoint24_0Output = Worktree.List
|
||||
export type WorktreeListOperation<E = never> = (input: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
|
||||
|
||||
export type Endpoint24_1Input = {
|
||||
export type Endpoint24_0Input = {
|
||||
readonly projectID: Project.ID
|
||||
readonly strategy: Worktree.StrategyID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly strategy: ProjectCopy.StrategyID
|
||||
readonly directory: AbsolutePath
|
||||
readonly name?: string | undefined
|
||||
}
|
||||
export type Endpoint24_1Output = Worktree.Info
|
||||
export type WorktreeCreateOperation<E = never> = (input: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
|
||||
export type Endpoint24_0Output = ProjectCopy.Copy
|
||||
export type ProjectCopyCreateOperation<E = never> = (input: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
|
||||
|
||||
export type Endpoint24_2Input = {
|
||||
export type Endpoint24_1Input = {
|
||||
readonly projectID: Project.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly directory: AbsolutePath
|
||||
readonly force: boolean
|
||||
}
|
||||
export type Endpoint24_1Output = void
|
||||
export type ProjectCopyRemoveOperation<E = never> = (input: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
|
||||
|
||||
export type Endpoint24_2Input = {
|
||||
readonly projectID: Project.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint24_2Output = void
|
||||
export type WorktreeRemoveOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
|
||||
export type ProjectCopyRefreshOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
|
||||
|
||||
export type Endpoint24_3Input = { readonly projectID: Project.ID }
|
||||
export type Endpoint24_3Output = void
|
||||
export type WorktreeRefreshOperation<E = never> = (input: Endpoint24_3Input) => Effect.Effect<Endpoint24_3Output, E>
|
||||
|
||||
export interface WorktreeApi<E = never> {
|
||||
readonly list: WorktreeListOperation<E>
|
||||
readonly create: WorktreeCreateOperation<E>
|
||||
readonly remove: WorktreeRemoveOperation<E>
|
||||
readonly refresh: WorktreeRefreshOperation<E>
|
||||
export interface ProjectCopyApi<E = never> {
|
||||
readonly create: ProjectCopyCreateOperation<E>
|
||||
readonly remove: ProjectCopyRemoveOperation<E>
|
||||
readonly refresh: ProjectCopyRefreshOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint25_0Input = {
|
||||
@@ -1683,7 +1691,7 @@ export interface AppApi<E = never> {
|
||||
readonly shell: ShellApi<E>
|
||||
readonly question: QuestionApi<E>
|
||||
readonly reference: ReferenceApi<E>
|
||||
readonly worktree: WorktreeApi<E>
|
||||
readonly projectCopy: ProjectCopyApi<E>
|
||||
readonly vcs: VcsApi<E>
|
||||
readonly debug: DebugApi<E>
|
||||
readonly migration: MigrationApi<E>
|
||||
|
||||
@@ -139,6 +139,8 @@ import type {
|
||||
Endpoint13_0Output,
|
||||
Endpoint13_1Input,
|
||||
Endpoint13_1Output,
|
||||
Endpoint13_2Input,
|
||||
Endpoint13_2Output,
|
||||
Endpoint14_0Input,
|
||||
Endpoint14_0Output,
|
||||
Endpoint14_1Input,
|
||||
@@ -214,8 +216,6 @@ import type {
|
||||
Endpoint24_1Output,
|
||||
Endpoint24_2Input,
|
||||
Endpoint24_2Output,
|
||||
Endpoint24_3Input,
|
||||
Endpoint24_3Output,
|
||||
Endpoint25_0Input,
|
||||
Endpoint25_0Output,
|
||||
Endpoint25_1Input,
|
||||
@@ -876,7 +876,19 @@ const Endpoint13_1 = (raw: RawClient["server.project"]) => (input?: Endpoint13_1
|
||||
raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup13 = (raw: RawClient["server.project"]) => ({ list: Endpoint13_0(raw), current: Endpoint13_1(raw) })
|
||||
const Endpoint13_2 = (raw: RawClient["server.project"]) => (input: Endpoint13_2Input) =>
|
||||
preserveEffect<Endpoint13_2Output>()(
|
||||
raw["project.directories"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
query: { location: input["location"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup13 = (raw: RawClient["server.project"]) => ({
|
||||
list: Endpoint13_0(raw),
|
||||
current: Endpoint13_1(raw),
|
||||
directories: Endpoint13_2(raw),
|
||||
})
|
||||
|
||||
const Endpoint14_0 = (raw: RawClient["server.form"]) => (input?: Endpoint14_0Input) =>
|
||||
preserveEffect<Endpoint14_0Output>()(
|
||||
@@ -1200,37 +1212,36 @@ const Endpoint23_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint23
|
||||
|
||||
const adaptGroup23 = (raw: RawClient["server.reference"]) => ({ list: Endpoint23_0(raw) })
|
||||
|
||||
const Endpoint24_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_0Input) =>
|
||||
const Endpoint24_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint24_0Input) =>
|
||||
preserveEffect<Endpoint24_0Output>()(
|
||||
raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint24_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_1Input) =>
|
||||
preserveEffect<Endpoint24_1Output>()(
|
||||
raw["worktree.create"]({
|
||||
raw["projectCopy.create"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint24_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_2Input) =>
|
||||
preserveEffect<Endpoint24_2Output>()(
|
||||
raw["worktree.remove"]({
|
||||
const Endpoint24_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint24_1Input) =>
|
||||
preserveEffect<Endpoint24_1Output>()(
|
||||
raw["projectCopy.remove"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { directory: input["directory"], force: input["force"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint24_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_3Input) =>
|
||||
preserveEffect<Endpoint24_3Output>()(
|
||||
raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
const Endpoint24_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint24_2Input) =>
|
||||
preserveEffect<Endpoint24_2Output>()(
|
||||
raw["projectCopy.refresh"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
query: { location: input["location"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup24 = (raw: RawClient["server.worktree"]) => ({
|
||||
list: Endpoint24_0(raw),
|
||||
create: Endpoint24_1(raw),
|
||||
remove: Endpoint24_2(raw),
|
||||
refresh: Endpoint24_3(raw),
|
||||
const adaptGroup24 = (raw: RawClient["server.projectCopy"]) => ({
|
||||
create: Endpoint24_0(raw),
|
||||
remove: Endpoint24_1(raw),
|
||||
refresh: Endpoint24_2(raw),
|
||||
})
|
||||
|
||||
const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) =>
|
||||
@@ -1323,7 +1334,7 @@ const adaptClient = (raw: RawClient) => ({
|
||||
shell: adaptGroup21(raw["server.shell"]),
|
||||
question: adaptGroup22(raw["server.question"]),
|
||||
reference: adaptGroup23(raw["server.reference"]),
|
||||
worktree: adaptGroup24(raw["server.worktree"]),
|
||||
projectCopy: adaptGroup24(raw["server.projectCopy"]),
|
||||
vcs: adaptGroup25(raw["server.vcs"]),
|
||||
debug: adaptGroup26(raw["server.debug"]),
|
||||
migration: adaptGroup27(raw["server.migration"]),
|
||||
|
||||
@@ -33,7 +33,7 @@ export { Model } from "@opencode-ai/schema/model"
|
||||
export { Permission } from "@opencode-ai/schema/permission"
|
||||
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
export { Project } from "@opencode-ai/schema/project"
|
||||
export { Worktree } from "@opencode-ai/schema/worktree"
|
||||
export { ProjectCopy } from "@opencode-ai/schema/project-copy"
|
||||
export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { Pty } from "@opencode-ai/schema/pty"
|
||||
export { Question } from "@opencode-ai/schema/question"
|
||||
|
||||
@@ -133,6 +133,8 @@ import type {
|
||||
ProjectListOutput,
|
||||
ProjectCurrentInput,
|
||||
ProjectCurrentOutput,
|
||||
ProjectDirectoriesInput,
|
||||
ProjectDirectoriesOutput,
|
||||
FormRequestListInput,
|
||||
FormRequestListOutput,
|
||||
FormListInput,
|
||||
@@ -204,14 +206,12 @@ import type {
|
||||
QuestionRejectOutput,
|
||||
ReferenceListInput,
|
||||
ReferenceListOutput,
|
||||
WorktreeListInput,
|
||||
WorktreeListOutput,
|
||||
WorktreeCreateInput,
|
||||
WorktreeCreateOutput,
|
||||
WorktreeRemoveInput,
|
||||
WorktreeRemoveOutput,
|
||||
WorktreeRefreshInput,
|
||||
WorktreeRefreshOutput,
|
||||
ProjectCopyCreateInput,
|
||||
ProjectCopyCreateOutput,
|
||||
ProjectCopyRemoveInput,
|
||||
ProjectCopyRemoveOutput,
|
||||
ProjectCopyRefreshInput,
|
||||
ProjectCopyRefreshOutput,
|
||||
VcsGetInput,
|
||||
VcsGetOutput,
|
||||
VcsStatusInput,
|
||||
@@ -1255,6 +1255,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
directories: (input: ProjectDirectoriesInput, requestOptions?: RequestOptions) =>
|
||||
request<ProjectDirectoriesOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/project/${encodeURIComponent(input.projectID)}/directories`,
|
||||
query: { location: input["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
form: {
|
||||
request: {
|
||||
@@ -1724,23 +1736,13 @@ export function make(options: ClientOptions) {
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
worktree: {
|
||||
list: (input: WorktreeListInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
create: (input: WorktreeCreateInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeCreateOutput>(
|
||||
projectCopy: {
|
||||
create: (input: ProjectCopyCreateInput, requestOptions?: RequestOptions) =>
|
||||
request<ProjectCopyCreateOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
|
||||
query: { location: input["location"] },
|
||||
body: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
@@ -1748,11 +1750,12 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
remove: (input: WorktreeRemoveInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeRemoveOutput>(
|
||||
remove: (input: ProjectCopyRemoveInput, requestOptions?: RequestOptions) =>
|
||||
request<ProjectCopyRemoveOutput>(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
|
||||
query: { location: input["location"] },
|
||||
body: { directory: input["directory"], force: input["force"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
@@ -1760,11 +1763,12 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
refresh: (input: WorktreeRefreshInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeRefreshOutput>(
|
||||
refresh: (input: ProjectCopyRefreshInput, requestOptions?: RequestOptions) =>
|
||||
request<ProjectCopyRefreshOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree/refresh`,
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
|
||||
query: { location: input["location"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: true,
|
||||
|
||||
@@ -291,6 +291,8 @@ export type ProjectTime = { created: number; updated: number; initialized?: numb
|
||||
|
||||
export type ProjectCurrent = { id: string; directory: string; canonical: string }
|
||||
|
||||
export type ProjectDirectory = { directory: string; strategy?: string }
|
||||
|
||||
export type FormMetadata = { [x: string]: JsonValue }
|
||||
|
||||
export type FormValue = string | number | boolean | Array<string>
|
||||
@@ -368,9 +370,7 @@ export type ReferenceGitSource = {
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
export type WorktreeDirectory = { directory: string; strategy?: string }
|
||||
|
||||
export type WorktreeInfo = { directory: string }
|
||||
export type ProjectCopyCopy = { directory: string }
|
||||
|
||||
export type VcsBranch = { current?: string; default?: string }
|
||||
|
||||
@@ -828,20 +828,20 @@ export type PluginUpdated = {
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type WorktreeUpdated = {
|
||||
export type ProjectDirectoriesUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "worktree.updated"
|
||||
type: "project.directories.updated"
|
||||
location?: LocationRef
|
||||
data: { projectID: string }
|
||||
}
|
||||
|
||||
export type WorktreeResolved = {
|
||||
export type ProjectDirectoryResolved = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "worktree.resolved"
|
||||
type: "project.directory.resolved"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { projectID: string; directory: string; previous: string }
|
||||
@@ -1369,6 +1369,8 @@ export type Project = {
|
||||
sandboxes: Array<string>
|
||||
}
|
||||
|
||||
export type ProjectDirectories = Array<ProjectDirectory>
|
||||
|
||||
export type FormAnswer = { [x: string]: FormValue }
|
||||
|
||||
export type PermissionRequest = {
|
||||
@@ -1518,8 +1520,6 @@ export type SessionStatus2 = {
|
||||
|
||||
export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource
|
||||
|
||||
export type WorktreeList = Array<WorktreeDirectory>
|
||||
|
||||
export type VcsInfo = { branch: VcsBranch }
|
||||
|
||||
export type PermissionRuleset = Array<PermissionRule>
|
||||
@@ -2102,8 +2102,8 @@ export type V2Event =
|
||||
| PermissionReplied
|
||||
| PluginAdded
|
||||
| PluginUpdated
|
||||
| WorktreeUpdated
|
||||
| WorktreeResolved
|
||||
| ProjectDirectoriesUpdated
|
||||
| ProjectDirectoryResolved
|
||||
| CommandUpdated
|
||||
| ConfigUpdated
|
||||
| SkillUpdated
|
||||
@@ -2304,12 +2304,12 @@ export type QuestionNotFoundError = {
|
||||
export const isQuestionNotFoundError = (value: unknown): value is QuestionNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "QuestionNotFoundError"
|
||||
|
||||
export type WorktreeError = {
|
||||
readonly name: "WorktreeError"
|
||||
export type ProjectCopyError = {
|
||||
readonly name: "ProjectCopyError"
|
||||
readonly data: { readonly message: string; readonly forceRequired?: boolean | undefined }
|
||||
}
|
||||
export const isWorktreeError = (value: unknown): value is WorktreeError =>
|
||||
typeof value === "object" && value !== null && "name" in value && value["name"] === "WorktreeError"
|
||||
export const isProjectCopyError = (value: unknown): value is ProjectCopyError =>
|
||||
typeof value === "object" && value !== null && "name" in value && value["name"] === "ProjectCopyError"
|
||||
|
||||
export type HealthGetOutput = ServiceHealth
|
||||
|
||||
@@ -4348,6 +4348,15 @@ export type ProjectCurrentInput = {
|
||||
|
||||
export type ProjectCurrentOutput = ProjectCurrent
|
||||
|
||||
export type ProjectDirectoriesInput = {
|
||||
readonly projectID: { readonly projectID: string }["projectID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type ProjectDirectoriesOutput = ProjectDirectories
|
||||
|
||||
export type FormRequestListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
@@ -5633,30 +5642,37 @@ export type ReferenceListOutput = {
|
||||
data: Array<ReferenceInfo>
|
||||
}
|
||||
|
||||
export type WorktreeListInput = { readonly projectID: { readonly projectID: string }["projectID"] }
|
||||
|
||||
export type WorktreeListOutput = WorktreeList
|
||||
|
||||
export type WorktreeCreateInput = {
|
||||
export type ProjectCopyCreateInput = {
|
||||
readonly projectID: { readonly projectID: string }["projectID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly strategy: { readonly strategy: string; readonly directory: string; readonly name?: string }["strategy"]
|
||||
readonly directory: { readonly strategy: string; readonly directory: string; readonly name?: string }["directory"]
|
||||
readonly name?: { readonly strategy: string; readonly directory: string; readonly name?: string }["name"]
|
||||
}
|
||||
|
||||
export type WorktreeCreateOutput = WorktreeInfo
|
||||
export type ProjectCopyCreateOutput = ProjectCopyCopy
|
||||
|
||||
export type WorktreeRemoveInput = {
|
||||
export type ProjectCopyRemoveInput = {
|
||||
readonly projectID: { readonly projectID: string }["projectID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly directory: { readonly directory: string; readonly force: boolean }["directory"]
|
||||
readonly force: { readonly directory: string; readonly force: boolean }["force"]
|
||||
}
|
||||
|
||||
export type WorktreeRemoveOutput = void
|
||||
export type ProjectCopyRemoveOutput = void
|
||||
|
||||
export type WorktreeRefreshInput = { readonly projectID: { readonly projectID: string }["projectID"] }
|
||||
export type ProjectCopyRefreshInput = {
|
||||
readonly projectID: { readonly projectID: string }["projectID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type WorktreeRefreshOutput = void
|
||||
export type ProjectCopyRefreshOutput = void
|
||||
|
||||
export type VcsGetInput = {
|
||||
readonly location?: {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "../service-contender.js"
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
import { matchesVersion } from "../service-version.js"
|
||||
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
|
||||
import type { ServiceStopResponse } from "./generated/types.js"
|
||||
|
||||
export * from "../service.js"
|
||||
|
||||
@@ -130,7 +130,15 @@ async function read(file?: string) {
|
||||
const text = await readFile(file ?? fallback(), "utf8").catch(() => undefined)
|
||||
if (text === undefined) return undefined
|
||||
try {
|
||||
return JSON.parse(text) as Info
|
||||
const value: unknown = JSON.parse(text)
|
||||
if (typeof value !== "object" || value === null) return undefined
|
||||
if (!("url" in value) || typeof value.url !== "string") return undefined
|
||||
if (!("pid" in value) || !Number.isInteger(value.pid) || typeof value.pid !== "number" || value.pid <= 0)
|
||||
return undefined
|
||||
if ("id" in value && value.id !== undefined && typeof value.id !== "string") return undefined
|
||||
if ("version" in value && value.version !== undefined && typeof value.version !== "string") return undefined
|
||||
if ("password" in value && value.password !== undefined && typeof value.password !== "string") return undefined
|
||||
return value as Info
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
@@ -163,7 +171,7 @@ async function probeResult(info: Info, allowLegacy = false, timeout = defaultEns
|
||||
})
|
||||
.then(async (response) => ({
|
||||
response,
|
||||
body: (await response.json()) as ServiceHealth | { readonly healthy: true },
|
||||
body: (await response.json()) as unknown,
|
||||
}))
|
||||
.then(
|
||||
(value) => ({ value }),
|
||||
@@ -172,7 +180,18 @@ async function probeResult(info: Info, allowLegacy = false, timeout = defaultEns
|
||||
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
|
||||
const response = result.value.response
|
||||
const body = result.value.body
|
||||
if (body !== undefined && "version" in body && "pid" in body) {
|
||||
if (
|
||||
typeof body === "object" &&
|
||||
body !== null &&
|
||||
"healthy" in body &&
|
||||
body.healthy === true &&
|
||||
"version" in body &&
|
||||
typeof body.version === "string" &&
|
||||
"pid" in body &&
|
||||
typeof body.pid === "number" &&
|
||||
Number.isInteger(body.pid) &&
|
||||
body.pid > 0
|
||||
) {
|
||||
if (body.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||
if (info.version !== undefined && body.version !== info.version) return { service: undefined, timedOut: false }
|
||||
return {
|
||||
@@ -186,7 +205,16 @@ async function probeResult(info: Info, allowLegacy = false, timeout = defaultEns
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
if (!allowLegacy || body?.healthy !== true) return { service: undefined, timedOut: false }
|
||||
if (
|
||||
!allowLegacy ||
|
||||
typeof body !== "object" ||
|
||||
body === null ||
|
||||
!("healthy" in body) ||
|
||||
body.healthy !== true ||
|
||||
"version" in body ||
|
||||
"pid" in body
|
||||
)
|
||||
return { service: undefined, timedOut: false }
|
||||
return {
|
||||
service: { info, endpoint, state: "ready", legacy: true } satisfies LocalService,
|
||||
timedOut: false,
|
||||
|
||||
@@ -38,6 +38,50 @@ test("discovers a compatible registered service", async () => {
|
||||
expect(await Service.discover({ file: registration, version: (version) => version.startsWith("3.") })).toBeUndefined()
|
||||
})
|
||||
|
||||
test("rejects malformed registrations without probing or signaling", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const malformed = [
|
||||
null,
|
||||
[],
|
||||
{},
|
||||
{ url: "http://127.0.0.1:1" },
|
||||
{ url: "http://127.0.0.1:1", pid: 0 },
|
||||
{ url: "http://127.0.0.1:1", pid: -1 },
|
||||
{ url: "http://127.0.0.1:1", pid: 1.5 },
|
||||
{ url: "http://127.0.0.1:1", pid: "1" },
|
||||
{ url: "http://127.0.0.1:1", pid: 1, id: 1 },
|
||||
]
|
||||
|
||||
for (const value of malformed) {
|
||||
await Bun.write(registration, JSON.stringify(value))
|
||||
expect(await Service.discover({ file: registration })).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects primitive and partial modern health responses", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const bodies = [
|
||||
null,
|
||||
1,
|
||||
"healthy",
|
||||
[],
|
||||
{},
|
||||
{ healthy: false, version: "test", pid: process.pid },
|
||||
{ healthy: true, version: null, pid: process.pid },
|
||||
{ healthy: true, version: "test", pid: "1" },
|
||||
{ healthy: true, version: "test" },
|
||||
{ healthy: true, pid: process.pid },
|
||||
]
|
||||
|
||||
for (const body of bodies) {
|
||||
using server = Bun.serve({ port: 0, fetch: () => Response.json(body) })
|
||||
await Bun.write(registration, JSON.stringify({ url: server.url.toString(), pid: process.pid }))
|
||||
expect(await Service.discover({ file: registration })).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
test("ensures a missing service with native promises", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
|
||||
@@ -29,7 +29,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
"shell",
|
||||
"question",
|
||||
"reference",
|
||||
"worktree",
|
||||
"projectCopy",
|
||||
"vcs",
|
||||
"debug",
|
||||
"migration",
|
||||
@@ -49,8 +49,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.vcs)).toEqual(["get", "status", "diff"])
|
||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
|
||||
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
|
||||
expect(Object.keys(client.project)).toEqual(["list", "current"])
|
||||
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
|
||||
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
|
||||
})
|
||||
|
||||
test("config.get returns ordered config entries for a location", async () => {
|
||||
@@ -252,44 +251,30 @@ test("file.read returns binary content from the public HTTP contract", async ()
|
||||
)
|
||||
})
|
||||
|
||||
test("worktree methods use the global project contract", async () => {
|
||||
const requests: Request[] = []
|
||||
test("project methods use the public HTTP contract", async () => {
|
||||
const requests: string[] = []
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push(request)
|
||||
if (request.method === "GET") return Response.json([{ directory: "/tmp/project" }])
|
||||
if (request.method === "POST" && !request.url.endsWith("/refresh"))
|
||||
return Response.json({ directory: "/tmp/worktrees/api" })
|
||||
return new Response(null, { status: 204 })
|
||||
fetch: async (input) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
|
||||
requests.push(url)
|
||||
if (url.includes("/directories")) return Response.json([])
|
||||
return Response.json({ id: "proj_test", directory: "/tmp/project" })
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.worktree.list({ projectID: "proj_test" })).toEqual([{ directory: "/tmp/project" }])
|
||||
expect(
|
||||
await client.worktree.create({
|
||||
projectID: "proj_test",
|
||||
strategy: "git",
|
||||
directory: "/tmp/worktrees",
|
||||
name: "api",
|
||||
}),
|
||||
).toEqual({ directory: "/tmp/worktrees/api" })
|
||||
await client.worktree.remove({
|
||||
projectID: "proj_test",
|
||||
directory: "/tmp/worktrees/api",
|
||||
force: false,
|
||||
const current = await client.project.current({ location: { workspace: "wrk_test" } })
|
||||
const directories = await client.project.directories({
|
||||
projectID: current.id,
|
||||
location: { directory: current.directory },
|
||||
})
|
||||
await client.worktree.refresh({ projectID: "proj_test" })
|
||||
|
||||
expect(requests.map((request) => [request.method, request.url])).toEqual([
|
||||
["GET", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
|
||||
["POST", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
|
||||
["DELETE", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
|
||||
["POST", "http://localhost:3000/api/experimental/project/proj_test/worktree/refresh"],
|
||||
expect(current).toEqual({ id: "proj_test", directory: "/tmp/project" })
|
||||
expect(directories).toEqual([])
|
||||
expect(requests).toEqual([
|
||||
"http://localhost:3000/api/project/current?location%5Bworkspace%5D=wrk_test",
|
||||
"http://localhost:3000/api/project/proj_test/directories?location%5Bdirectory%5D=%2Ftmp%2Fproject",
|
||||
])
|
||||
expect(await requests[1]?.json()).toEqual({ strategy: "git", directory: "/tmp/worktrees", name: "api" })
|
||||
expect(await requests[2]?.json()).toEqual({ directory: "/tmp/worktrees/api", force: false })
|
||||
})
|
||||
|
||||
test("shell list and remove use the public HTTP contract", async () => {
|
||||
|
||||
+41
-191
@@ -1,10 +1,8 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936",
|
||||
"prevIds": [
|
||||
"5c1aa56b-c3ee-4283-9a84-c0bf626dc604"
|
||||
],
|
||||
"id": "5c1aa56b-c3ee-4283-9a84-c0bf626dc604",
|
||||
"prevIds": ["00924d88-1842-4d71-ac74-5682ddc47e1c"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "account_state",
|
||||
@@ -78,10 +76,6 @@
|
||||
"name": "workspace",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "worktree",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
@@ -1443,53 +1437,9 @@
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "project_id",
|
||||
"entityType": "columns",
|
||||
"table": "worktree"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "directory",
|
||||
"entityType": "columns",
|
||||
"table": "worktree"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "strategy",
|
||||
"entityType": "columns",
|
||||
"table": "worktree"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_created",
|
||||
"entityType": "columns",
|
||||
"table": "worktree"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"active_account_id"
|
||||
],
|
||||
"columns": ["active_account_id"],
|
||||
"tableTo": "account",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "SET NULL",
|
||||
"nameExplicit": false,
|
||||
@@ -1498,13 +1448,9 @@
|
||||
"table": "account_state"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"aggregate_id"
|
||||
],
|
||||
"columns": ["aggregate_id"],
|
||||
"tableTo": "event_sequence",
|
||||
"columnsTo": [
|
||||
"aggregate_id"
|
||||
],
|
||||
"columnsTo": ["aggregate_id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1513,13 +1459,9 @@
|
||||
"table": "event"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"project_id"
|
||||
],
|
||||
"columns": ["project_id"],
|
||||
"tableTo": "project",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1528,13 +1470,9 @@
|
||||
"table": "permission"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"project_id"
|
||||
],
|
||||
"columns": ["project_id"],
|
||||
"tableTo": "project",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1543,13 +1481,9 @@
|
||||
"table": "project_directory"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"columns": ["session_id"],
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1558,13 +1492,9 @@
|
||||
"table": "instruction_entry"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"columns": ["session_id"],
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1573,13 +1503,9 @@
|
||||
"table": "instruction_state"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"columns": ["session_id"],
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1588,13 +1514,9 @@
|
||||
"table": "session_inbox"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"columns": ["session_id"],
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1603,13 +1525,9 @@
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"columns": ["session_id"],
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1618,13 +1536,9 @@
|
||||
"table": "session_pending"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"project_id"
|
||||
],
|
||||
"columns": ["project_id"],
|
||||
"tableTo": "project",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1633,190 +1547,126 @@
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"project_id"
|
||||
],
|
||||
"tableTo": "project",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_worktree_project_id_project_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "worktree"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"email",
|
||||
"url"
|
||||
],
|
||||
"columns": ["email", "url"],
|
||||
"nameExplicit": false,
|
||||
"name": "control_account_pk",
|
||||
"entityType": "pks",
|
||||
"table": "control_account"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"project_id",
|
||||
"directory"
|
||||
],
|
||||
"columns": ["project_id", "directory"],
|
||||
"nameExplicit": false,
|
||||
"name": "project_directory_pk",
|
||||
"entityType": "pks",
|
||||
"table": "project_directory"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id",
|
||||
"key"
|
||||
],
|
||||
"columns": ["session_id", "key"],
|
||||
"nameExplicit": false,
|
||||
"name": "instruction_entry_pk",
|
||||
"entityType": "pks",
|
||||
"table": "instruction_entry"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"project_id",
|
||||
"directory"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "worktree_pk",
|
||||
"entityType": "pks",
|
||||
"table": "worktree"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "account_state_pk",
|
||||
"table": "account_state",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "account_pk",
|
||||
"table": "account",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "credential_pk",
|
||||
"table": "credential",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"aggregate_id"
|
||||
],
|
||||
"columns": ["aggregate_id"],
|
||||
"nameExplicit": false,
|
||||
"name": "event_sequence_pk",
|
||||
"table": "event_sequence",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "event_pk",
|
||||
"table": "event",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"key"
|
||||
],
|
||||
"columns": ["key"],
|
||||
"nameExplicit": false,
|
||||
"name": "kv_pk",
|
||||
"table": "kv",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "permission_pk",
|
||||
"table": "permission",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "project_pk",
|
||||
"table": "project",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"hash"
|
||||
],
|
||||
"columns": ["hash"],
|
||||
"nameExplicit": false,
|
||||
"name": "instruction_blob_pk",
|
||||
"table": "instruction_blob",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"columns": ["session_id"],
|
||||
"nameExplicit": false,
|
||||
"name": "instruction_state_pk",
|
||||
"table": "instruction_state",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "session_inbox_pk",
|
||||
"table": "session_inbox",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "session_message_pk",
|
||||
"table": "session_message",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "session_pending_pk",
|
||||
"table": "session_pending",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "session_v2_pk",
|
||||
"table": "session_v2",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
"name": "workspace_pk",
|
||||
"table": "workspace",
|
||||
@@ -2112,4 +1962,4 @@
|
||||
}
|
||||
],
|
||||
"renames": []
|
||||
}
|
||||
}
|
||||
|
||||
-2
@@ -42,7 +42,6 @@ import m39 from "./migration/20260805200742_import_legacy_credentials.js"
|
||||
import m40 from "./migration/20260808023530_workspace_domain.js"
|
||||
import m41 from "./migration/20260811161259_execution_claim_attempts.js"
|
||||
import m42 from "./migration/20260812181746_session_inbox.js"
|
||||
import m43 from "./migration/20260812213948_worktree.js"
|
||||
|
||||
export const migrations = [
|
||||
m00,
|
||||
@@ -88,5 +87,4 @@ export const migrations = [
|
||||
m40,
|
||||
m41,
|
||||
m42,
|
||||
m43,
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260812213948_worktree",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`worktree\` (
|
||||
\`project_id\` text NOT NULL,
|
||||
\`directory\` text NOT NULL,
|
||||
\`strategy\` text,
|
||||
\`time_created\` integer NOT NULL,
|
||||
CONSTRAINT \`worktree_pk\` PRIMARY KEY(\`project_id\`, \`directory\`),
|
||||
CONSTRAINT \`fk_worktree_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
INSERT INTO \`worktree\` (\`project_id\`, \`directory\`, \`strategy\`, \`time_created\`)
|
||||
SELECT
|
||||
\`project_id\`,
|
||||
\`directory\`,
|
||||
CASE
|
||||
WHEN \`strategy\` = 'git_worktree' THEN 'git'
|
||||
WHEN \`strategy\` IS NOT NULL THEN \`strategy\`
|
||||
WHEN \`type\` = 'git_worktree' THEN 'git'
|
||||
END,
|
||||
\`time_created\`
|
||||
FROM \`project_directory\`;
|
||||
`)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default migration
|
||||
@@ -225,16 +225,6 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
\`last_used_at\` integer NOT NULL
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`worktree\` (
|
||||
\`project_id\` text NOT NULL,
|
||||
\`directory\` text NOT NULL,
|
||||
\`strategy\` text,
|
||||
\`time_created\` integer NOT NULL,
|
||||
CONSTRAINT \`worktree_pk\` PRIMARY KEY(\`project_id\`, \`directory\`),
|
||||
CONSTRAINT \`fk_worktree_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`)
|
||||
yield* tx.run(
|
||||
|
||||
@@ -25,7 +25,7 @@ import { MCP } from "./mcp/index.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { Plugin } from "./plugin.js"
|
||||
import { PluginSupervisor } from "./plugin/supervisor.js"
|
||||
import { Worktree } from "./worktree.js"
|
||||
import { ProjectCopy } from "./project/copy.js"
|
||||
import { Pty } from "./pty.js"
|
||||
import { Question } from "./question.js"
|
||||
import { Shell } from "./shell.js"
|
||||
@@ -66,7 +66,8 @@ const locationServiceNodes = [
|
||||
AISDK.node,
|
||||
Plugin.node,
|
||||
PluginSupervisor.node,
|
||||
Worktree.refreshNode,
|
||||
ProjectCopy.node,
|
||||
ProjectCopy.refreshNode,
|
||||
FileSystemSearch.node,
|
||||
FileSystem.node,
|
||||
Pty.node,
|
||||
|
||||
@@ -2,20 +2,20 @@ export * as Project from "./project.js"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { and, asc, desc, eq } from "drizzle-orm"
|
||||
import { asc, desc } from "drizzle-orm"
|
||||
import path from "path"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Database } from "./database/database.js"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { Event } from "@opencode-ai/schema/project-directories"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Git } from "./git.js"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { ProjectDirectories } from "./project/directories.js"
|
||||
import { ProjectSchema } from "./project/schema.js"
|
||||
import { ProjectTable, upsertProject } from "./project/sql.js"
|
||||
import { WorktreeTable } from "./worktree/sql.js"
|
||||
|
||||
export const ID = ProjectSchema.ID
|
||||
export type ID = ProjectSchema.ID
|
||||
@@ -26,9 +26,18 @@ export type Vcs = ProjectSchema.Vcs
|
||||
export const Current = ProjectSchema.Current
|
||||
export type Current = ProjectSchema.Current
|
||||
|
||||
export const Directory = ProjectSchema.Directory
|
||||
export type Directory = ProjectSchema.Directory
|
||||
|
||||
export const Info = ProjectSchema.Info
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
|
||||
export const DirectoriesInput = ProjectSchema.DirectoriesInput
|
||||
export type DirectoriesInput = typeof DirectoriesInput.Type
|
||||
|
||||
export const Directories = ProjectSchema.Directories
|
||||
export type Directories = typeof Directories.Type
|
||||
|
||||
export interface Resolved {
|
||||
readonly previous?: ID
|
||||
readonly id: ID
|
||||
@@ -47,6 +56,7 @@ export const root = Effect.fn("Project.root")(function* (fs: FSUtil.Interface, i
|
||||
|
||||
export interface Interface {
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
|
||||
readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
|
||||
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
|
||||
}
|
||||
|
||||
@@ -85,19 +95,18 @@ const layer = Layer.effect(
|
||||
const proc = yield* AppProcess.Service
|
||||
const bus = yield* Bus.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const projectDirectories = yield* ProjectDirectories.Service
|
||||
|
||||
const announcing = new Set<string>()
|
||||
const persist = Effect.fnUntraced(function* (project: Resolved) {
|
||||
yield* upsertProject(db, project).pipe(Effect.orDie)
|
||||
if (!project.vcs) return project
|
||||
const directories: Array<{ projectID: ID; directory: AbsolutePath; strategy?: string }> = [
|
||||
{ projectID: project.id, directory: project.canonical },
|
||||
]
|
||||
const directories: ProjectDirectories.CreateInput[] = [{ projectID: project.id, directory: project.canonical }]
|
||||
if (project.directory !== project.canonical)
|
||||
directories.push({
|
||||
projectID: project.id,
|
||||
directory: project.directory,
|
||||
strategy: project.vcs.type === "git" ? "git" : undefined,
|
||||
strategy: project.vcs.type === "git" ? "git_worktree" : undefined,
|
||||
})
|
||||
// A missing directory row means this directory's resolution is a new durable
|
||||
// fact (copy.ts registers copy directories directly; those never strand
|
||||
@@ -110,25 +119,11 @@ const layer = Layer.effect(
|
||||
if (announcing.has(key)) continue
|
||||
announcing.add(key)
|
||||
yield* Effect.gen(function* () {
|
||||
const stored = yield* db
|
||||
.select({ directory: WorktreeTable.directory })
|
||||
.from(WorktreeTable)
|
||||
.where(and(eq(WorktreeTable.project_id, item.projectID), eq(WorktreeTable.directory, item.directory)))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (stored) return
|
||||
if (yield* projectDirectories.get({ projectID: item.projectID, directory: item.directory })) return
|
||||
yield* bus.publish(
|
||||
Worktree.Event.Resolved,
|
||||
Event.Resolved,
|
||||
{ projectID: item.projectID, directory: item.directory, previous: project.previous ?? ID.global },
|
||||
{
|
||||
commit: () =>
|
||||
db
|
||||
.insert(WorktreeTable)
|
||||
.values({ project_id: item.projectID, directory: item.directory, strategy: item.strategy })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie, Effect.asVoid),
|
||||
},
|
||||
{ commit: () => Effect.asVoid(projectDirectories.create(item)) },
|
||||
)
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => announcing.delete(key))))
|
||||
}
|
||||
@@ -145,6 +140,10 @@ const layer = Layer.effect(
|
||||
return rows.map(fromRow)
|
||||
})
|
||||
|
||||
const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) {
|
||||
return yield* projectDirectories.list(input.projectID)
|
||||
})
|
||||
|
||||
const cached = Effect.fnUntraced(function* (dir: string) {
|
||||
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
|
||||
Effect.map((value) => value.trim()),
|
||||
@@ -258,12 +257,12 @@ const layer = Layer.effect(
|
||||
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
|
||||
})
|
||||
|
||||
return Service.of({ list, resolve })
|
||||
return Service.of({ list, directories, resolve })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [Bus.node, Database.node, FSUtil.node, Git.node, AppProcess.node],
|
||||
deps: [Bus.node, Database.node, FSUtil.node, Git.node, AppProcess.node, ProjectDirectories.node],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Effect } from "effect"
|
||||
import { AbsolutePath } from "../schema.js"
|
||||
import { Git } from "../git.js"
|
||||
import { DirectoryUnavailableError, StrategyID, type ListEntry, type Strategy } from "./copy.js"
|
||||
|
||||
export function makeGitWorktreeStrategy(input: {
|
||||
git: Git.Interface
|
||||
canonical: (directory: AbsolutePath) => Effect.Effect<AbsolutePath, DirectoryUnavailableError>
|
||||
}) {
|
||||
return {
|
||||
id: StrategyID.make("git_worktree"),
|
||||
create: Effect.fn("ProjectCopy.GitWorktree.create")(function* (options) {
|
||||
const repository = yield* input.git.repo.discover(options.sourceDirectory)
|
||||
if (!repository) return yield* new DirectoryUnavailableError({ directory: options.sourceDirectory })
|
||||
yield* input.git.worktree.create({ repository, directory: options.directory })
|
||||
return { directory: yield* input.canonical(options.directory) }
|
||||
}),
|
||||
remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (options) {
|
||||
const found = yield* input.git.repo.discover(options.directory)
|
||||
if (!found) return yield* new DirectoryUnavailableError({ directory: options.directory })
|
||||
yield* input.git.worktree.remove({ repository: found, directory: options.directory, force: options.force })
|
||||
}),
|
||||
list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) {
|
||||
const found = yield* input.git.repo.discover(directory)
|
||||
if (!found) return yield* new DirectoryUnavailableError({ directory })
|
||||
const entries = yield* input.git.worktree.list(found)
|
||||
return yield* Effect.forEach(entries, (entry) =>
|
||||
input.canonical(entry.directory).pipe(
|
||||
Effect.map((directory) => ({ directory, type: entry.kind === "main" ? "root" : "copy" }) as const),
|
||||
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined)))
|
||||
}),
|
||||
} satisfies Strategy
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
export * as ProjectCopy from "./copy.js"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { AbsolutePath } from "../schema.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Git } from "../git.js"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Project } from "../project.js"
|
||||
import { ProjectDirectories } from "./directories.js"
|
||||
import { makeGitWorktreeStrategy } from "./copy-strategies.js"
|
||||
import { Slug } from "../util/slug.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Location } from "../location.js"
|
||||
import { Event } from "@opencode-ai/schema/project-directories"
|
||||
import { ProjectCopy } from "@opencode-ai/schema/project-copy"
|
||||
|
||||
export const StrategyID = ProjectCopy.StrategyID
|
||||
export type StrategyID = typeof StrategyID.Type
|
||||
|
||||
export const CreateInput = ProjectCopy.CreateInput
|
||||
export type CreateInput = typeof CreateInput.Type
|
||||
|
||||
export const RemoveInput = ProjectCopy.RemoveInput
|
||||
export type RemoveInput = typeof RemoveInput.Type
|
||||
|
||||
export const RefreshInput = Schema.Struct({
|
||||
projectID: Project.ID,
|
||||
}).annotate({ identifier: "ProjectCopy.RefreshInput" })
|
||||
export type RefreshInput = typeof RefreshInput.Type
|
||||
|
||||
export const RefreshResult = Schema.Struct({
|
||||
updated: Schema.Array(AbsolutePath),
|
||||
removed: Schema.Array(AbsolutePath),
|
||||
}).annotate({ identifier: "ProjectCopy.RefreshResult" })
|
||||
export type RefreshResult = typeof RefreshResult.Type
|
||||
|
||||
export const Copy = ProjectCopy.Copy
|
||||
export type Copy = typeof Copy.Type
|
||||
|
||||
export const ListEntry = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
type: Schema.Literals(["root", "copy"]),
|
||||
}).annotate({ identifier: "ProjectCopy.ListEntry" })
|
||||
export type ListEntry = typeof ListEntry.Type
|
||||
|
||||
export class SourceDirectoryNotFoundError extends Schema.TaggedErrorClass<SourceDirectoryNotFoundError>()(
|
||||
"ProjectCopy.SourceDirectoryNotFoundError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class DestinationExistsError extends Schema.TaggedErrorClass<DestinationExistsError>()(
|
||||
"ProjectCopy.DestinationExistsError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class DirectoryUnavailableError extends Schema.TaggedErrorClass<DirectoryUnavailableError>()(
|
||||
"ProjectCopy.DirectoryUnavailableError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class InvalidDirectoryError extends Schema.TaggedErrorClass<InvalidDirectoryError>()(
|
||||
"ProjectCopy.InvalidDirectoryError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class StrategyUnavailableError extends Schema.TaggedErrorClass<StrategyUnavailableError>()(
|
||||
"ProjectCopy.StrategyUnavailableError",
|
||||
{ strategy: StrategyID },
|
||||
) {}
|
||||
|
||||
export type Error =
|
||||
| SourceDirectoryNotFoundError
|
||||
| DestinationExistsError
|
||||
| DirectoryUnavailableError
|
||||
| InvalidDirectoryError
|
||||
| StrategyUnavailableError
|
||||
| Git.WorktreeError
|
||||
|
||||
export interface Strategy {
|
||||
readonly id: StrategyID
|
||||
readonly create: (input: {
|
||||
sourceDirectory: AbsolutePath
|
||||
directory: AbsolutePath
|
||||
}) => Effect.Effect<Copy, Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly remove: (input: {
|
||||
directory: AbsolutePath
|
||||
force: boolean
|
||||
}) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly list: (directory: AbsolutePath) => Effect.Effect<ListEntry[], Git.WorktreeError | DirectoryUnavailableError>
|
||||
}
|
||||
|
||||
export { Event }
|
||||
|
||||
export interface Interface {
|
||||
readonly create: (input: CreateInput) => Effect.Effect<Copy, Error>
|
||||
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
|
||||
readonly refresh: (input: RefreshInput) => Effect.Effect<RefreshResult, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectCopy") {}
|
||||
|
||||
export const refreshAfterBoot = Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const copies = yield* Service
|
||||
yield* Effect.gen(function* () {
|
||||
yield* Effect.logInfo("project copy refresh started", { projectID: location.project.id })
|
||||
const result = yield* copies.refresh({ projectID: location.project.id })
|
||||
yield* Effect.logInfo("project copy refresh done", {
|
||||
projectID: location.project.id,
|
||||
updated: result.updated,
|
||||
removed: result.removed,
|
||||
})
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => Effect.logWarning("project copy refresh failed", { cause })),
|
||||
Effect.forkScoped,
|
||||
Effect.asVoid,
|
||||
)
|
||||
})
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
const directories = yield* ProjectDirectories.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const changed = Effect.fnUntraced(function* (projectID: Project.ID, update: boolean) {
|
||||
if (update) yield* bus.publish(Event.Updated, { projectID })
|
||||
})
|
||||
|
||||
const canonical = Effect.fnUntraced(function* (input: AbsolutePath) {
|
||||
const resolved = AbsolutePath.make(yield* fs.resolve(input))
|
||||
if (!(yield* fs.isDir(resolved))) return yield* new DirectoryUnavailableError({ directory: input })
|
||||
return resolved
|
||||
})
|
||||
|
||||
const strategy = makeGitWorktreeStrategy({ git, canonical })
|
||||
|
||||
const source = Effect.fnUntraced(function* (input: AbsolutePath, projectID: Project.ID) {
|
||||
const sourceDirectory = yield* canonical(input)
|
||||
if ((yield* directories.get({ projectID, directory: sourceDirectory })) === undefined)
|
||||
return yield* new SourceDirectoryNotFoundError({ directory: sourceDirectory })
|
||||
return sourceDirectory
|
||||
})
|
||||
|
||||
const getStrategy = Effect.fnUntraced(function* (id: StrategyID) {
|
||||
if (id !== strategy.id) return yield* new StrategyUnavailableError({ strategy: id })
|
||||
return strategy
|
||||
})
|
||||
|
||||
const create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) {
|
||||
const selected = yield* getStrategy(input.strategy)
|
||||
const sourceDirectory = yield* source(input.sourceDirectory, input.projectID)
|
||||
yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie)
|
||||
const name = input.name ?? Slug.create()
|
||||
let suffix = 1
|
||||
let copyDirectory = AbsolutePath.make(path.join(input.directory, name))
|
||||
while (yield* fs.existsSafe(copyDirectory)) {
|
||||
suffix++
|
||||
if (suffix > 10) return yield* new DestinationExistsError({ directory: copyDirectory })
|
||||
copyDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
|
||||
}
|
||||
|
||||
const result = yield* selected.create({
|
||||
directory: copyDirectory,
|
||||
sourceDirectory,
|
||||
})
|
||||
yield* changed(
|
||||
input.projectID,
|
||||
yield* directories.create({
|
||||
projectID: input.projectID,
|
||||
directory: result.directory,
|
||||
strategy: input.strategy,
|
||||
behavior: "replace",
|
||||
}),
|
||||
)
|
||||
return result
|
||||
})
|
||||
|
||||
const remove = Effect.fn("ProjectCopy.remove")(function* (input: RemoveInput) {
|
||||
const copyDirectory = yield* canonical(input.directory)
|
||||
const stored = yield* directories.get({ projectID: input.projectID, directory: copyDirectory })
|
||||
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: copyDirectory })
|
||||
const strategy = yield* getStrategy(StrategyID.make(stored.strategy))
|
||||
yield* strategy.remove({
|
||||
directory: copyDirectory,
|
||||
force: input.force,
|
||||
})
|
||||
yield* changed(
|
||||
input.projectID,
|
||||
yield* directories.remove({ projectID: input.projectID, directory: copyDirectory }),
|
||||
)
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("ProjectCopy.refresh")(function* (input: RefreshInput) {
|
||||
const stored = yield* directories.list(input.projectID)
|
||||
const checked = yield* Effect.forEach(
|
||||
stored,
|
||||
(item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const sourceDirectories = checked
|
||||
.filter((item) => item.strategy === undefined && item.exists)
|
||||
.map((item) => item.directory)
|
||||
const discovered = yield* Effect.forEach(
|
||||
sourceDirectories,
|
||||
(sourceDirectory) =>
|
||||
strategy.list(sourceDirectory).pipe(
|
||||
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed([])),
|
||||
Effect.map((items) =>
|
||||
items.map((item) => ({
|
||||
directory: item.directory,
|
||||
strategy: item.type === "copy" ? strategy.id : undefined,
|
||||
})),
|
||||
),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(
|
||||
Effect.map((sets) => new Map(sets.flat().map((item) => [item.directory, item] as const)).values().toArray()),
|
||||
)
|
||||
const removed = checked.filter((item) => !item.exists).map((item) => item.directory)
|
||||
const result = yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.all({
|
||||
updated: Effect.forEach(discovered, (item) =>
|
||||
directories.create(
|
||||
{
|
||||
projectID: input.projectID,
|
||||
directory: item.directory,
|
||||
strategy: item.strategy,
|
||||
behavior: "replace",
|
||||
},
|
||||
tx,
|
||||
),
|
||||
),
|
||||
removed: Effect.forEach(removed, (directory) =>
|
||||
directories.remove({ projectID: input.projectID, directory }, tx),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
const changes = {
|
||||
updated: discovered.filter((_, index) => result.updated[index]).map((item) => item.directory),
|
||||
removed: removed.filter((_, index) => result.removed[index]),
|
||||
}
|
||||
yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
|
||||
return changes
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
create,
|
||||
remove,
|
||||
refresh,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Git.node, ProjectDirectories.node, Bus.node, Database.node],
|
||||
})
|
||||
|
||||
export const refreshNode = makeLocationNode({
|
||||
name: "project-copy-refresh",
|
||||
layer: Layer.effectDiscard(refreshAfterBoot),
|
||||
deps: [node, Location.node],
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
export * as ProjectDirectories from "./directories.js"
|
||||
|
||||
import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "../database/database.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { AbsolutePath } from "../schema.js"
|
||||
import { ProjectSchema } from "./schema.js"
|
||||
import { ProjectDirectoryTable } from "./sql.js"
|
||||
import type { EffectDrizzleSqlite } from "../database/drizzle.js"
|
||||
import type { Project } from "../project.js"
|
||||
|
||||
export type Directory = Project.Directory
|
||||
|
||||
export const CreateInput = Schema.Struct({
|
||||
projectID: ProjectSchema.ID,
|
||||
directory: AbsolutePath,
|
||||
strategy: Schema.optional(Schema.String),
|
||||
behavior: Schema.Literals(["ignore", "replace"]).pipe(Schema.optional),
|
||||
})
|
||||
export type CreateInput = typeof CreateInput.Type
|
||||
|
||||
export const RemoveInput = Schema.Struct({
|
||||
projectID: ProjectSchema.ID,
|
||||
directory: AbsolutePath,
|
||||
})
|
||||
export type RemoveInput = typeof RemoveInput.Type
|
||||
|
||||
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
|
||||
export type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
|
||||
|
||||
export const ListInput = ProjectSchema.DirectoriesInput
|
||||
export type ListInput = typeof ListInput.Type
|
||||
|
||||
export const ListOutput = ProjectSchema.Directories
|
||||
export type ListOutput = typeof ListOutput.Type
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (projectID: ProjectSchema.ID) => Effect.Effect<ReadonlyArray<Directory>>
|
||||
readonly get: (input: {
|
||||
projectID: ProjectSchema.ID
|
||||
directory: AbsolutePath
|
||||
}) => Effect.Effect<Directory | undefined>
|
||||
readonly create: (input: CreateInput, tx?: Transaction) => Effect.Effect<boolean>
|
||||
readonly remove: (input: RemoveInput, tx?: Transaction) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectDirectories") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
|
||||
const create = Effect.fn("ProjectDirectories.create")(function* (input: CreateInput, tx?: Transaction) {
|
||||
const insert = (tx ?? db)
|
||||
.insert(ProjectDirectoryTable)
|
||||
.values({ project_id: input.projectID, directory: input.directory, strategy: input.strategy })
|
||||
const query =
|
||||
input.behavior === "replace"
|
||||
? insert.onConflictDoUpdate({
|
||||
target: [ProjectDirectoryTable.project_id, ProjectDirectoryTable.directory],
|
||||
set: { strategy: input.strategy ?? null },
|
||||
setWhere: input.strategy
|
||||
? or(isNull(ProjectDirectoryTable.strategy), ne(ProjectDirectoryTable.strategy, input.strategy))
|
||||
: isNotNull(ProjectDirectoryTable.strategy),
|
||||
})
|
||||
: insert.onConflictDoNothing()
|
||||
return (
|
||||
(yield* query.returning({ directory: ProjectDirectoryTable.directory }).get().pipe(Effect.orDie)) !== undefined
|
||||
)
|
||||
})
|
||||
|
||||
const remove = Effect.fn("ProjectDirectories.remove")(function* (input: RemoveInput, tx?: Transaction) {
|
||||
return (
|
||||
(yield* (tx ?? db)
|
||||
.delete(ProjectDirectoryTable)
|
||||
.where(
|
||||
and(
|
||||
eq(ProjectDirectoryTable.project_id, input.projectID),
|
||||
eq(ProjectDirectoryTable.directory, input.directory),
|
||||
),
|
||||
)
|
||||
.returning({ directory: ProjectDirectoryTable.directory })
|
||||
.get()
|
||||
.pipe(Effect.orDie)) !== undefined
|
||||
)
|
||||
})
|
||||
|
||||
const list = Effect.fn("ProjectDirectories.list")(function* (projectID: ProjectSchema.ID) {
|
||||
const rows = yield* db
|
||||
.select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(eq(ProjectDirectoryTable.project_id, projectID))
|
||||
.orderBy(desc(ProjectDirectoryTable.time_created), asc(ProjectDirectoryTable.directory))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
|
||||
})
|
||||
|
||||
const get = Effect.fn("ProjectDirectories.get")(function* (input: {
|
||||
projectID: ProjectSchema.ID
|
||||
directory: AbsolutePath
|
||||
}) {
|
||||
const row = yield* db
|
||||
.select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(
|
||||
and(
|
||||
eq(ProjectDirectoryTable.project_id, input.projectID),
|
||||
eq(ProjectDirectoryTable.directory, input.directory),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row ? { directory: row.directory, strategy: row.strategy ?? undefined } : undefined
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
list,
|
||||
get,
|
||||
create,
|
||||
remove,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] })
|
||||
@@ -10,9 +10,18 @@ export type ID = typeof ID.Type
|
||||
export const Current = Project.Current
|
||||
export type Current = typeof Current.Type
|
||||
|
||||
export const Directory = Project.Directory
|
||||
export type Directory = typeof Directory.Type
|
||||
|
||||
export const Info = Project.Info
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
|
||||
export const DirectoriesInput = Project.DirectoriesInput
|
||||
export type DirectoriesInput = typeof DirectoriesInput.Type
|
||||
|
||||
export const Directories = Project.Directories
|
||||
export type Directories = typeof Directories.Type
|
||||
|
||||
export const Vcs = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("git"),
|
||||
|
||||
@@ -23,7 +23,6 @@ export const ProjectTable = sqliteTable("project", {
|
||||
commands: text({ mode: "json" }).$type<{ start?: string }>(),
|
||||
})
|
||||
|
||||
/** @deprecated Use WorktreeTable from worktree/sql instead. */
|
||||
export const ProjectDirectoryTable = sqliteTable(
|
||||
"project_directory",
|
||||
{
|
||||
|
||||
@@ -18,7 +18,7 @@ import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js"
|
||||
import { Slug } from "../util/slug.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { Event } from "@opencode-ai/schema/project-directories"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { AbsolutePath, RelativePath } from "../schema.js"
|
||||
import type { SessionSchema } from "./schema.js"
|
||||
@@ -442,7 +442,7 @@ const layer = Layer.effectDiscard(
|
||||
// Sessions whose ownership came from the directory's previous resolution
|
||||
// follow its new identity. Location, transcript, instructions, and recency
|
||||
// are untouched: the session did not move, its directory got identified.
|
||||
yield* bus.project(Worktree.Event.Resolved, (event) =>
|
||||
yield* bus.project(Event.Resolved, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const stale = [event.data.previous, Project.ID.global].filter((id) => id !== event.data.projectID)
|
||||
if (stale.length === 0) return
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
export * as Worktree from "./worktree.js"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
|
||||
import path from "path"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Git } from "./git.js"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { ProjectSchema } from "./project/schema.js"
|
||||
import { Slug } from "./util/slug.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Database } from "./database/database.js"
|
||||
import { Location } from "./location.js"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { WorktreeTable } from "./worktree/sql.js"
|
||||
import { canonical, DirectoryUnavailableError } from "./worktree/directory.js"
|
||||
import { WorktreeGit } from "./worktree/git.js"
|
||||
import type { EffectDrizzleSqlite } from "./database/drizzle.js"
|
||||
|
||||
export { DirectoryUnavailableError } from "./worktree/directory.js"
|
||||
|
||||
export const StrategyID = Worktree.StrategyID
|
||||
export type StrategyID = typeof StrategyID.Type
|
||||
|
||||
export const CreateInput = Worktree.CreateInput
|
||||
export type CreateInput = typeof CreateInput.Type
|
||||
|
||||
export const RemoveInput = Worktree.RemoveInput
|
||||
export type RemoveInput = typeof RemoveInput.Type
|
||||
|
||||
export const RefreshInput = Schema.Struct({
|
||||
projectID: ProjectSchema.ID,
|
||||
}).annotate({ identifier: "Worktree.RefreshInput" })
|
||||
export type RefreshInput = typeof RefreshInput.Type
|
||||
|
||||
export const RefreshResult = Schema.Struct({
|
||||
updated: Schema.Array(AbsolutePath),
|
||||
removed: Schema.Array(AbsolutePath),
|
||||
}).annotate({ identifier: "Worktree.RefreshResult" })
|
||||
export type RefreshResult = typeof RefreshResult.Type
|
||||
|
||||
export const Info = Worktree.Info
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
export const ListInput = Worktree.ListInput
|
||||
export type ListInput = typeof ListInput.Type
|
||||
|
||||
export const List = Worktree.List
|
||||
export type List = typeof List.Type
|
||||
|
||||
export const ListEntry = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
type: Schema.Literals(["root", "worktree"]),
|
||||
}).annotate({ identifier: "Worktree.ListEntry" })
|
||||
export type ListEntry = typeof ListEntry.Type
|
||||
|
||||
export class SourceDirectoryNotFoundError extends Schema.TaggedErrorClass<SourceDirectoryNotFoundError>()(
|
||||
"Worktree.SourceDirectoryNotFoundError",
|
||||
{ projectID: ProjectSchema.ID },
|
||||
) {}
|
||||
|
||||
export class DestinationExistsError extends Schema.TaggedErrorClass<DestinationExistsError>()(
|
||||
"Worktree.DestinationExistsError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class InvalidDirectoryError extends Schema.TaggedErrorClass<InvalidDirectoryError>()(
|
||||
"Worktree.InvalidDirectoryError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class StrategyUnavailableError extends Schema.TaggedErrorClass<StrategyUnavailableError>()(
|
||||
"Worktree.StrategyUnavailableError",
|
||||
{ strategy: StrategyID },
|
||||
) {}
|
||||
|
||||
export class DuplicateStrategyError extends Schema.TaggedErrorClass<DuplicateStrategyError>()(
|
||||
"Worktree.DuplicateStrategyError",
|
||||
{ strategy: StrategyID },
|
||||
) {}
|
||||
|
||||
export type Error =
|
||||
| SourceDirectoryNotFoundError
|
||||
| DestinationExistsError
|
||||
| DirectoryUnavailableError
|
||||
| InvalidDirectoryError
|
||||
| StrategyUnavailableError
|
||||
| Git.WorktreeError
|
||||
|
||||
export interface Strategy {
|
||||
readonly id: StrategyID
|
||||
readonly create: (input: {
|
||||
sourceDirectory: AbsolutePath
|
||||
directory: AbsolutePath
|
||||
}) => Effect.Effect<Info, Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly remove: (input: {
|
||||
directory: AbsolutePath
|
||||
force: boolean
|
||||
}) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly list: (directory: AbsolutePath) => Effect.Effect<ListEntry[], Git.WorktreeError | DirectoryUnavailableError>
|
||||
}
|
||||
|
||||
export const Event = Worktree.Event
|
||||
|
||||
interface StoredInput {
|
||||
readonly projectID: ProjectSchema.ID
|
||||
readonly directory: AbsolutePath
|
||||
readonly strategy?: string
|
||||
}
|
||||
|
||||
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
|
||||
type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (strategy: Strategy) => Effect.Effect<void, DuplicateStrategyError>
|
||||
readonly list: (projectID: ProjectSchema.ID) => Effect.Effect<List>
|
||||
readonly create: (input: CreateInput) => Effect.Effect<Info, Error>
|
||||
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
|
||||
readonly refresh: (input: RefreshInput) => Effect.Effect<RefreshResult, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Worktree") {}
|
||||
|
||||
export const refreshAfterBoot = Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const worktrees = yield* Service
|
||||
yield* Effect.gen(function* () {
|
||||
yield* Effect.logInfo("worktree refresh started", { projectID: location.project.id })
|
||||
const result = yield* worktrees.refresh({ projectID: location.project.id })
|
||||
yield* Effect.logInfo("worktree refresh done", {
|
||||
projectID: location.project.id,
|
||||
updated: result.updated,
|
||||
removed: result.removed,
|
||||
})
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => Effect.logWarning("worktree refresh failed", { cause })),
|
||||
Effect.forkScoped,
|
||||
Effect.asVoid,
|
||||
)
|
||||
})
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const changed = Effect.fnUntraced(function* (projectID: ProjectSchema.ID, update: boolean) {
|
||||
if (update) yield* bus.publish(Event.Updated, { projectID })
|
||||
})
|
||||
|
||||
const ops = {
|
||||
list: Effect.fn("Worktree.list")(function* (projectID: ProjectSchema.ID) {
|
||||
const rows = yield* db
|
||||
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
|
||||
.from(WorktreeTable)
|
||||
.where(eq(WorktreeTable.project_id, projectID))
|
||||
.orderBy(desc(WorktreeTable.time_created), asc(WorktreeTable.directory))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
|
||||
}),
|
||||
find: Effect.fnUntraced(function* (projectID: ProjectSchema.ID, directory: AbsolutePath) {
|
||||
const row = yield* db
|
||||
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
|
||||
.from(WorktreeTable)
|
||||
.where(and(eq(WorktreeTable.project_id, projectID), eq(WorktreeTable.directory, directory)))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row ? { directory: row.directory, strategy: row.strategy ?? undefined } : undefined
|
||||
}),
|
||||
create: Effect.fnUntraced(function* (input: StoredInput, tx?: Transaction) {
|
||||
return (
|
||||
(yield* (tx ?? db)
|
||||
.insert(WorktreeTable)
|
||||
.values({ project_id: input.projectID, directory: input.directory, strategy: input.strategy })
|
||||
.onConflictDoUpdate({
|
||||
target: [WorktreeTable.project_id, WorktreeTable.directory],
|
||||
set: { strategy: input.strategy ?? null },
|
||||
setWhere: input.strategy
|
||||
? or(isNull(WorktreeTable.strategy), ne(WorktreeTable.strategy, input.strategy))
|
||||
: isNotNull(WorktreeTable.strategy),
|
||||
})
|
||||
.returning({ directory: WorktreeTable.directory })
|
||||
.get()
|
||||
.pipe(Effect.orDie)) !== undefined
|
||||
)
|
||||
}),
|
||||
remove: Effect.fnUntraced(function* (projectID: ProjectSchema.ID, directory: AbsolutePath, tx?: Transaction) {
|
||||
return (
|
||||
(yield* (tx ?? db)
|
||||
.delete(WorktreeTable)
|
||||
.where(and(eq(WorktreeTable.project_id, projectID), eq(WorktreeTable.directory, directory)))
|
||||
.returning({ directory: WorktreeTable.directory })
|
||||
.get()
|
||||
.pipe(Effect.orDie)) !== undefined
|
||||
)
|
||||
}),
|
||||
}
|
||||
|
||||
const registry = new Map<StrategyID, Strategy>()
|
||||
|
||||
const register = Effect.fn("Worktree.register")(function* (strategy: Strategy) {
|
||||
if (registry.has(strategy.id)) return yield* new DuplicateStrategyError({ strategy: strategy.id })
|
||||
registry.set(strategy.id, strategy)
|
||||
})
|
||||
|
||||
// Register default strategies
|
||||
const gitStrategy = yield* WorktreeGit.make
|
||||
yield* register(gitStrategy).pipe(Effect.orDie)
|
||||
|
||||
const strategies = () => Array.from(registry.values())
|
||||
|
||||
const source = Effect.fnUntraced(function* (projectID: ProjectSchema.ID) {
|
||||
const checked = yield* Effect.forEach(
|
||||
yield* ops.list(projectID),
|
||||
(item) =>
|
||||
canonical(fs, item.directory).pipe(
|
||||
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed(undefined)),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const found = checked.find((directory) => directory !== undefined)
|
||||
if (!found) return yield* new SourceDirectoryNotFoundError({ projectID })
|
||||
return found
|
||||
})
|
||||
|
||||
const getStrategy = Effect.fnUntraced(function* (id: StrategyID) {
|
||||
const found = registry.get(id)
|
||||
if (!found) return yield* new StrategyUnavailableError({ strategy: id })
|
||||
return found
|
||||
})
|
||||
|
||||
const create = Effect.fn("Worktree.create")(function* (input: CreateInput) {
|
||||
const selected = yield* getStrategy(input.strategy)
|
||||
const sourceDirectory = yield* source(input.projectID)
|
||||
yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie)
|
||||
const name = input.name ?? Slug.create()
|
||||
let suffix = 1
|
||||
let worktreeDirectory = AbsolutePath.make(path.join(input.directory, name))
|
||||
while (yield* fs.existsSafe(worktreeDirectory)) {
|
||||
suffix++
|
||||
if (suffix > 10) return yield* new DestinationExistsError({ directory: worktreeDirectory })
|
||||
worktreeDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
|
||||
}
|
||||
|
||||
const result = yield* selected.create({
|
||||
directory: worktreeDirectory,
|
||||
sourceDirectory,
|
||||
})
|
||||
yield* changed(
|
||||
input.projectID,
|
||||
yield* ops.create({
|
||||
projectID: input.projectID,
|
||||
directory: result.directory,
|
||||
strategy: input.strategy,
|
||||
}),
|
||||
)
|
||||
return result
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
|
||||
const worktreeDirectory = yield* canonical(fs, input.directory)
|
||||
const stored = yield* ops.find(input.projectID, worktreeDirectory)
|
||||
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: worktreeDirectory })
|
||||
yield* (yield* getStrategy(StrategyID.make(stored.strategy))).remove({
|
||||
directory: worktreeDirectory,
|
||||
force: input.force,
|
||||
})
|
||||
yield* changed(input.projectID, yield* ops.remove(input.projectID, worktreeDirectory))
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("Worktree.refresh")(function* (input: RefreshInput) {
|
||||
const stored = yield* ops.list(input.projectID)
|
||||
const checked = yield* Effect.forEach(
|
||||
stored,
|
||||
(item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const sourceDirectories = checked
|
||||
.filter((item) => item.strategy === undefined && item.exists)
|
||||
.map((item) => item.directory)
|
||||
const discovered = yield* Effect.forEach(
|
||||
sourceDirectories,
|
||||
(sourceDirectory) =>
|
||||
Effect.forEach(strategies(), (strategy) =>
|
||||
strategy.list(sourceDirectory).pipe(
|
||||
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed([])),
|
||||
Effect.map((items) =>
|
||||
items.map((item) => ({
|
||||
directory: item.directory,
|
||||
strategy: item.type === "worktree" ? strategy.id : undefined,
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(
|
||||
Effect.map((sets) => new Map(sets.flat(2).map((item) => [item.directory, item] as const)).values().toArray()),
|
||||
)
|
||||
const removed = checked.filter((item) => !item.exists).map((item) => item.directory)
|
||||
const result = yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.all({
|
||||
updated: Effect.forEach(discovered, (item) =>
|
||||
ops.create(
|
||||
{
|
||||
projectID: input.projectID,
|
||||
directory: item.directory,
|
||||
strategy: item.strategy,
|
||||
},
|
||||
tx,
|
||||
),
|
||||
),
|
||||
removed: Effect.forEach(removed, (directory) => ops.remove(input.projectID, directory, tx)),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
const changes = {
|
||||
updated: discovered.filter((_, index) => result.updated[index]).map((item) => item.directory),
|
||||
removed: removed.filter((_, index) => result.removed[index]),
|
||||
}
|
||||
yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
|
||||
return changes
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
register,
|
||||
list: ops.list,
|
||||
create,
|
||||
remove,
|
||||
refresh,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Git.node, Bus.node, Database.node],
|
||||
})
|
||||
|
||||
export const refreshNode = makeLocationNode({
|
||||
name: "worktree-refresh",
|
||||
layer: Layer.effectDiscard(refreshAfterBoot),
|
||||
deps: [node, Location.node],
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
export * as WorktreeDirectory from "./directory.js"
|
||||
|
||||
import { Effect, Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { AbsolutePath } from "../schema.js"
|
||||
|
||||
export class DirectoryUnavailableError extends Schema.TaggedErrorClass<DirectoryUnavailableError>()(
|
||||
"Worktree.DirectoryUnavailableError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export const canonical = Effect.fnUntraced(function* (fs: FSUtil.Interface, input: AbsolutePath) {
|
||||
const resolved = AbsolutePath.make(yield* fs.resolve(input))
|
||||
if (!(yield* fs.isDir(resolved))) return yield* new DirectoryUnavailableError({ directory: input })
|
||||
return resolved
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
export * as WorktreeGit from "./git.js"
|
||||
|
||||
import { Effect } from "effect"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Git } from "../git.js"
|
||||
import { canonical, DirectoryUnavailableError } from "./directory.js"
|
||||
import type { ListEntry, Strategy } from "../worktree.js"
|
||||
|
||||
export const make = Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
|
||||
return {
|
||||
id: Worktree.StrategyID.make("git"),
|
||||
create: Effect.fn("Worktree.Git.create")(function* (input) {
|
||||
const repository = yield* git.repo.discover(input.sourceDirectory)
|
||||
if (!repository) return yield* new DirectoryUnavailableError({ directory: input.sourceDirectory })
|
||||
yield* git.worktree.create({ repository, directory: input.directory })
|
||||
return { directory: yield* canonical(fs, input.directory) }
|
||||
}),
|
||||
remove: Effect.fn("Worktree.Git.remove")(function* (input) {
|
||||
const repository = yield* git.repo.discover(input.directory)
|
||||
if (!repository) return yield* new DirectoryUnavailableError({ directory: input.directory })
|
||||
yield* git.worktree.remove({ repository, directory: input.directory, force: input.force })
|
||||
}),
|
||||
list: Effect.fn("Worktree.Git.list")(function* (directory) {
|
||||
const repository = yield* git.repo.discover(directory)
|
||||
if (!repository) return yield* new DirectoryUnavailableError({ directory })
|
||||
const entries = yield* git.worktree.list(repository)
|
||||
return yield* Effect.forEach(entries, (entry) =>
|
||||
canonical(fs, entry.directory).pipe(
|
||||
Effect.map((directory) => ({ directory, type: entry.kind === "main" ? "root" : "worktree" }) as const),
|
||||
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined)))
|
||||
}),
|
||||
} satisfies Strategy
|
||||
})
|
||||
@@ -1,20 +0,0 @@
|
||||
import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
import { absoluteColumn } from "../database/path.js"
|
||||
import { ProjectSchema } from "../project/schema.js"
|
||||
import { ProjectTable } from "../project/sql.js"
|
||||
|
||||
export const WorktreeTable = sqliteTable(
|
||||
"worktree",
|
||||
{
|
||||
project_id: text()
|
||||
.$type<ProjectSchema.ID>()
|
||||
.notNull()
|
||||
.references(() => ProjectTable.id, { onDelete: "cascade" }),
|
||||
directory: absoluteColumn().notNull(),
|
||||
strategy: text(),
|
||||
time_created: integer()
|
||||
.notNull()
|
||||
.$default(() => Date.now()),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.project_id, table.directory] })],
|
||||
)
|
||||
@@ -12,7 +12,6 @@ import { Database } from "@opencode-ai/core/database/database"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import type { SqlClient } from "effect/unstable/sql/SqlClient"
|
||||
import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
|
||||
import worktreeMigration from "@opencode-ai/core/database/migration/20260812213948_worktree"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
const run = <A, E>(
|
||||
@@ -128,31 +127,6 @@ describe("DatabaseMigration", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("copies project directories into worktrees without removing the old table", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE project (id text PRIMARY KEY)`)
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE project_directory (project_id text NOT NULL, directory text NOT NULL, type text, strategy text, time_created integer NOT NULL, PRIMARY KEY (project_id, directory))`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO project_directory (project_id, directory, type, strategy, time_created) VALUES ('project', '/root', 'main', NULL, 1), ('project', '/legacy', 'git_worktree', NULL, 2), ('project', '/strategy', NULL, 'git_worktree', 3), ('project', '/custom', NULL, 'acme/snapshot', 4)`,
|
||||
)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [worktreeMigration])
|
||||
|
||||
expect(yield* db.all(sql`SELECT directory, strategy FROM worktree ORDER BY directory`)).toEqual([
|
||||
{ directory: "/custom", strategy: "acme/snapshot" },
|
||||
{ directory: "/legacy", strategy: "git" },
|
||||
{ directory: "/root", strategy: null },
|
||||
{ directory: "/strategy", strategy: "git" },
|
||||
])
|
||||
expect(yield* db.get(sql`SELECT count(*) AS count FROM project_directory`)).toEqual({ count: 4 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("imports legacy JSON credentials without changing the source file or existing credentials", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const source = path.join(tmp.path, "auth.json")
|
||||
|
||||
@@ -78,6 +78,7 @@ describe("node build", () => {
|
||||
acquisitions++
|
||||
return Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
directories: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -13,6 +13,7 @@ const projectLayer = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
directories: () => Effect.succeed([]),
|
||||
resolve: () =>
|
||||
Effect.succeed({
|
||||
id: Project.ID.make("project"),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { and, eq, isNull } from "drizzle-orm"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Fiber, Stream } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
@@ -11,19 +11,21 @@ import { Git } from "@opencode-ai/core/git"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { Worktree } from "@opencode-ai/core/worktree"
|
||||
import { WorktreeTable } from "@opencode-ai/core/worktree/sql"
|
||||
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
||||
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Worktree.node, Database.node, Bus.node])))
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([ProjectCopy.node, Database.node, Bus.node, ProjectDirectories.node])),
|
||||
)
|
||||
|
||||
function abs(input: string) {
|
||||
return AbsolutePath.make(input)
|
||||
}
|
||||
|
||||
const gitWorktree = Worktree.StrategyID.make("git")
|
||||
const gitWorktree = ProjectCopy.StrategyID.make("git_worktree")
|
||||
|
||||
async function initRepo(directory: string) {
|
||||
await $`git init`.cwd(directory).quiet()
|
||||
@@ -42,7 +44,7 @@ function setup() {
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(root.path))
|
||||
const sourceDirectory = abs(yield* Effect.promise(() => fs.realpath(root.path)))
|
||||
const projectID = Project.ID.make("worktree-project")
|
||||
const projectID = Project.ID.make("copy-project")
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
@@ -50,7 +52,7 @@ function setup() {
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(WorktreeTable)
|
||||
.insert(ProjectDirectoryTable)
|
||||
.values({ project_id: projectID, directory: sourceDirectory })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
@@ -61,9 +63,9 @@ function setup() {
|
||||
function stored(projectID: Project.ID) {
|
||||
return Database.Service.use(({ db }) =>
|
||||
db
|
||||
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
|
||||
.from(WorktreeTable)
|
||||
.where(eq(WorktreeTable.project_id, projectID))
|
||||
.select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(eq(ProjectDirectoryTable.project_id, projectID))
|
||||
.all()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
@@ -72,151 +74,92 @@ function stored(projectID: Project.ID) {
|
||||
)
|
||||
}
|
||||
|
||||
describe("Worktree", () => {
|
||||
describe("ProjectCopy", () => {
|
||||
it.effect("accepts arbitrary non-empty strategy ids", () =>
|
||||
Effect.sync(() => {
|
||||
expect(String(Worktree.StrategyID.make("acme/snapshot"))).toBe("acme/snapshot")
|
||||
expect(() => Worktree.StrategyID.make(" acme/snapshot ")).toThrow()
|
||||
expect(() => Worktree.StrategyID.make(" ")).toThrow()
|
||||
expect(String(ProjectCopy.StrategyID.make("acme/snapshot"))).toBe("acme/snapshot")
|
||||
expect(() => ProjectCopy.StrategyID.make(" acme/snapshot ")).toThrow()
|
||||
expect(() => ProjectCopy.StrategyID.make(" ")).toThrow()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports unavailable strategy ids", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const unavailable = Worktree.StrategyID.make("acme/missing")
|
||||
const error = yield* worktree
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const unavailable = ProjectCopy.StrategyID.make("acme/missing")
|
||||
const error = yield* copy
|
||||
.create({
|
||||
projectID: input.projectID,
|
||||
strategy: unavailable,
|
||||
sourceDirectory: input.sourceDirectory,
|
||||
directory: abs(`${input.root.path}-missing-strategy`),
|
||||
name: "worktree",
|
||||
name: "copy",
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(Worktree.StrategyUnavailableError)
|
||||
if (error instanceof Worktree.StrategyUnavailableError) expect(error.strategy).toBe(unavailable)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("requires a tracked project worktree", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
yield* input.db
|
||||
.delete(WorktreeTable)
|
||||
.where(eq(WorktreeTable.project_id, input.projectID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const worktree = yield* Worktree.Service
|
||||
|
||||
const error = yield* worktree
|
||||
.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
directory: abs(`${input.root.path}-missing-source`),
|
||||
name: "worktree",
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(Worktree.SourceDirectoryNotFoundError)
|
||||
if (error instanceof Worktree.SourceDirectoryNotFoundError) expect(error.projectID).toBe(input.projectID)
|
||||
expect(error).toBeInstanceOf(ProjectCopy.StrategyUnavailableError)
|
||||
if (error instanceof ProjectCopy.StrategyUnavailableError) expect(error.strategy).toBe(unavailable)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("creates and removes a git worktree directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const bus = yield* Bus.Service
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-created"))
|
||||
const target = abs(path.join(parent, "worktree"))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-created"))
|
||||
const target = abs(path.join(parent, "copy"))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
const fiber = yield* bus
|
||||
.subscribe(Worktree.Event.Updated)
|
||||
.subscribe(ProjectCopy.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
const created = yield* worktree.create({
|
||||
const created = yield* copy.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
sourceDirectory: input.sourceDirectory,
|
||||
directory: parent,
|
||||
name: "worktree",
|
||||
name: "copy",
|
||||
})
|
||||
expect(created.directory).toBe(target)
|
||||
expect(yield* stored(input.projectID)).toEqual(
|
||||
[
|
||||
{ directory: input.sourceDirectory, strategy: null },
|
||||
{ directory: created.directory, strategy: "git" },
|
||||
{ directory: created.directory, strategy: "git_worktree" },
|
||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
)
|
||||
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
|
||||
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
|
||||
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
|
||||
expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("creates from another managed worktree", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
const sourceParent = abs(path.join(temp, path.basename(input.root.path) + "-managed-source"))
|
||||
const targetParent = abs(path.join(temp, path.basename(input.root.path) + "-managed-target"))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.all([
|
||||
Effect.promise(() => fs.rm(sourceParent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
Effect.promise(() => fs.rm(targetParent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
]).pipe(Effect.asVoid),
|
||||
)
|
||||
const source = yield* worktree.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
directory: sourceParent,
|
||||
name: "source",
|
||||
})
|
||||
yield* input.db
|
||||
.delete(WorktreeTable)
|
||||
.where(and(eq(WorktreeTable.project_id, input.projectID), isNull(WorktreeTable.strategy)))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const created = yield* worktree.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
directory: targetParent,
|
||||
name: "target",
|
||||
})
|
||||
|
||||
expect(created.directory).toBe(abs(path.join(targetParent, "target")))
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: source.directory, force: false })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("requires force to remove a dirty git worktree", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-dirty"))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-dirty"))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
const created = yield* worktree.create({
|
||||
const created = yield* copy.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
sourceDirectory: input.sourceDirectory,
|
||||
directory: parent,
|
||||
name: "worktree",
|
||||
name: "copy",
|
||||
})
|
||||
yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty"))
|
||||
|
||||
const error = yield* worktree
|
||||
const error = yield* copy
|
||||
.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
.pipe(Effect.flip)
|
||||
|
||||
@@ -225,113 +168,115 @@ describe("Worktree", () => {
|
||||
expect(error.operation).toBe("remove")
|
||||
expect(error.forceRequired).toBe(true)
|
||||
}
|
||||
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "git" })
|
||||
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "git_worktree" })
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "dirty.txt")).exists())).toBe(true)
|
||||
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: true })
|
||||
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: true })
|
||||
expect(yield* Effect.promise(() => Bun.file(created.directory).exists())).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("preserves worktrees whose stored strategy is unavailable", () =>
|
||||
it.live("preserves copies whose stored strategy is unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const unavailable = abs(`${input.root.path}-worktree-unavailable`)
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const unavailable = abs(`${input.root.path}-copy-unavailable`)
|
||||
yield* Effect.promise(() => fs.mkdir(unavailable))
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(unavailable, { recursive: true, force: true })))
|
||||
yield* input.db
|
||||
.insert(WorktreeTable)
|
||||
.insert(ProjectDirectoryTable)
|
||||
.values({ project_id: input.projectID, directory: unavailable, strategy: "acme/missing" })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const error = yield* worktree
|
||||
const error = yield* copy
|
||||
.remove({ projectID: input.projectID, directory: unavailable, force: false })
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(Worktree.StrategyUnavailableError)
|
||||
expect(error).toBeInstanceOf(ProjectCopy.StrategyUnavailableError)
|
||||
expect(yield* stored(input.projectID)).toContainEqual({ directory: unavailable, strategy: "acme/missing" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("adds a numeric suffix when a worktree directory already exists", () =>
|
||||
it.live("adds a numeric suffix when a copy directory already exists", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-suffix"))
|
||||
const target = abs(path.join(parent, "worktree-3"))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-suffix"))
|
||||
const target = abs(path.join(parent, "copy-3"))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(parent, "worktree"), { recursive: true }))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(parent, "worktree-2")))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(parent, "copy"), { recursive: true }))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(parent, "copy-2")))
|
||||
|
||||
const created = yield* worktree.create({
|
||||
const created = yield* copy.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
sourceDirectory: input.sourceDirectory,
|
||||
directory: parent,
|
||||
name: "worktree",
|
||||
name: "copy",
|
||||
})
|
||||
|
||||
expect(created.directory).toBe(target)
|
||||
expect(
|
||||
yield* Effect.promise(() => fs.stat(path.join(parent, "worktree")).then((item) => item.isDirectory())),
|
||||
).toBe(true)
|
||||
expect(
|
||||
yield* Effect.promise(() => fs.stat(path.join(parent, "worktree-2")).then((item) => item.isDirectory())),
|
||||
).toBe(true)
|
||||
expect(yield* Effect.promise(() => fs.stat(path.join(parent, "copy")).then((item) => item.isDirectory()))).toBe(
|
||||
true,
|
||||
)
|
||||
expect(yield* Effect.promise(() => fs.stat(path.join(parent, "copy-2")).then((item) => item.isDirectory()))).toBe(
|
||||
true,
|
||||
)
|
||||
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("fails after ten worktree directory conflicts", () =>
|
||||
it.live("fails after ten copy directory conflicts", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-conflicts"))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-conflicts"))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all(
|
||||
Array.from({ length: 10 }, (_, index) =>
|
||||
fs.mkdir(path.join(parent, index === 0 ? "worktree" : `worktree-${index + 1}`), { recursive: true }),
|
||||
fs.mkdir(path.join(parent, index === 0 ? "copy" : `copy-${index + 1}`), { recursive: true }),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const error = yield* worktree
|
||||
const error = yield* copy
|
||||
.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
sourceDirectory: input.sourceDirectory,
|
||||
directory: parent,
|
||||
name: "worktree",
|
||||
name: "copy",
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(Worktree.DestinationExistsError)
|
||||
if (error instanceof Worktree.DestinationExistsError)
|
||||
expect(error.directory).toBe(abs(path.join(parent, "worktree-10")))
|
||||
expect(error).toBeInstanceOf(ProjectCopy.DestinationExistsError)
|
||||
if (error instanceof ProjectCopy.DestinationExistsError)
|
||||
expect(error.directory).toBe(abs(path.join(parent, "copy-10")))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not publish an event when refresh finds no directory changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const bus = yield* Bus.Service
|
||||
const event = yield* bus.subscribe(Worktree.Event.Updated).pipe(
|
||||
const event = yield* bus.subscribe(ProjectCopy.Event.Updated).pipe(
|
||||
Stream.take(1),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped,
|
||||
Effect.flatMap((fiber) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.yieldNow
|
||||
yield* worktree.refresh({ projectID: input.projectID })
|
||||
yield* copy.refresh({ projectID: input.projectID })
|
||||
return yield* Fiber.join(fiber).pipe(Effect.timeoutOption("50 millis"))
|
||||
}),
|
||||
),
|
||||
@@ -344,36 +289,36 @@ describe("Worktree", () => {
|
||||
it.live("refresh discovers and prunes an externally managed git worktree", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const bus = yield* Bus.Service
|
||||
const target = abs(`${input.root.path}-worktree-external`)
|
||||
const target = abs(`${input.root.path}-copy-external`)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
|
||||
yield* input.db
|
||||
.insert(WorktreeTable)
|
||||
.insert(ProjectDirectoryTable)
|
||||
.values({ project_id: input.projectID, directory: target })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const fiber = yield* bus
|
||||
.subscribe(Worktree.Event.Updated)
|
||||
.subscribe(ProjectCopy.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
|
||||
expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({ updated: [discovered], removed: [] })
|
||||
expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [discovered], removed: [] })
|
||||
|
||||
expect(yield* stored(input.projectID)).toEqual(
|
||||
[
|
||||
{ directory: input.sourceDirectory, strategy: null },
|
||||
{ directory: discovered, strategy: "git" },
|
||||
{ directory: discovered, strategy: "git_worktree" },
|
||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
)
|
||||
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
|
||||
|
||||
yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
|
||||
expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [discovered] })
|
||||
expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [discovered] })
|
||||
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
|
||||
}),
|
||||
)
|
||||
@@ -381,9 +326,9 @@ describe("Worktree", () => {
|
||||
it.live("refresh ignores stale git worktree registrations", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const stale = abs(`${input.root.path}-worktree-stale`)
|
||||
const target = abs(`${input.root.path}-worktree-after-stale`)
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const stale = abs(`${input.root.path}-copy-stale`)
|
||||
const target = abs(`${input.root.path}-copy-after-stale`)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
@@ -391,13 +336,13 @@ describe("Worktree", () => {
|
||||
yield* Effect.promise(() => fs.rm(stale, { recursive: true, force: true }))
|
||||
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
|
||||
|
||||
yield* worktree.refresh({ projectID: input.projectID })
|
||||
yield* copy.refresh({ projectID: input.projectID })
|
||||
|
||||
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
|
||||
expect(yield* stored(input.projectID)).toEqual(
|
||||
[
|
||||
{ directory: input.sourceDirectory, strategy: null },
|
||||
{ directory: discovered, strategy: "git" },
|
||||
{ directory: discovered, strategy: "git_worktree" },
|
||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
)
|
||||
}),
|
||||
@@ -407,9 +352,9 @@ describe("Worktree", () => {
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
yield* Effect.promise(() => fs.rm(path.join(input.sourceDirectory, ".git"), { recursive: true }))
|
||||
const worktree = yield* Worktree.Service
|
||||
const copy = yield* ProjectCopy.Service
|
||||
|
||||
yield* worktree.refresh({ projectID: input.projectID })
|
||||
yield* copy.refresh({ projectID: input.projectID })
|
||||
|
||||
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
|
||||
}),
|
||||
@@ -417,9 +362,9 @@ describe("Worktree", () => {
|
||||
|
||||
it.live("refresh with no roots is a no-op", () =>
|
||||
Effect.gen(function* () {
|
||||
const worktree = yield* Worktree.Service
|
||||
const copy = yield* ProjectCopy.Service
|
||||
|
||||
expect(yield* worktree.refresh({ projectID: Project.ID.make("missing-project") })).toEqual({
|
||||
expect(yield* copy.refresh({ projectID: Project.ID.make("missing-project") })).toEqual({
|
||||
updated: [],
|
||||
removed: [],
|
||||
})
|
||||
@@ -431,13 +376,13 @@ describe("Worktree", () => {
|
||||
const input = yield* setup()
|
||||
const missing = abs(`${input.root.path}-missing-checkout`)
|
||||
yield* input.db
|
||||
.insert(WorktreeTable)
|
||||
.insert(ProjectDirectoryTable)
|
||||
.values({ project_id: input.projectID, directory: missing })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const worktree = yield* Worktree.Service
|
||||
const copy = yield* ProjectCopy.Service
|
||||
|
||||
expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [missing] })
|
||||
expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [missing] })
|
||||
|
||||
expect(yield* stored(input.projectID)).not.toContainEqual({ directory: missing, strategy: null })
|
||||
}),
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, ProjectDirectories.node])))
|
||||
|
||||
const projectID = Project.ID.make("project-directories")
|
||||
const directory = AbsolutePath.make("/tmp/project-directories")
|
||||
|
||||
function setup() {
|
||||
return Database.Service.use(({ db }) =>
|
||||
db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: projectID, worktree: directory, sandboxes: [], time_created: 1, time_updated: 1 })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
}
|
||||
|
||||
describe("ProjectDirectories", () => {
|
||||
it.effect("decodes directory schemas", () =>
|
||||
Effect.sync(() => {
|
||||
expect(Schema.decodeUnknownSync(ProjectDirectories.ListInput)({ projectID })).toEqual({ projectID })
|
||||
expect(Schema.decodeUnknownSync(ProjectDirectories.ListOutput)([{ directory }])).toEqual([{ directory }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("creates once and ignores conflicts", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
const service = yield* ProjectDirectories.Service
|
||||
|
||||
expect(yield* service.create({ projectID, directory })).toBe(true)
|
||||
expect(yield* service.create({ projectID, directory, strategy: "git_worktree" })).toBe(false)
|
||||
expect(yield* service.list(projectID)).toEqual([{ directory, strategy: undefined }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns an empty list for missing projects", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ProjectDirectories.Service
|
||||
|
||||
expect(yield* service.list(Project.ID.make("missing-project"))).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces the strategy when requested", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
const service = yield* ProjectDirectories.Service
|
||||
yield* service.create({ projectID, directory, strategy: "old/strategy" })
|
||||
|
||||
expect(yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" })).toBe(true)
|
||||
expect(yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" })).toBe(false)
|
||||
expect(yield* service.create({ projectID, directory, behavior: "replace" })).toBe(true)
|
||||
expect(yield* service.create({ projectID, directory, behavior: "replace" })).toBe(false)
|
||||
expect(yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" })).toBe(true)
|
||||
expect(yield* service.list(projectID)).toEqual([{ directory, strategy: "new/strategy" }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -2,13 +2,11 @@ import { describe, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { WorktreeTable } from "@opencode-ai/core/worktree/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
@@ -367,15 +365,8 @@ describe("Project.resolve", () => {
|
||||
time: { created: 1, initialized: 2 },
|
||||
})
|
||||
expect(
|
||||
(yield* db
|
||||
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
|
||||
.from(WorktreeTable)
|
||||
.where(eq(WorktreeTable.project_id, id))
|
||||
.all()
|
||||
.pipe(Effect.orDie))
|
||||
.map((item) => ({ directory: item.directory, strategy: item.strategy ?? undefined }))
|
||||
.toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
).toEqual([{ directory: yield* real(tmp.path), strategy: undefined }, { directory: yield* real(worktree), strategy: "git" }])
|
||||
(yield* project.directories({ projectID: id })).toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
).toEqual([{ directory: yield* real(tmp.path) }, { directory: yield* real(worktree), strategy: "git_worktree" }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -34,6 +34,7 @@ const projects = Layer.succeed(
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
let requests: LLMRequest[] = []
|
||||
|
||||
@@ -36,6 +36,7 @@ const projects = Layer.succeed(
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
@@ -143,7 +144,7 @@ describe("Session.create", () => {
|
||||
.where(eq(EventTable.aggregate_id, project.id))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
expect(announced.map((event) => event.type)).toEqual(["worktree.resolved.1"])
|
||||
expect(announced.map((event) => event.type)).toEqual(["project.directory.resolved.1"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -55,6 +55,7 @@ const projects = Layer.succeed(
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
const permission = Layer.succeed(
|
||||
|
||||
@@ -22,6 +22,7 @@ const projects = Layer.succeed(
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { Event } from "@opencode-ai/schema/project-directories"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -22,6 +22,7 @@ const projects = Layer.succeed(
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
@@ -98,7 +99,7 @@ describe("Session.move", () => {
|
||||
projectID: Project.ID.global,
|
||||
})
|
||||
// The former directory becomes a project after the session left it.
|
||||
yield* bus.publish(Worktree.Event.Resolved, {
|
||||
yield* bus.publish(Event.Resolved, {
|
||||
projectID: Project.ID.make("adopting"),
|
||||
directory: previous,
|
||||
previous: Project.ID.global,
|
||||
|
||||
@@ -18,6 +18,7 @@ const projects = Layer.succeed(
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Location } from "@opencode-ai/schema/location"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
|
||||
import { PermissionV1 } from "@opencode-ai/schema/permission-v1"
|
||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
@@ -39,7 +39,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
coreModel,
|
||||
corePermission,
|
||||
corePermissionV1,
|
||||
coreWorktree,
|
||||
coreProjectCopy,
|
||||
corePty,
|
||||
coreProject,
|
||||
coreProvider,
|
||||
@@ -60,7 +60,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
import("@opencode-ai/core/model"),
|
||||
import("@opencode-ai/core/permission"),
|
||||
import("@opencode-ai/core/v1/permission"),
|
||||
import("@opencode-ai/core/worktree"),
|
||||
import("@opencode-ai/core/project/copy"),
|
||||
import("@opencode-ai/core/pty"),
|
||||
import("@opencode-ai/core/project/schema"),
|
||||
import("@opencode-ai/core/provider"),
|
||||
@@ -112,16 +112,14 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
[corePermission.Rule, Permission.Rule],
|
||||
[corePermission.Ruleset, Permission.Ruleset],
|
||||
[corePermissionV1.Event, PermissionV1.Event],
|
||||
[coreWorktree.CreateInput, Worktree.CreateInput],
|
||||
[coreWorktree.RemoveInput, Worktree.RemoveInput],
|
||||
[coreWorktree.Info, Worktree.Info],
|
||||
[coreWorktree.ListInput, Worktree.ListInput],
|
||||
[coreWorktree.List, Worktree.List],
|
||||
[coreWorktree.Event, Worktree.Event],
|
||||
[coreProjectCopy.Event, ProjectDirectories.Event],
|
||||
[corePty.Info, Pty.Info],
|
||||
[corePty.Event, Pty.Event],
|
||||
[coreProject.ID, Project.ID],
|
||||
[coreProject.Current, Project.Current],
|
||||
[coreProject.Directory, Project.Directory],
|
||||
[coreProject.DirectoriesInput, Project.DirectoriesInput],
|
||||
[coreProject.Directories, Project.Directories],
|
||||
[coreReference.LocalSource, Reference.LocalSource],
|
||||
[coreReference.GitSource, Reference.GitSource],
|
||||
[coreReference.Source, Reference.Source],
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# opencode-drive
|
||||
|
||||
## 1.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 99561ad: Restore controlled tools against the current V2 plugin API and add typed runtime control for write calls.
|
||||
|
||||
## 1.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b524213: Render light box-drawing borders as continuous geometric primitives.
|
||||
- a24a09d: Defer recording font initialization so source-checkout scripts can start without loading a duplicate renderer.
|
||||
|
||||
## 1.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6a8d52b: Prevent concurrent detached launchers from stealing prepared instance ownership and spawning competing daemon processes.
|
||||
- d71356f: Restore compatibility with current OpenCode V2 checkouts and packed Drive installations. Drive now uses V2's built-in simulation transport and provider shape, isolates scripted service ports and command forms, and compiles standalone scripts against the launching Drive toolchain without package installation or source-directory links.
|
||||
|
||||
## 1.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c20d147: Control arbitrary provider-backed tool lifecycles with dynamic registration, structured progress, success, failure, cancellation, and reconnect-safe replay.
|
||||
|
||||
## 1.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 7caebeb: Expose semantic UI snapshots, exact semantic node polling, and safe semantic-node clicks for compatible OpenCode endpoints.
|
||||
|
||||
## 1.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 4e0c002: Write screenshots and recordings beneath run- and restart-scoped media directories so named outputs cannot overwrite earlier runs.
|
||||
|
||||
## 1.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- fad9f96: Allow scripts and library drivers to intercept declared tools and control concurrent invocations by call ID at runtime.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 63d3464: Keep service and progress output out of visible TUI sessions and avoid reinstalling the OpenTUI preload package for development checkouts.
|
||||
- fd45cfe: Allow Drive runs to select a durable OpenCode database with the Effect-configured `OPENCODE_DRIVE_DB` setting while retaining `:memory:` as the default.
|
||||
- e66adc1: Preserve recorded frame timing during MP4 encoding and reduce work for dense or unchanged terminal output.
|
||||
- e7dff5f: Render diagonal quadrant block glyphs as exact terminal cell geometry in screenshots, recordings, and catalog frames.
|
||||
- 63d3464: Export recordings at 60 FPS by default and preserve the requested frame rate in generated MP4 files.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
- 1009394: Remove the Promise-based simulation clients. `SimulationClient`, `BackendSimulationClient`, `connectSimulation`, and `connectBackendSimulation` are gone, along with the `opencode-drive/experimental` entry point. The `opencode-drive/client` entry now exports only the canonical protocol schemas and default ports; the public API is Effect-only, as documented. The CLI drives instances through the Effect `SimulationConnector` directly.
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 9deab8d: Add the browser-safe `opencode-drive/frame` entry point: canonical cell geometry, OpenTUI text-attribute bits, the geometric block/bar glyph table, and baseline placement shared by the Drive PNG renderer and downstream canvas renderers. The PNG renderer now also draws the `┃` and `╹` structural bars geometrically instead of with fonts.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8481090: Settle simulated LLM responses cleanly when OpenCode terminates an invocation during interruption. Drive now uses the negotiated `llm.pending` capability to distinguish external termination from genuine response write failures.
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 58c4801: Return simulated background shells immediately, continue their handlers asynchronously, notify the session when they finish, and cancel them when Drive shuts down.
|
||||
- b5e8dfe: Make the script API Effect-only. Script setup and run callbacks, UI, LLM, filesystem, server, and TUI operations now return Effects; LLM serve handlers return Streams; and script cancellation uses Effect interruption without a Promise compatibility shim.
|
||||
- 775f799: Remove the tool handler `AbortSignal`. Foreground session interruption, transport disconnects, and Drive shutdown now surface uniformly as Effect interruption, and controller shutdown awaits handler finalizers. Detached background shell handlers remain active after launch and are interrupted during Drive shutdown.
|
||||
- 8e51796: Add deterministic shell, web fetch, and web search handlers with progress, success, failure, and interruption simulation.
|
||||
- 905f846: Add `opencode-drive script init` for generating an Effect-native starter script and show focused migration guidance when `check` finds Promise-style script callbacks.
|
||||
- d1bba54: Add first-class tool call input streaming through `Llm.toolCall` stream options.
|
||||
- 72f7aff: Expose the authenticated generated OpenCode SDK as `opencode` to drivers and scripts.
|
||||
- 37b4cd1: Give capabilities precise typed errors, validate UI predicates in canonical `ui.waitFor`, expose concrete failures through `Errors`, and keep pure response constructors exclusively under `Llm`.
|
||||
- 13ec474: Unify the Effect driver and `defineScript` around one canonical programmatic model. Both expose the generated SDK as `opencode`, the primary frontend as `tui`, additional frontends through `tuis`, and the primary UI as `ui`. Every `Tui` has the same `{ ui, close, recording }` shape and `{ recording, viewport }` options. Project setup now uses the shared `Project`, `Setup`, `SetupContext`, and `ProjectFileSystem` types. Remove duplicate script UI types, flattened frontend handles, partial settlement controls, root-level raw simulation exports, convenience CLI aliases, and the `wait` helper.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c8f5b51: Attach one best-effort normalized terminal frame to UI polling timeout errors without retaining screenshot artifacts.
|
||||
- c8f5b51: Render OpenCode's full UI symbol set with deterministic bundled fallback fonts instead of platform fonts or hand-drawn symbol exceptions.
|
||||
- c8f5b51: Preserve the managed driver's `Scope.Scope` requirement when consumed from TypeScript workspace applications.
|
||||
- 40d2241: Render the background completion arrow correctly in exported recordings.
|
||||
- 11cbbfd: Preserve the canonical OpenCode UI command shapes for optional named screenshots and key presses.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 opencode-drive
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,565 @@
|
||||
# opencode-drive
|
||||
|
||||
This project gives your agents control over OpenCode:
|
||||
|
||||
- Run it during development and let your agents see and poke at the running instance
|
||||
- Allow your agents to run it in headless mode and drive it to test things
|
||||
|
||||
## Requirements
|
||||
|
||||
OpenCode Drive requires [Bun](https://bun.sh/) 1.3.14 or newer. MP4 recording export also requires `ffmpeg` on `PATH`.
|
||||
|
||||
Install dependencies with:
|
||||
|
||||
```sh
|
||||
bun install
|
||||
```
|
||||
|
||||
## Skill
|
||||
|
||||
```sh
|
||||
npx skills add anomalyco/opencode --agent opencode --skill opencode-drive
|
||||
```
|
||||
|
||||
## Effect programs
|
||||
|
||||
The primary way to automate OpenCode is a default-exported, fully provided
|
||||
Effect. Drive type-checks the module contract, compiles the script and its local
|
||||
imports against the launching Drive toolchain, then validates and runs the
|
||||
export in an isolated Bun process:
|
||||
|
||||
```ts
|
||||
// drive.ts
|
||||
import { OpenCodeDriver } from "opencode-drive"
|
||||
|
||||
export default OpenCodeDriver.use(({ ui }) => ui.screenshot("home"))
|
||||
```
|
||||
|
||||
```sh
|
||||
opencode-drive run ./drive.ts
|
||||
```
|
||||
|
||||
`run` accepts exactly one module path. It rejects `--command.*` flags, other
|
||||
command flags, and application arguments after `--`. Backend and UI behavior
|
||||
belongs in the Effect program.
|
||||
|
||||
`OpenCodeDriver.use` is the safe default. It owns the scope, observes backend
|
||||
failure, settles queued LLM work, closes every TUI, and exports recordings
|
||||
whether the program succeeds or fails:
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { Llm, OpenCodeDriver } from "opencode-drive"
|
||||
|
||||
export default OpenCodeDriver.use(
|
||||
{
|
||||
project: {
|
||||
git: true,
|
||||
files: { "src/value.ts": "export const value = 1\n" },
|
||||
},
|
||||
},
|
||||
({ ui, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* llm.queue(Llm.text("The value is 1."))
|
||||
yield* ui.submit("Read src/value.ts")
|
||||
yield* ui.waitFor("The value is 1.")
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Use `OpenCodeDriver.useReport` when the program also needs structured evidence.
|
||||
It returns the program value plus a schema-validated report containing branded
|
||||
artifact and recording paths, retention, and the negotiated or legacy
|
||||
compatibility of every simulation endpoint:
|
||||
|
||||
```ts
|
||||
const result = yield * OpenCodeDriver.useReport(options, program)
|
||||
yield * Effect.log(result.report)
|
||||
```
|
||||
|
||||
Drive prefers `simulation.handshake` and explicitly records legacy fallback.
|
||||
Require negotiation when protocol skew must fail before the program runs:
|
||||
|
||||
```ts
|
||||
OpenCodeDriver.use(
|
||||
{
|
||||
opencode: { compatibility: "required" },
|
||||
},
|
||||
program,
|
||||
)
|
||||
```
|
||||
|
||||
Additional TUIs share the same server and LLM controller:
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { OpenCodeDriver } from "opencode-drive"
|
||||
|
||||
export default OpenCodeDriver.use((oc) =>
|
||||
Effect.gen(function* () {
|
||||
const secondary = yield* oc.tuis.launch({
|
||||
viewport: { cols: 120, rows: 40 },
|
||||
})
|
||||
yield* oc.ui.screenshot("primary")
|
||||
yield* secondary.ui.screenshot("secondary")
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
The generated OpenCode SDK client is exposed as `opencode`; launched frontend
|
||||
processes are `tui` and `tuis`. This keeps SDK calls distinct from terminal UI
|
||||
control:
|
||||
|
||||
```ts
|
||||
const health = yield * opencode.health.get()
|
||||
const frame = yield * tui.ui.capture()
|
||||
```
|
||||
|
||||
Enable recording per TUI. Settlement finishes each timeline and exports its
|
||||
video automatically:
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { OpenCodeDriver } from "opencode-drive"
|
||||
|
||||
export default OpenCodeDriver.use({ tui: { recording: true } }, (oc) =>
|
||||
Effect.gen(function* () {
|
||||
yield* oc.ui.screenshot("recorded-home")
|
||||
yield* Effect.log(`recording will be exported to ${oc.tui.recording?.path}`)
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Settlement errors are program failures. For example, output after a terminal
|
||||
LLM event fails the run while `use` still closes TUIs and attempts recording
|
||||
export:
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { Llm, OpenCodeDriver } from "opencode-drive"
|
||||
|
||||
export default OpenCodeDriver.use(({ ui, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* llm.queue(Llm.finish(), Llm.text("too late"))
|
||||
yield* ui.submit("trigger a response")
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Use `OpenCodeDriver.make` only when the program needs explicit terminal
|
||||
settlement. It requires a scope, and `driver.settle()` must run before leaving
|
||||
that scope:
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { OpenCodeDriver } from "opencode-drive"
|
||||
|
||||
export default Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const driver = yield* OpenCodeDriver.make()
|
||||
yield* driver.ui.screenshot("home")
|
||||
yield* driver.settle()
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Use `opencode-drive check ./drive.ts` and `start --script` for the Effect-native
|
||||
`defineScript` workflow described below.
|
||||
|
||||
## OpenCode development
|
||||
|
||||
Run this:
|
||||
|
||||
```sh
|
||||
OPENCODE_DRIVE=1 bun run dev
|
||||
```
|
||||
|
||||
If you installed the skill file, OpenCode will be able to see and interact with the running instance.
|
||||
|
||||
## Using with agents
|
||||
|
||||
Install the skill file above and ask the agent to test various flows with the app. Start with `--record` when you want a video; `opencode-drive stop` then exports the complete session and prints its path.
|
||||
|
||||
Screenshots and videos are written beneath `<system temp>/opencode-drive/output/<run-id>/<generation-id>`, so named outputs cannot overwrite media from earlier runs or restarts. Set `OPENCODE_DRIVE_MEDIA_DIR` to use a different media root.
|
||||
|
||||
Captured frames use the official full Commit Mono v1.143 faces at 16px with bundled Noto Symbols, Symbols 2, and Math fallbacks in a fixed 10x20 cell grid. Set `OPENCODE_DRIVE_FONT` to a comma-separated list of font files (for example regular, bold, italic, and bold-italic faces) to use a different primary capture font without changing the symbol fallback or cell geometry.
|
||||
|
||||
## UI development
|
||||
|
||||
If you are doing UI development in OpenCode, you might want to run it in a simulated mode. This allows `opencode-drive` to drive it and always put it into a state that you want to see.
|
||||
|
||||
Run it in visible mode:
|
||||
|
||||
```sh
|
||||
opencode-drive start --visible --dev ~/projects/opencode
|
||||
```
|
||||
|
||||
Initialize first when you need to customize the isolated environment before OpenCode starts:
|
||||
|
||||
```sh
|
||||
artifacts=$(opencode-drive init --name demo)
|
||||
cp -R ./fixtures/home/. "$artifacts/"
|
||||
cp -R ./fixtures/project/. "$artifacts/files/"
|
||||
opencode-drive start --name demo --visible --dev ~/projects/opencode
|
||||
```
|
||||
|
||||
`start` reuses the prepared artifacts for that name. If `init` was not run, `start` initializes them automatically.
|
||||
|
||||
Drive uses an in-memory OpenCode database by default. Set
|
||||
`OPENCODE_DRIVE_DB` when a test restarts the OpenCode service and needs sessions
|
||||
to survive the replacement process. Relative paths resolve inside the isolated
|
||||
run's OpenCode data directory:
|
||||
|
||||
```sh
|
||||
OPENCODE_DRIVE_DB=restart.sqlite \
|
||||
opencode-drive start --name restart-demo --script ./restart.ts
|
||||
```
|
||||
|
||||
Remove artifact directories left by sessions that are no longer active:
|
||||
|
||||
```sh
|
||||
opencode-drive prune
|
||||
```
|
||||
|
||||
Prune one inactive instance's artifacts by instance name, or force removal of all artifact directories:
|
||||
|
||||
```sh
|
||||
opencode-drive prune --name demo
|
||||
opencode-drive prune --force
|
||||
```
|
||||
|
||||
While developing, you can run `opencode-drive restart` to restart only the UI (the server will persist as a separate process). Do this with agents, and they will always restart and get the UI where you want it to be automatically.
|
||||
|
||||
View the [skills file](https://github.com/anomalyco/opencode/blob/v2/.opencode/skills/opencode-drive/SKILL.md) for more details about the CLI.
|
||||
|
||||
## Effect script API
|
||||
|
||||
Scripted runs use one fully typed, Effect-only definition. `setup` and `run`
|
||||
return Effects; Promise callbacks are not part of the API:
|
||||
|
||||
```sh
|
||||
opencode-drive script init ./drive.ts
|
||||
```
|
||||
|
||||
This creates a canonical starter without overwriting an existing file. The
|
||||
generated script is ready for `opencode-drive check ./drive.ts` and
|
||||
`start --script ./drive.ts`.
|
||||
|
||||
```ts
|
||||
import { defineScript, Effect, Llm } from "opencode-drive"
|
||||
|
||||
export default defineScript({
|
||||
config: {
|
||||
autoupdate: false,
|
||||
},
|
||||
tuiConfig: {
|
||||
theme: "system",
|
||||
},
|
||||
project: {
|
||||
git: true,
|
||||
files: {
|
||||
"src/example.ts": "export const value = 1\n",
|
||||
},
|
||||
},
|
||||
setup: ({ config, tuiConfig }) =>
|
||||
Effect.sync(() => {
|
||||
config.username = "Drive"
|
||||
tuiConfig.scroll_speed = 1
|
||||
}),
|
||||
run: ({ ui, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ui.submit("Read src/example.ts")
|
||||
yield* llm.send(Llm.text("The value is 1."))
|
||||
yield* ui.waitFor("The value is 1.")
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
`project.files` seeds the isolated project before `setup` runs. With
|
||||
`project.git: true`, Drive creates a fresh repository and commits the complete
|
||||
pre-launch state, including files written in `setup`. A prepared repository is
|
||||
never replaced; omit `project.git` when an `init` step supplies Git history.
|
||||
Declared `config` and `tuiConfig` values are deeply merged over fixture
|
||||
`.opencode/opencode.jsonc` and `.opencode/tui.jsonc` files. Arrays replace
|
||||
instead of merging, and mutations made in `setup` take final precedence.
|
||||
|
||||
Attach arbitrary provider-backed tools at runtime with their JSON schemas, then
|
||||
take and settle native OpenCode invocations by model call ID. `attach` replaces
|
||||
the complete dynamic set atomically; it does not affect the built-in adapters
|
||||
configured through the driver or script `tools` option.
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { Llm, OpenCodeDriver } from "opencode-drive"
|
||||
|
||||
export default OpenCodeDriver.use(({ tools, llm, ui }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tools.attach({
|
||||
tools: [
|
||||
{
|
||||
name: "lookup",
|
||||
description: "Look up a value",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { query: { type: "string" } },
|
||||
required: ["query"],
|
||||
},
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
properties: { answer: { type: "number" } },
|
||||
required: ["answer"],
|
||||
},
|
||||
options: { codemode: false },
|
||||
},
|
||||
],
|
||||
})
|
||||
yield* llm.queue(
|
||||
Llm.toolCall({
|
||||
index: 0,
|
||||
id: "call_lookup",
|
||||
name: "lookup",
|
||||
input: { query: "meaning" },
|
||||
}),
|
||||
Llm.finish("tool-calls"),
|
||||
)
|
||||
yield* ui.submit("Look up the meaning")
|
||||
|
||||
const lookup = yield* tools.take("call_lookup")
|
||||
yield* lookup.progress({
|
||||
structured: { phase: "searching" },
|
||||
content: [{ type: "text", text: "Searching" }],
|
||||
})
|
||||
yield* lookup.finish({
|
||||
structured: { answer: 42 },
|
||||
content: [{ type: "text", text: "42" }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Drive owns progress sequence numbers and retries uncertain operations without
|
||||
rerunning a claimed call. `awaitCancelled()` completes when OpenCode interrupts
|
||||
the native invocation before `finish` or `fail`. Dynamic registrations survive
|
||||
the tool-only controller reconnecting; an intentional server generation change
|
||||
cancels unresolved calls and reapplies the desired set after launch.
|
||||
|
||||
Declare which built-in tools Drive should intercept with `tools`, then control
|
||||
their invocations inside `run`. Each tool controller accepts calls in arrival
|
||||
order or by the stable call ID chosen in `Llm.toolCall`:
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { defineScript, Llm } from "opencode-drive"
|
||||
|
||||
export default defineScript({
|
||||
tools: ["shell"],
|
||||
run: ({ ui, llm, tools }) =>
|
||||
Effect.gen(function* () {
|
||||
const shells = yield* tools.control("shell")
|
||||
yield* llm.queue(
|
||||
Llm.toolCall({
|
||||
index: 0,
|
||||
id: "call_shell",
|
||||
name: "shell",
|
||||
input: { command: "deploy production" },
|
||||
}),
|
||||
Llm.finish("tool-calls"),
|
||||
)
|
||||
yield* ui.submit("Deploy production")
|
||||
const shell = yield* shells.take("call_shell")
|
||||
yield* shell.progress(`Running: ${shell.input.command}...\n`)
|
||||
yield* shell.succeed({ output: "Controlled output\n", exit: 0 })
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
Use `calls.take(id)` to coordinate known parallel calls independently, or
|
||||
`calls.take()` to accept the next unclaimed invocation. A controlled call can
|
||||
emit progress and then succeed or fail exactly once. `awaitInterrupted()`
|
||||
observes OpenCode interruption or transport disconnection. Drive interrupts
|
||||
all unresolved calls when it shuts down.
|
||||
|
||||
The original `tools(registry)` callback remains available for fixed handlers
|
||||
that do not need orchestration from `run`. Foreground handler Effects are
|
||||
interrupted when OpenCode interrupts the session, the transport disconnects,
|
||||
or Drive shuts down. Detached background shell handlers continue after their
|
||||
launch response and are interrupted when Drive shuts down.
|
||||
|
||||
Only declared or registered tools are replaced. Unhandled tools continue to
|
||||
use OpenCode's real implementations. Each `progress` value replaces the
|
||||
visible tool output; send accumulated output when earlier lines should remain
|
||||
visible.
|
||||
Supported adapters are `shell`, `webfetch`, and `websearch`; each handler
|
||||
receives its canonical typed V2 input and maintains an independent call index.
|
||||
When a shell call sets `background: true`, Drive returns immediately with the
|
||||
OpenCode tool call ID as `shellID`, keeps the handler running, and injects the
|
||||
terminal `completed`, `error`, or `cancelled` result into the session
|
||||
automatically. Background handlers are cancelled when Drive shuts down.
|
||||
|
||||
Type-check every new or edited script before running it:
|
||||
|
||||
```sh
|
||||
opencode-drive check ./drive.ts
|
||||
```
|
||||
|
||||
Drive resolves its script API, Effect, Bun declarations, and `tsgo` from the
|
||||
launching installation without installing packages or modifying the script's
|
||||
directory. When it detects an old Promise-style `setup`, `run`, or `ui.waitFor`
|
||||
callback, it prints the equivalent Effect shape after the TypeScript
|
||||
diagnostics. Use `Effect.sleep(milliseconds)` for unconditional delays.
|
||||
|
||||
The `fs`, `ui`, `llm`, `tools`, `server`, and `tuis` capabilities expose
|
||||
Effect-returning operations. Compose them with `yield*`, `Effect.flatMap`, or
|
||||
other Effect operators. Scripts receive the same `Ui`, `Tui`, `Tuis`, and TUI
|
||||
options as `OpenCodeDriver`; `defineScript` does not define a second
|
||||
programmatic interface. Predicates passed to `ui.waitFor` may return a boolean
|
||||
or an Effect. Set `launch: "manual"` to launch the shared OpenCode server and
|
||||
every TUI explicitly:
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { defineScript } from "opencode-drive"
|
||||
|
||||
export default defineScript({
|
||||
launch: "manual",
|
||||
run: ({ ui, server, tuis }) =>
|
||||
Effect.gen(function* () {
|
||||
// ui is null in manual mode.
|
||||
yield* server.launch()
|
||||
const alice = yield* tuis.launch("alice")
|
||||
const bob = yield* tuis.launch("bob")
|
||||
yield* alice.ui.submit("Hello from Alice")
|
||||
yield* bob.ui.screenshot("bob-view")
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
Only one server may be launched per script. All TUIs share its LLM backend. TUI
|
||||
processes and compiled script artifacts are cleaned up when the script ends.
|
||||
|
||||
`yield* server.kill()` stops the server so it can be launched again later.
|
||||
`yield* tui.close()` closes a TUI, after which its name may be reused.
|
||||
|
||||
Pass `{ recording: true }` to record an individual TUI:
|
||||
|
||||
```ts
|
||||
const alice = yield * tuis.launch("alice", { recording: true })
|
||||
yield * alice.ui.submit("Hello")
|
||||
yield * alice.close()
|
||||
```
|
||||
|
||||
Recordings are exported when the script settles. Call
|
||||
`alice.recording.finish()` only when the video is needed before settlement.
|
||||
|
||||
Background title requests receive `OpenCode Drive` by default and do not
|
||||
consume `llm.queue`, `llm.send`, or `llm.serve` responses. Manual-launch
|
||||
scripts can customize them before starting the server:
|
||||
|
||||
```ts
|
||||
yield * llm.title(() => Effect.succeed("Custom title"))
|
||||
yield * server.launch()
|
||||
```
|
||||
|
||||
Use `yield* llm.send(...)` to wait for and complete the next request or `yield*
|
||||
llm.queue(...)` to declare future responses upfront. For ongoing responses,
|
||||
the handler passed to `llm.serve` returns an Effect `Stream`:
|
||||
|
||||
```ts
|
||||
import { Stream } from "effect"
|
||||
import { Llm } from "opencode-drive"
|
||||
|
||||
yield * llm.serve((_request, index) => Stream.make(Llm.text(`Response ${index + 1}`)))
|
||||
```
|
||||
|
||||
The backend connection, default `finish("stop")`, and cleanup are automatic.
|
||||
Cancellation is represented by Effect interruption: interrupting the script or
|
||||
the fiber running an operation interrupts its in-flight work and runs scoped
|
||||
finalizers. There is no Promise compatibility shim or separate cancellation
|
||||
API. All public script types are canonically defined in
|
||||
[`src/script/types.ts`](./src/script/types.ts), which can be provided directly
|
||||
to an authoring agent.
|
||||
|
||||
`Llm.text()` streams text in randomized chunks. It defaults to a 2 ms delay and
|
||||
a target chunk size of 15 characters, varied by plus or minus 5 per chunk:
|
||||
|
||||
```ts
|
||||
Llm.text("A deliberately slower response", { delay: 20, chunkSize: 10 })
|
||||
```
|
||||
|
||||
`Llm.reasoning()` accepts the same streaming options. Use
|
||||
`Llm.pause(milliseconds)` to add timing between any two outputs.
|
||||
|
||||
`Llm.toolCall()` emits a complete call atomically by default. Pass the same
|
||||
streaming options to expose partial JSON input while it is generated:
|
||||
|
||||
```ts
|
||||
Llm.toolCall(
|
||||
{
|
||||
index: 0,
|
||||
id: "call_patch",
|
||||
name: "patch",
|
||||
input: { patchText: "*** Begin Patch\n*** End Patch" },
|
||||
},
|
||||
{ delay: 40, chunkSize: 12 },
|
||||
)
|
||||
```
|
||||
|
||||
Finish a tool-calling response with `Llm.finish("tool-calls")`. Streamed calls
|
||||
drive OpenCode's normal tool-input start, delta, and end lifecycle; `Llm.raw()`
|
||||
remains available for provider-wire scenarios not covered by these helpers.
|
||||
|
||||
Current OpenCode simulation endpoints expose a semantic UI tree alongside
|
||||
renderer state and terminal capture. Use `ui.snapshot()` for the complete
|
||||
versioned tree or `ui.getNode()` to poll for one exact semantic match. Semantic
|
||||
nodes carry stable IDs, optional occurrence identity, role, label, hierarchy,
|
||||
component-owned state, and a transient element handle that `ui.click()` can
|
||||
resolve safely:
|
||||
|
||||
```ts
|
||||
const allow =
|
||||
yield *
|
||||
ui.getNode({
|
||||
role: "option",
|
||||
label: "Allow once",
|
||||
selected: true,
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
yield * ui.click(allow)
|
||||
```
|
||||
|
||||
`ui.snapshot` and atomic semantic clicks are negotiated as optional
|
||||
capabilities so ordinary operations remain compatible with older OpenCode
|
||||
checkouts. Calling `ui.snapshot()`, `ui.getNode()`, or `ui.click(node)` when its
|
||||
required capability is unavailable fails locally with `UiCapabilityError`.
|
||||
|
||||
Capability errors are typed and the concrete classes are grouped under
|
||||
`Errors`. UI timeouts remain owner-fatal even when caught; recover locally
|
||||
from errors for which the script has a truthful fallback:
|
||||
|
||||
Polling timeouts from `ui.waitFor`, `ui.getElement`, and `ui.getNode` make one
|
||||
best-effort, bounded `ui.capture` request. When it succeeds, the resulting
|
||||
normalized terminal frame is available as `error.frame` without creating or
|
||||
retaining a screenshot file. RPC-level timeouts and failed diagnostic captures
|
||||
leave `error.frame` undefined.
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { Errors } from "opencode-drive"
|
||||
|
||||
yield *
|
||||
ui
|
||||
.getElement({ editor: true })
|
||||
.pipe(Effect.catchTag("UiElementAmbiguousError", (error) => Effect.logWarning(`Matched ${error.count} editors`)))
|
||||
|
||||
const isFileSystemError = (error: unknown) => error instanceof Errors.FileSystemError
|
||||
```
|
||||
|
||||
## Release validation
|
||||
|
||||
Before publishing a release, run the non-publishing validation command to
|
||||
check, test, and inspect the packed artifact:
|
||||
|
||||
```sh
|
||||
bun run release:validate
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
# Releasing opencode-drive
|
||||
|
||||
`opencode-drive` keeps its own version line. OpenCode product releases must not rewrite its version.
|
||||
|
||||
The imported baseline is `1.4.3` and the workspace package remains `private` until release setup is complete.
|
||||
Do not remove that guard or publish from this repository until both release gates are complete:
|
||||
|
||||
1. The versions of `@opencode-ai/client` and `@opencode-ai/protocol` written into the packed Drive manifest are available on npm, including the `@opencode-ai/protocol/simulation` export.
|
||||
2. npm package administration and trusted publishing move from `anomalyco/opencode-drive` to `anomalyco/opencode`.
|
||||
|
||||
The npm package is currently maintained by `jlongster`, and its trusted publisher is the old repository's
|
||||
`publish.yml`. James must add the destination release operator as an npm owner or update the trusted publisher
|
||||
himself. Keep James as an owner through the first successful release from this repository.
|
||||
|
||||
The first destination release will be `1.4.4`, which contains the pending special-key fix after `1.4.3`. Use a
|
||||
dedicated GitHub-hosted workflow named `publish-drive.yml` with Node 24, npm trusted publishing, and
|
||||
`id-token: write`. Its tag must be `opencode-drive-v1.4.4`; bare `v1.4.4` already belongs to OpenCode.
|
||||
|
||||
Before enabling that workflow:
|
||||
|
||||
1. Pack Drive and inspect the rewritten `package.json` inside the tarball.
|
||||
2. Install the tarball in a clean Bun consumer and import every public export.
|
||||
3. Run the installed `opencode-drive` binary and one scripted flow.
|
||||
4. Configure npm's trusted publisher for `anomalyco/opencode` and `publish-drive.yml`.
|
||||
5. Publish the namespaced tag and verify npm provenance points at this repository and workflow.
|
||||
|
||||
After the first successful destination release, disable the old publish workflow and archive the old repository.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,90 @@
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/symbols)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env bun
|
||||
import "../src/cli/index.js"
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as Effect from "effect/Effect"
|
||||
import { OpenCodeDriver, Tool } from "../src/index.js"
|
||||
import type { Frontend, Project, Tui, Tuis, Ui } from "../src/index.js"
|
||||
import type { ScriptContext } from "../src/script/types.js"
|
||||
|
||||
type Equal<Left, Right> =
|
||||
(<Value>() => Value extends Left ? 1 : 2) extends <Value>() => Value extends Right ? 1 : 2 ? true : false
|
||||
|
||||
type Assert<Value extends true> = Value
|
||||
|
||||
export type ScriptUiIsCanonical = Assert<Equal<ScriptContext["ui"], Ui>>
|
||||
export type ScriptTuiIsCanonical = Assert<Equal<ScriptContext["tui"], Tui>>
|
||||
export type ScriptTuisAreCanonical = Assert<Equal<ScriptContext["tuis"], Tuis>>
|
||||
export type ScriptToolsAreCanonical = Assert<Equal<ScriptContext["tools"], Tool.Controls>>
|
||||
export type DriverToolsAreCanonical = Assert<Equal<OpenCodeDriver.Driver["tools"], Tool.Controls>>
|
||||
export type LaunchedTuiIsCanonical = Assert<Equal<Effect.Success<ReturnType<Tuis["launch"]>>, Tui>>
|
||||
export type ResizeIsCanonicalAction = Assert<
|
||||
Equal<
|
||||
Extract<Frontend.Action, { readonly type: "ui.resize" }>,
|
||||
{
|
||||
readonly type: "ui.resize"
|
||||
readonly cols: number
|
||||
readonly rows: number
|
||||
}
|
||||
>
|
||||
>
|
||||
export type DriverProjectIsCanonical = Assert<Equal<NonNullable<OpenCodeDriver.Options["project"]>, Project>>
|
||||
|
||||
const zeroConfig = OpenCodeDriver.use(() => Effect.void)
|
||||
export type ZeroConfigUseIsRunnable = Assert<Equal<Effect.Services<typeof zeroConfig>, never>>
|
||||
|
||||
const controlledOptions: OpenCodeDriver.Options = { tools: ["shell"] }
|
||||
declare const controls: Tool.Controls
|
||||
const shellCalls = controls.control("shell")
|
||||
declare const dynamicTools: Tool.AttachParams
|
||||
const attached = controls.attach(dynamicTools)
|
||||
const dynamicCall = controls.take("call_lookup")
|
||||
declare const toolName: Tool.Name
|
||||
controls.control(toolName)
|
||||
export type ShellControlIsTyped = Assert<
|
||||
Equal<Effect.Success<typeof shellCalls>, Tool.ControlledCalls<Tool.ShellInput, Tool.ShellResult>>
|
||||
>
|
||||
export type DynamicAttachIsTyped = Assert<Equal<Effect.Success<typeof attached>, void>>
|
||||
export type DynamicCallIsTyped = Assert<Equal<Effect.Success<typeof dynamicCall>, Tool.Invocation>>
|
||||
const controlled = OpenCodeDriver.use(controlledOptions, ({ tools }) => tools.control("shell").pipe(Effect.asVoid))
|
||||
export type ControlledUseIsRunnable = Assert<Equal<Effect.Services<typeof controlled>, never>>
|
||||
@@ -0,0 +1,500 @@
|
||||
# OpenCode Driver API
|
||||
|
||||
Status: exploratory implementation, settled call sites only
|
||||
|
||||
This document records interface shapes that have been accepted during design. It intentionally omits unresolved alternatives rather than presenting them as competing proposals.
|
||||
|
||||
Internal resource ownership and desugaring are documented in [OpenCode Driver Architecture](./open-code-driver-architecture.md).
|
||||
|
||||
## Run Effect programs from the CLI
|
||||
|
||||
`opencode-drive run <module>` is the primary CLI entrypoint. The module must
|
||||
default-export an `Effect<_, _, never>`. Before importing the module, Drive
|
||||
generates and type-checks a contract entrypoint that assigns its default export
|
||||
to that fully provided Effect type. Drive then imports the module, verifies the
|
||||
value with `Effect.isEffect`, and yields it directly from the command handler.
|
||||
There is no nested runtime or detached owner.
|
||||
|
||||
```ts
|
||||
import { OpenCodeDriver } from "opencode-drive"
|
||||
|
||||
export default OpenCodeDriver.use(({ ui }) => ui.screenshot("home"))
|
||||
```
|
||||
|
||||
```sh
|
||||
opencode-drive run ./drive.ts
|
||||
```
|
||||
|
||||
The command accepts no flags and no arguments after `--`. Use the driver API in
|
||||
the module for simulation control. `opencode-drive check` validates Effect-only
|
||||
`defineScript` modules, and `start --script` executes them.
|
||||
|
||||
## `use` settles one scoped driver
|
||||
|
||||
`OpenCodeDriver.use(run)` is the zero-configuration top-level interface;
|
||||
`OpenCodeDriver.use(options, run)` configures the same lifecycle. Both acquire
|
||||
the driver returned by `make`, run the program, validate queued LLM work,
|
||||
finish recordings, close TUIs, export videos, and then release the server
|
||||
and project scope.
|
||||
|
||||
`OpenCodeDriver.useReport(run)` and `useReport(options, run)` have the same lifecycle semantics and
|
||||
returns both the user value and a compact `RunReport`. The report contains
|
||||
validated artifact and recording paths, retention, and endpoint compatibility.
|
||||
Set `opencode.compatibility` to `"required"` or `"preferred"`;
|
||||
the default is `"preferred"`, which negotiates when supported and reports an
|
||||
explicit legacy profile otherwise.
|
||||
|
||||
```ts
|
||||
import { NodeRuntime } from "@effect/platform-node"
|
||||
import { Effect } from "effect"
|
||||
import { Llm, OpenCodeDriver } from "opencode-drive"
|
||||
|
||||
const program = OpenCodeDriver.use(
|
||||
{
|
||||
project: {
|
||||
git: true,
|
||||
files: {
|
||||
"src/example.ts": "export const value = 1\n",
|
||||
},
|
||||
},
|
||||
config: {
|
||||
autoupdate: false,
|
||||
},
|
||||
tui: {
|
||||
viewport: {
|
||||
cols: 96,
|
||||
rows: 32,
|
||||
},
|
||||
recording: false,
|
||||
},
|
||||
},
|
||||
({ ui, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* llm.queue(Llm.text("The value is 1."))
|
||||
|
||||
yield* ui.submit("Read src/example.ts")
|
||||
yield* ui.waitFor("The value is 1.")
|
||||
}),
|
||||
)
|
||||
|
||||
NodeRuntime.runMain(program)
|
||||
```
|
||||
|
||||
`OpenCodeDriver.make(...)` remains the lower-level scoped constructor for programs that need to control settlement explicitly. Call `driver.settle()` before leaving its scope. `settle()` is terminal: it rejects new TUIs and LLM responses, validates queued work, stops TUIs, and exports recordings.
|
||||
|
||||
```ts
|
||||
const program = Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const driver = yield* OpenCodeDriver.make(options)
|
||||
yield* driver.ui.submit("Hello")
|
||||
yield* driver.settle()
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Capture font size is not part of this interface. The current renderer uses a fixed 16px font in 10-by-20 cells; the terminal catalog's `OPENCODE_DRIVE_FONT_SIZE=14` environment variable is currently ignored.
|
||||
|
||||
The generated SDK client is `opencode`. The primary frontend process is `tui`,
|
||||
its UI is also available directly as `ui`, and `tuis` launches more frontend
|
||||
processes:
|
||||
|
||||
```ts
|
||||
const health = yield * driver.opencode.health.get()
|
||||
const frame = yield * driver.tui.ui.capture()
|
||||
const secondary = yield * driver.tuis.launch()
|
||||
```
|
||||
|
||||
## The driver has one primary TUI and optional additional TUIs
|
||||
|
||||
The `tui` section configures the primary frontend created by `make`. Its UI is exposed directly as `ui` for the common case.
|
||||
|
||||
Additional TUIs connect to the same server and expose their own UI:
|
||||
|
||||
```ts
|
||||
const program = Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const oc = yield* OpenCodeDriver.make({
|
||||
tui: {
|
||||
viewport: {
|
||||
cols: 96,
|
||||
rows: 32,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const secondary = yield* oc.tuis.launch({
|
||||
viewport: {
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
},
|
||||
recording: true,
|
||||
})
|
||||
|
||||
yield* oc.ui.submit("Prompt from the primary TUI")
|
||||
yield* secondary.ui.submit("Prompt from the secondary TUI")
|
||||
yield* oc.settle()
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
`tuis.launch(options)` generates an identity. Pass a name as the first argument
|
||||
when a stable identity is useful for logs, recordings, or closing and
|
||||
relaunching the same TUI: `tuis.launch(name, options)`.
|
||||
|
||||
```text
|
||||
╭────────────────╮
|
||||
│ OpenCodeDriver ├───────────────────────╮
|
||||
╰────────┬───────╯ │
|
||||
╭────────────────╰──────────────────╮ │
|
||||
▼ ▼ │
|
||||
╭────────────────────────╮ ╭────────────────────╮ │
|
||||
│ Shared OpenCode Server │ │ Shared LLM Control │ │
|
||||
╰────────────┬───────────╯ ╰────────────────────╯ │
|
||||
╰───────────────────────────────╮ │
|
||||
▼ ▼ │
|
||||
╭────────────────╮ ╭────────────────────╮ │
|
||||
│ Primary TUI │◀───────────│ Additional TUIs │◀─────╯
|
||||
╰────────┬───────╯ ╰──────────┬─────────╯
|
||||
╰───╮ ╭──────╯
|
||||
▼ ▼
|
||||
╭────╮ ╭───────────╮
|
||||
│ ui │ │ tui.ui │
|
||||
╰────╯ ╰───────────╯
|
||||
```
|
||||
|
||||
## Common scripts destructure UI and LLM control
|
||||
|
||||
Scripts that only need the primary TUI should normally destructure the driver:
|
||||
|
||||
```ts
|
||||
const driver = yield * OpenCodeDriver.make()
|
||||
const { ui, llm } = driver
|
||||
|
||||
yield * llm.queue(Llm.text("Hello from the simulated model."))
|
||||
|
||||
yield * ui.submit("Hello")
|
||||
yield * ui.waitFor("Hello from the simulated model.")
|
||||
yield * driver.settle()
|
||||
```
|
||||
|
||||
Keep the aggregate value only when driver-wide capabilities such as `tuis` are needed:
|
||||
|
||||
```ts
|
||||
const oc = yield * OpenCodeDriver.make()
|
||||
const secondary = yield * oc.tuis.launch()
|
||||
|
||||
yield * oc.ui.screenshot("primary")
|
||||
yield * secondary.ui.screenshot("secondary")
|
||||
yield * oc.settle()
|
||||
```
|
||||
|
||||
## Runtime tool control uses statically declared adapters
|
||||
|
||||
Declare the built-in tool names Drive should intercept before OpenCode starts,
|
||||
then control each invocation through the live `tools` capability. Undeclared
|
||||
tools keep their real OpenCode implementations.
|
||||
|
||||
```ts
|
||||
const program = OpenCodeDriver.use({ tools: ["shell"] }, ({ tools, llm, ui }) =>
|
||||
Effect.gen(function* () {
|
||||
const shells = yield* tools.control("shell")
|
||||
yield* llm.queue(
|
||||
Llm.toolCall({
|
||||
index: 0,
|
||||
id: "call_build",
|
||||
name: "shell",
|
||||
input: { command: "bun run build" },
|
||||
}),
|
||||
Llm.toolCall({
|
||||
index: 1,
|
||||
id: "call_test",
|
||||
name: "shell",
|
||||
input: { command: "bun run test" },
|
||||
}),
|
||||
Llm.finish("tool-calls"),
|
||||
)
|
||||
yield* ui.submit("Build and test")
|
||||
|
||||
const build = yield* shells.take("call_build")
|
||||
const test = yield* shells.take("call_test")
|
||||
yield* test.succeed({ output: "Tests passed\n", exit: 0 })
|
||||
yield* build.succeed({ output: "Build passed\n", exit: 0 })
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
`take(callID)` reserves and accepts one known invocation independently of
|
||||
arrival order. `take()` accepts the oldest unclaimed invocation. Exact-ID
|
||||
waiters take precedence over generic waiters, so parallel calls may settle in
|
||||
any deliberate order. Each call may emit serialized progress and then succeed
|
||||
or fail exactly once. `awaitInterrupted()` completes when transport or
|
||||
controller interruption wins before terminal settlement.
|
||||
|
||||
The program must take and terminally settle every intercepted invocation it
|
||||
expects. Driver scope closure fails blocked `take` operations, interrupts
|
||||
unresolved calls, and waits for transport cleanup. The callback-style
|
||||
`tools(registry)` configuration remains available
|
||||
for fixed handlers; callback-controlled tools are not also available through
|
||||
the runtime `tools.control` capability.
|
||||
|
||||
## Arbitrary tools use the provider-backed lifecycle
|
||||
|
||||
`tools.attach({ tools })` atomically replaces the complete dynamic registration
|
||||
set for the current run. Registrations use OpenCode's canonical JSON Schema,
|
||||
permission, namespace, and CodeMode options. Static `shell`, `webfetch`, and
|
||||
`websearch` adapters remain installed separately.
|
||||
|
||||
```ts
|
||||
yield *
|
||||
tools.attach({
|
||||
tools: [
|
||||
{
|
||||
name: "lookup",
|
||||
description: "Look up a value",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { query: { type: "string" } },
|
||||
required: ["query"],
|
||||
},
|
||||
options: { codemode: false },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const invocation = yield * tools.take("call_lookup")
|
||||
yield *
|
||||
invocation.progress({
|
||||
structured: { phase: "searching" },
|
||||
content: [{ type: "text", text: "Searching" }],
|
||||
})
|
||||
yield *
|
||||
invocation.finish({
|
||||
structured: { answer: 42 },
|
||||
content: [{ type: "text", text: "42" }],
|
||||
})
|
||||
```
|
||||
|
||||
`take(callID)` matches `context.callID`, the model call ID supplied to
|
||||
`Llm.toolCall`; `invocation.id` is the producer's transport identity. Drive
|
||||
deduplicates invocation replay after a controller reconnect and retries
|
||||
progress or terminal operations with the same producer identity and progress
|
||||
sequence. `awaitCancelled()` observes OpenCode's native interruption. There is
|
||||
no public cancel operation because cancellation flows from OpenCode to Drive.
|
||||
|
||||
Attaching a dynamic effective name that collides with a configured static
|
||||
adapter fails locally. Calling `attach({ tools: [] })` clears the dynamic set.
|
||||
Older OpenCode revisions remain compatible with static adapters and ordinary
|
||||
LLM control; dynamic attachment fails with `Tool.LifecycleError` when the six
|
||||
tool lifecycle capabilities are unavailable.
|
||||
|
||||
## LLM response description is separate from live LLM control
|
||||
|
||||
`Llm` is a pure data module. `llm` is the live capability that queues, sends, and serves responses.
|
||||
|
||||
```ts
|
||||
yield *
|
||||
llm.queue(
|
||||
Llm.reasoning("Inspecting the file"),
|
||||
Llm.pause(20),
|
||||
Llm.text("The value is 1.", {
|
||||
delay: 2,
|
||||
chunkSize: 15,
|
||||
}),
|
||||
Llm.finish("stop"),
|
||||
)
|
||||
```
|
||||
|
||||
Each constructor returns an ordinary serializable value. Raw values with the same schema remain accepted.
|
||||
|
||||
Tool calls remain atomic when options are omitted. Supplying stream options
|
||||
serializes the input to JSON and emits provider-neutral partial tool input when
|
||||
the endpoint advertises that capability. Older endpoints retain the existing
|
||||
OpenAI-compatible fallback:
|
||||
|
||||
```ts
|
||||
Llm.toolCall(
|
||||
{
|
||||
index: 0,
|
||||
id: "call_patch",
|
||||
name: "patch",
|
||||
input: { patchText: "*** Begin Patch\n*** End Patch" },
|
||||
},
|
||||
{ delay: 40, chunkSize: 12 },
|
||||
)
|
||||
```
|
||||
|
||||
The authoritative schema is a manual union of independently named variants:
|
||||
|
||||
```ts
|
||||
export const Text = Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
options: Schema.optionalKey(StreamOptions),
|
||||
})
|
||||
export interface Text extends Schema.Schema.Type<typeof Text> {}
|
||||
|
||||
export const Reasoning = Schema.Struct({
|
||||
type: Schema.Literal("reasoning"),
|
||||
text: Schema.String,
|
||||
options: Schema.optionalKey(StreamOptions),
|
||||
})
|
||||
export interface Reasoning extends Schema.Schema.Type<typeof Reasoning> {}
|
||||
|
||||
export const Pause = Schema.Struct({
|
||||
type: Schema.Literal("pause"),
|
||||
milliseconds: NonNegativeMilliseconds,
|
||||
})
|
||||
export interface Pause extends Schema.Schema.Type<typeof Pause> {}
|
||||
|
||||
export const Finish = Schema.Struct({
|
||||
type: Schema.Literal("finish"),
|
||||
reason: Schema.optionalKey(FinishReason),
|
||||
})
|
||||
export interface Finish extends Schema.Schema.Type<typeof Finish> {}
|
||||
|
||||
export const Output = Schema.Union([Text, Reasoning, Pause, Finish, ToolCall, Raw, Disconnect])
|
||||
export type Output = Schema.Schema.Type<typeof Output>
|
||||
```
|
||||
|
||||
Pure constructors delegate to those individual schemas:
|
||||
|
||||
```ts
|
||||
export const text = (text: string, options?: StreamOptions): Text =>
|
||||
Text.make({
|
||||
type: "text",
|
||||
text,
|
||||
...(options ? { options } : {}),
|
||||
})
|
||||
```
|
||||
|
||||
No `.cases` interface appears in userland.
|
||||
|
||||
## One `queue` call describes one future model response
|
||||
|
||||
Multiple outputs in one call are ordered events within one response:
|
||||
|
||||
```ts
|
||||
yield *
|
||||
llm.queue(
|
||||
Llm.toolCall({
|
||||
index: 0,
|
||||
id: "call_permission_capture",
|
||||
name: "patch",
|
||||
input: {
|
||||
patchText,
|
||||
},
|
||||
}),
|
||||
Llm.finish("tool-calls"),
|
||||
)
|
||||
```
|
||||
|
||||
A second call queues a response for the next model request:
|
||||
|
||||
```ts
|
||||
yield * llm.queue(Llm.text("The fixture was updated."))
|
||||
```
|
||||
|
||||
Responses without an explicit terminal output finish with `"stop"`. Title requests remain separate and do not consume this queue.
|
||||
|
||||
## `defineScript` is Effect-only
|
||||
|
||||
`defineScript` does not provide a Promise adapter. Its `setup` and `run`
|
||||
callbacks return Effects, as do operations on `fs`, `ui`, `llm`, `server`,
|
||||
and `tuis`. Compose script operations in the same runtime with
|
||||
`yield*` or Effect operators.
|
||||
|
||||
### Primary UI
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { defineScript, Llm } from "opencode-drive"
|
||||
|
||||
export default defineScript({
|
||||
run: ({ ui, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* llm.queue(Llm.text("The value is 1."))
|
||||
yield* ui.submit("Read src/example.ts")
|
||||
yield* ui.waitFor("The value is 1.")
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
`llm.serve` accepts a handler that returns an Effect `Stream`. The registration
|
||||
itself is also an Effect:
|
||||
|
||||
```ts
|
||||
import { Stream } from "effect"
|
||||
import { Llm } from "opencode-drive"
|
||||
|
||||
yield * llm.serve((_request, index) => Stream.make(Llm.text(`Response ${index + 1}`)))
|
||||
```
|
||||
|
||||
Predicates passed to `ui.waitFor` may return a boolean or an Effect.
|
||||
Capability methods expose typed error channels. Concrete tagged errors are
|
||||
available from the `Errors` namespace.
|
||||
|
||||
`ui.snapshot()` returns the endpoint's versioned semantic tree. `ui.getNode()`
|
||||
polls for one exact match and fails with `UiNodeAmbiguousError` when more than
|
||||
one node matches. Semantic snapshots and identity-checked semantic clicks are
|
||||
optional during negotiation, so older OpenCode checkouts retain ordinary UI
|
||||
control while unsupported semantic operations fail locally with
|
||||
`UiCapabilityError`.
|
||||
|
||||
```ts
|
||||
const option =
|
||||
yield *
|
||||
ui.getNode({
|
||||
role: "option",
|
||||
label: "Allow once",
|
||||
selected: true,
|
||||
})
|
||||
yield * ui.click(option)
|
||||
```
|
||||
|
||||
### Additional TUI
|
||||
|
||||
```ts
|
||||
yield * server.launch()
|
||||
const alice = yield * tuis.launch("alice")
|
||||
const bob = yield * tuis.launch("bob")
|
||||
|
||||
yield * alice.ui.submit("Hello from Alice")
|
||||
yield * bob.ui.screenshot("bob-view")
|
||||
```
|
||||
|
||||
### TUI configuration
|
||||
|
||||
```ts
|
||||
export default defineScript({
|
||||
tui: {
|
||||
viewport: {
|
||||
cols: 118,
|
||||
rows: 34,
|
||||
},
|
||||
},
|
||||
run: ({ ui }) => ui.screenshot("home").pipe(Effect.asVoid),
|
||||
})
|
||||
```
|
||||
|
||||
Script cancellation uses Effect interruption. Interrupting the script or an
|
||||
operation's fiber interrupts in-flight work and runs its scoped finalizers;
|
||||
there is no `AbortSignal`, Promise cancellation convention, or compatibility
|
||||
shim.
|
||||
|
||||
## Settled interface
|
||||
|
||||
- `OpenCodeDriver.use(run)` and `use(options, run)` are the safe top-level brackets and perform typed settlement.
|
||||
- `OpenCodeDriver.make(options)` is the primary scoped constructor.
|
||||
- `opencode` is the generated OpenCode SDK client.
|
||||
- Programs that call `make` directly call terminal `driver.settle()` before leaving the scope.
|
||||
- Direct library programs run the same Effect without any export convention.
|
||||
- The `tui` section configures one primary TUI.
|
||||
- The primary TUI's UI is exposed as `ui` and `oc.ui`.
|
||||
- The common case destructures `{ ui, llm }`.
|
||||
- `oc.tuis.launch(options?)` creates an additional TUI with a generated identity.
|
||||
- `oc.tuis.launch(name, options?)` creates a TUI with a stable identity.
|
||||
- Additional TUIs expose their UI as `tui.ui`.
|
||||
- Drivers and scripts share the same `Tui`, `Tuis`, `Ui`, and option types.
|
||||
- `Llm` exposes pure constructors over manually composed Effect Schemas.
|
||||
- Raw schema-compatible LLM output objects remain accepted.
|
||||
- One `llm.queue(...)` call describes one future model response.
|
||||
@@ -0,0 +1,228 @@
|
||||
# OpenCode Driver Architecture
|
||||
|
||||
This guide describes the current Effect-native architecture. The public call
|
||||
sites are documented in [OpenCode Driver API](./open-code-driver-api.md).
|
||||
|
||||
## Domain Model
|
||||
|
||||
`OpenCodeDriver` composes these resources:
|
||||
|
||||
```text
|
||||
OpenCodeDriver
|
||||
project isolated files and configuration
|
||||
opencode generated OpenCode SDK client
|
||||
tui primary frontend process
|
||||
tuis additional frontend process factory
|
||||
ui convenience alias for tui.ui
|
||||
llm shared simulated-model control
|
||||
tools runtime control for static adapters and arbitrary tools
|
||||
```
|
||||
|
||||
The names distinguish the two kinds of client involved:
|
||||
|
||||
- `opencode` is the generated `@opencode-ai/client` SDK value.
|
||||
- `Tui` is a launched OpenCode frontend process with `ui`, `close`, and an
|
||||
optional `recording`.
|
||||
- `Tuis` launches and supervises additional frontend processes connected to
|
||||
the same server.
|
||||
- `tools.control` accepts independently controlled invocations for adapters
|
||||
declared before OpenCode starts.
|
||||
- `tools.attach` and `tools.take` control arbitrary native tools through the
|
||||
canonical provider-backed lifecycle.
|
||||
- Transport-level JSON-RPC clients remain private implementation details.
|
||||
|
||||
`defineScript` consumes these exact capabilities. It adds a branded module
|
||||
contract, restart behavior, filesystem access, and explicit manual launch. It
|
||||
does not define another UI, TUI, LLM, or project vocabulary.
|
||||
|
||||
## Ownership
|
||||
|
||||
```text
|
||||
Effect Scope
|
||||
OpenCodeProject
|
||||
artifact root
|
||||
isolated project files
|
||||
OpenCodeInstance
|
||||
server process
|
||||
TUI processes
|
||||
launch descriptors and logs
|
||||
CLI script ToolController
|
||||
controlled invocation exchanges
|
||||
OpenCodeServer
|
||||
backend simulation connection
|
||||
reconnecting tool-only backend connection
|
||||
LLM controller
|
||||
dynamic ToolProducer
|
||||
generated OpenCode SDK connection
|
||||
TUI supervisor
|
||||
primary TUI scope
|
||||
additional TUI scopes
|
||||
Library ToolController
|
||||
controlled invocation exchanges
|
||||
```
|
||||
|
||||
Library drivers create their ToolController before project preparation and
|
||||
pass that controller into `OpenCodeInstance`. CLI scripts create the controller
|
||||
inside `OpenCodeInstance`. Prepared drivers and script contexts combine the
|
||||
instance's static controller with the server's dynamic producer. The static
|
||||
controller that wrote plugin configuration remains the one exposed through
|
||||
`tools.control`.
|
||||
|
||||
`OpenCodeDriver.make(options)` requires `Scope.Scope`. It returns once the
|
||||
server, generated SDK client, primary TUI, and simulation connections are
|
||||
ready. `OpenCodeDriver.use` supplies that scope and performs terminal
|
||||
settlement even when the user program fails.
|
||||
|
||||
## Settlement
|
||||
|
||||
Settlement is one shared terminal operation. It runs in this order:
|
||||
|
||||
1. Validate that queued LLM work was consumed.
|
||||
2. Validate that native dynamic-tool invocations were settled.
|
||||
3. Shut down the LLM controller.
|
||||
4. Finish active recording timelines.
|
||||
5. Close all TUI scopes and processes.
|
||||
6. Export completed recordings.
|
||||
7. Decode the schema-validated `RunReport`.
|
||||
|
||||
`driver.settle()` is shared and idempotent. Once settlement starts, `tuis`
|
||||
rejects new launches and `llm` rejects new responses. `OpenCodeDriver.use`
|
||||
combines a user-program failure with a settlement failure rather than hiding
|
||||
either cause.
|
||||
|
||||
## Tool Control Lifecycle
|
||||
|
||||
`ToolController` installs only statically declared or callback-registered
|
||||
adapters into OpenCode's project configuration. Each runtime-controlled tool
|
||||
owns one exchange that matches incoming requests to exact-ID or FIFO waiters.
|
||||
Each accepted call owns a terminal Deferred, an interruption Deferred, and a
|
||||
one-permit Semaphore that serializes progress with terminal commitment.
|
||||
|
||||
Controller scope release closes blocked waiters, marks unresolved calls
|
||||
interrupted, aborts active HTTP transports, and waits for handler finalizers.
|
||||
Terminal commitment uses a synchronous first-writer-wins Deferred completion;
|
||||
Drive guarantees exactly-once acceptance inside the controller, not delivery
|
||||
across a transport disconnect.
|
||||
|
||||
`ToolProducer` owns a separate backend socket because LLM chunks are not
|
||||
idempotent and an LLM socket closure is terminal to `LlmController`. Dynamic
|
||||
tool progress and terminal RPCs are idempotent by producer invocation ID and
|
||||
sequence, so the tool-only connection may reconnect and replay pending
|
||||
invocations safely. One ordered event stream preserves invocation-before-
|
||||
cancellation order. The desired registration set survives reconnects and
|
||||
manual server relaunches; invocation records are scoped to one server
|
||||
generation because producer IDs may be reused by a new process.
|
||||
Settlement first clears the dynamic registration set on OpenCode, then drains
|
||||
the ordered local event stream before checking for unresolved invocations. The
|
||||
clear acts as the server-side barrier that prevents a native invocation from
|
||||
appearing after a successful settlement snapshot. Settlement is terminal for
|
||||
dynamic attachment. Reconnects remain available while the clear is in flight;
|
||||
the final connection gate drains any reconnect that landed during settlement
|
||||
before preventing further backend creation. If the server generation has
|
||||
already ended, its teardown has cleared the generation-scoped invocation
|
||||
records, so settlement does not wait for a replacement backend.
|
||||
|
||||
## TUI Lifecycle
|
||||
|
||||
`Tuis.launch(options)` generates an internal identity. `Tuis.launch(name,
|
||||
options)` uses a stable caller-supplied identity. Both return the same value:
|
||||
|
||||
```ts
|
||||
interface Tui {
|
||||
readonly ui: Ui
|
||||
readonly close: () => Effect.Effect<void>
|
||||
readonly recording?: Recording
|
||||
}
|
||||
```
|
||||
|
||||
Each TUI owns one frontend process, one negotiated UI connection, and
|
||||
optionally one recording timeline. Closing a named TUI releases its identity
|
||||
for reuse. An unexpected process exit fails the owning driver or script.
|
||||
|
||||
The primary TUI is not a special interface. `driver.tui` and values returned
|
||||
by `driver.tuis` have exactly the same `Tui` type. `driver.ui` is only
|
||||
`driver.tui.ui` exposed for the common single-TUI call site.
|
||||
|
||||
## OpenCode SDK
|
||||
|
||||
The server process writes an authenticated service registration into its
|
||||
isolated state directory. `driver/opencode.ts` discovers that registration and
|
||||
constructs the generated Effect SDK client with the project directory header.
|
||||
Passwords and registration paths remain internal. The resulting value is
|
||||
exposed as `driver.opencode` and `ScriptContext.opencode`.
|
||||
|
||||
## Canonical Protocol
|
||||
|
||||
`@opencode-ai/protocol/simulation` contains the single schema definition for OpenCode's
|
||||
handshake, frontend, and backend simulation messages. `client/protocol.ts`
|
||||
publishes those namespaces without redefining their data types.
|
||||
|
||||
```text
|
||||
Frontend protocol schemas
|
||||
-> driver/ui.ts Effect Ui capability
|
||||
-> driver/client.ts Tui and Tuis lifecycle
|
||||
-> driver/index.ts OpenCodeDriver aggregate
|
||||
-> script/types.ts exact capability reuse
|
||||
```
|
||||
|
||||
CLI `--command.ui.*` names are exhaustively checked against
|
||||
`Frontend.Capabilities`. The Promise transport under `opencode-drive/client`
|
||||
is separate from the Effect programmatic model but consumes the same protocol
|
||||
schemas.
|
||||
|
||||
## Transport Seam
|
||||
|
||||
`SimulationConnector` owns WebSocket acquisition, handshake negotiation,
|
||||
schema validation, request correlation, interruption, and connection failure.
|
||||
The driver receives the connector through an Effect service and does not
|
||||
expose it in userland.
|
||||
|
||||
The UI connection is request-response JSON-RPC. The LLM backend additionally
|
||||
receives unsolicited `llm.request` notifications. The tool-only backend keeps
|
||||
ordered `tool.invocation` and `tool.cancel` notifications on one validated
|
||||
stream and does not call `llm.attach`.
|
||||
|
||||
## Project Setup
|
||||
|
||||
Neutral project contracts live in `src/project.ts` so neither the driver nor
|
||||
scripts own the shared vocabulary:
|
||||
|
||||
```text
|
||||
Project
|
||||
Setup
|
||||
SetupContext
|
||||
ProjectFileSystem
|
||||
OpenCodeConfig
|
||||
OpenCodeTuiConfig
|
||||
```
|
||||
|
||||
Configuration is applied in this order:
|
||||
|
||||
1. Write declared project files.
|
||||
2. Read fixture `opencode.jsonc` and `tui.jsonc` values.
|
||||
3. Deep-merge `config` and `tuiConfig`; arrays replace existing arrays.
|
||||
4. Run Effect-only `setup`, which may mutate both merged objects.
|
||||
5. Write normalized JSON and optionally commit the Git baseline.
|
||||
|
||||
## Dependency Direction
|
||||
|
||||
```text
|
||||
project -> Effect and Schema
|
||||
simulation -> canonical protocol and Effect RPC
|
||||
driver -> project + simulation + instance + recording
|
||||
script -> project + driver capabilities
|
||||
cli -> script + driver + Promise transport
|
||||
```
|
||||
|
||||
Lower-level modules do not import the package root or the driver/script
|
||||
barrels. `script/types.ts` may reference driver capabilities; driver modules
|
||||
must not reference script types.
|
||||
|
||||
## Public Entry Points
|
||||
|
||||
- `opencode-drive`: Effect driver, scripts, project contracts, LLM constructors.
|
||||
- `opencode-drive/driver`: complete Effect driver namespace.
|
||||
- `opencode-drive/script`: `defineScript` and script contracts.
|
||||
- `opencode-drive/client`: Promise simulation transport.
|
||||
- `opencode-drive/llm`: pure LLM output constructors and schemas.
|
||||
- `opencode-drive/recording`: recording decode, replay, and export utilities.
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Effect, Stream } from "effect"
|
||||
import { defineScript, Llm } from "opencode-drive"
|
||||
|
||||
export default defineScript({
|
||||
launch: "manual",
|
||||
|
||||
run: ({ server, tuis, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* server.launch()
|
||||
|
||||
yield* llm.serve((_request, index) => Stream.make(Llm.text(`Response for request ${index + 1}`)))
|
||||
|
||||
const [alice, bob] = yield* Effect.all(
|
||||
[tuis.launch("alice", { recording: true }), tuis.launch("bob", { recording: true })],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
yield* Effect.all([alice.ui.submit("Reply to Alice"), bob.ui.submit("Reply to Bob")], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
yield* Effect.all(
|
||||
[alice.ui.screenshot("multiple-clients-alice-submitted"), bob.ui.screenshot("multiple-clients-bob-submitted")],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
yield* Effect.all(
|
||||
[
|
||||
alice.ui.waitFor("Response for request", { timeout: 30_000 }),
|
||||
bob.ui.waitFor("Response for request", { timeout: 30_000 }),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
yield* Effect.all(
|
||||
[alice.ui.screenshot("multiple-clients-alice-complete"), bob.ui.screenshot("multiple-clients-bob-complete")],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
yield* server.kill()
|
||||
yield* Effect.sleep(500)
|
||||
yield* Effect.all(
|
||||
[
|
||||
alice.ui.screenshot("multiple-clients-alice-server-stopped"),
|
||||
bob.ui.screenshot("multiple-clients-bob-server-stopped"),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
yield* server.launch()
|
||||
yield* Effect.sleep(1000)
|
||||
yield* Effect.all(
|
||||
[
|
||||
alice.ui.screenshot("multiple-clients-alice-server-relaunched"),
|
||||
bob.ui.screenshot("multiple-clients-bob-server-relaunched"),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Effect, Stream } from "effect"
|
||||
import { defineScript, Llm } from "opencode-drive"
|
||||
|
||||
export default defineScript({
|
||||
setup: ({ fs }) =>
|
||||
fs.writeFile(
|
||||
"src/greeting.ts",
|
||||
["export function greeting(name: string) {", " return `Welcome, ${name}!`", "}", ""].join("\n"),
|
||||
),
|
||||
|
||||
run: ({ llm, ui }) =>
|
||||
Effect.gen(function* () {
|
||||
let turn = 0
|
||||
|
||||
yield* llm.title(() => Effect.succeed("Understanding the greeting"))
|
||||
yield* llm.serve(() => {
|
||||
if (turn++ === 0)
|
||||
return Stream.make(
|
||||
Llm.reasoning("I should read the implementation before explaining it."),
|
||||
Llm.toolCall({
|
||||
index: 0,
|
||||
id: "call_read_greeting",
|
||||
name: "read",
|
||||
input: { filePath: "src/greeting.ts" },
|
||||
}),
|
||||
Llm.finish("tool-calls"),
|
||||
)
|
||||
|
||||
return Stream.make(
|
||||
Llm.text("The function accepts a name, "),
|
||||
Llm.pause(150),
|
||||
Llm.text("places it into a welcome message, "),
|
||||
Llm.pause(150),
|
||||
Llm.text("and adds an exclamation mark."),
|
||||
Llm.pause(150),
|
||||
Llm.finish("stop"),
|
||||
)
|
||||
})
|
||||
|
||||
yield* ui.submit("Read src/greeting.ts and explain what it does.")
|
||||
yield* ui.waitFor("adds an exclamation mark")
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Effect } from "effect"
|
||||
import { defineScript, Llm } from "opencode-drive"
|
||||
|
||||
export default defineScript({
|
||||
setup: ({ fs }) => fs.writeFile("src/message.ts", 'export const message = "Hello from OpenCode Drive"\n'),
|
||||
|
||||
run: ({ llm, ui }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ui.waitFor((state) => state.focused.editor)
|
||||
const editor = yield* ui.getElement({ editor: true, focused: true })
|
||||
yield* ui.focus(editor)
|
||||
|
||||
yield* ui.submit("What does src/message.ts export?")
|
||||
yield* llm.send(
|
||||
Llm.reasoning("I should inspect the small source file first.", {
|
||||
delay: 5,
|
||||
chunkSize: 10,
|
||||
}),
|
||||
Llm.pause(100),
|
||||
Llm.text('src/message.ts exports `message` with the value "Hello from OpenCode Drive".', {
|
||||
delay: 10,
|
||||
chunkSize: 12,
|
||||
}),
|
||||
)
|
||||
yield* llm.send(Llm.text("Message export"))
|
||||
|
||||
yield* ui.waitFor("Hello from OpenCode Drive")
|
||||
if (!(yield* ui.matches("OpenCode Drive"))) throw new Error("the expected response was not visible")
|
||||
|
||||
yield* ui.screenshot("simple-response")
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Effect } from "effect"
|
||||
import { defineScript, Llm } from "opencode-drive"
|
||||
|
||||
export default defineScript({
|
||||
tui: { viewport: { cols: 120, rows: 36 } },
|
||||
|
||||
setup: ({ fs }) => fs.writeFile("src/viewport.ts", `export const viewportSequence = ["120x36", "80x24", "50x18"]\n`),
|
||||
|
||||
run: ({ ui, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ui.submit("Show a compact status report for the viewport resize demo.")
|
||||
yield* llm.send(
|
||||
Llm.text(
|
||||
"Viewport demo: starting wide at 120 columns by 36 rows. The file src/viewport.ts lists the planned sequence. This first response should have plenty of horizontal room before the terminal narrows.",
|
||||
),
|
||||
)
|
||||
yield* Effect.sleep(900)
|
||||
|
||||
yield* ui.resize({ cols: 80, rows: 24 })
|
||||
yield* Effect.sleep(900)
|
||||
|
||||
yield* ui.submit("Now describe the medium viewport.")
|
||||
yield* llm.send(
|
||||
Llm.text(
|
||||
"Medium viewport: resized to 80 columns by 24 rows. Lines should wrap sooner, the composer has less vertical breathing room, and the conversation should reflow without losing focus.",
|
||||
),
|
||||
)
|
||||
yield* ui.waitFor("resized to 80 columns")
|
||||
yield* Effect.sleep(900)
|
||||
|
||||
yield* ui.resize({ cols: 50, rows: 18 })
|
||||
yield* Effect.sleep(900)
|
||||
|
||||
yield* ui.submit("Finish with the narrow viewport summary.")
|
||||
yield* llm.send(
|
||||
Llm.text(
|
||||
"Narrow viewport: now 50 columns by 18 rows. This final state is intentionally cramped so modal, wrapping, and footer behavior are easy to inspect in the recording.",
|
||||
),
|
||||
)
|
||||
yield* ui.waitFor("now 50 columns")
|
||||
yield* Effect.sleep(1200)
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode-drive",
|
||||
"version": "1.4.3",
|
||||
"private": true,
|
||||
"description": "Drive real and simulated OpenCode instances",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/anomalyco/opencode.git",
|
||||
"directory": "packages/drive"
|
||||
},
|
||||
"homepage": "https://github.com/anomalyco/opencode/tree/v2/packages/drive#readme",
|
||||
"bugs": "https://github.com/anomalyco/opencode/issues",
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"engines": {
|
||||
"bun": ">=1.3.14"
|
||||
},
|
||||
"bin": {
|
||||
"opencode-drive": "bin/opencode-drive"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./client": "./src/client/index.ts",
|
||||
"./driver": "./src/driver/index.ts",
|
||||
"./frame": "./src/frame/index.ts",
|
||||
"./llm": "./src/llm/index.ts",
|
||||
"./recording": "./src/recording/index.ts",
|
||||
"./script": "./src/script/index.ts"
|
||||
},
|
||||
"files": [
|
||||
"assets/fonts",
|
||||
"bin/opencode-drive",
|
||||
"docs",
|
||||
"src/cli",
|
||||
"src/client",
|
||||
"src/driver",
|
||||
"src/frame",
|
||||
"src/instance",
|
||||
"src/llm",
|
||||
"src/log.ts",
|
||||
"src/project.ts",
|
||||
"src/recording",
|
||||
"src/script",
|
||||
"src/simulation",
|
||||
"src/tool",
|
||||
"src/index.ts",
|
||||
"LICENSE",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"drive": "bun run src/cli/index.ts",
|
||||
"lint": "cd ../.. && bun run lint -- packages/drive/src",
|
||||
"test": "bun run test:effect && bun run test:cli",
|
||||
"test:effect": "bun run --bun vitest run",
|
||||
"test:cli": "bun test test/cli/integration.test.ts",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"check": "bun run lint && bun run typecheck",
|
||||
"release:validate": "bun run check && bun run test && bun pm pack --dry-run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@napi-rs/canvas": "1.0.2",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@wterm/core": "0.3.0",
|
||||
"@wterm/ghostty": "0.3.0",
|
||||
"effect": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/vitest": "4.0.0-beta.101",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"oxlint": "1.60.0",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { rm } from "node:fs/promises"
|
||||
import { initializeInstance } from "../instance/instance.js"
|
||||
import { checkScript } from "../script/tooling.js"
|
||||
|
||||
export async function check(file: string) {
|
||||
const artifacts = await initializeInstance()
|
||||
try {
|
||||
try {
|
||||
await checkScript(artifacts, file)
|
||||
} catch (error) {
|
||||
const source = await Bun.file(file)
|
||||
.slice(0, 256 * 1024)
|
||||
.text()
|
||||
.catch(() => "")
|
||||
const hint = effectScriptHint(source, message(error))
|
||||
if (hint !== undefined) throw new Error(`${message(error)}\n\n${hint}`, { cause: error })
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
await rm(artifacts, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
export function effectScriptHint(source: string, diagnostics: string) {
|
||||
if (!/\bPromise(?:Like)?</.test(diagnostics)) return undefined
|
||||
if (!/\bdefineScript\s*\(/.test(source)) return undefined
|
||||
const relevant = diagnosticSource(source, diagnostics)
|
||||
if (/\.waitFor\s*\(/.test(relevant))
|
||||
return `${heading}
|
||||
|
||||
Instead of:
|
||||
ui.waitFor(async (state) => state.focused.editor)
|
||||
|
||||
Use:
|
||||
ui.waitFor((state) => Effect.succeed(state.focused.editor))`
|
||||
if (/\bsetup\s*:|\basync\s+setup\s*\(/.test(relevant))
|
||||
return `${heading}
|
||||
|
||||
Instead of:
|
||||
setup: async ({ fs }) => {
|
||||
await fs.writeFile("src/example.ts", "export {}")
|
||||
}
|
||||
|
||||
Use:
|
||||
setup: ({ fs }) => fs.writeFile("src/example.ts", "export {}")`
|
||||
if (/\brun\s*:|\basync\s+run\s*\(/.test(relevant))
|
||||
return `${heading}
|
||||
|
||||
Instead of:
|
||||
run: async ({ ui }) => {
|
||||
await ui.submit("Hello")
|
||||
}
|
||||
|
||||
Use:
|
||||
run: ({ ui }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ui.submit("Hello")
|
||||
})`
|
||||
return undefined
|
||||
}
|
||||
|
||||
const heading = "OpenCode Drive scripts are Effect-only. Promise callbacks are not supported."
|
||||
|
||||
function diagnosticSource(source: string, diagnostics: string) {
|
||||
const lines = source.split("\n")
|
||||
const numbers = [...diagnostics.matchAll(/(?::(\d+):\d+|\((\d+),\d+\))/g)]
|
||||
.map((match) => Number(match[1] ?? match[2]))
|
||||
.filter((line) => Number.isInteger(line) && line > 0)
|
||||
return numbers.length === 0 ? source : numbers.map((line) => lines[line - 1] ?? "").join("\n")
|
||||
}
|
||||
|
||||
function message(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import * as Cause from "effect/Cause"
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as Exit from "effect/Exit"
|
||||
import { Frontend } from "../client/protocol.js"
|
||||
import { recordLog } from "../log.js"
|
||||
import * as SimulationConnector from "../simulation/connector.js"
|
||||
import type { DriveCommand } from "./types.js"
|
||||
|
||||
export const commandInfo = {
|
||||
"ui.type": { value: true, description: "Type text using JSON params" },
|
||||
"ui.press": { value: true, description: "Press a key using JSON params" },
|
||||
"ui.enter": { value: false, description: "Press Enter" },
|
||||
"ui.arrow": {
|
||||
value: true,
|
||||
description: "Press an arrow key using JSON params",
|
||||
},
|
||||
"ui.focus": {
|
||||
value: true,
|
||||
description: "Focus an element using JSON params",
|
||||
},
|
||||
"ui.click": { value: true, description: "Click using JSON params" },
|
||||
"ui.resize": {
|
||||
value: true,
|
||||
description: "Resize terminal viewport using JSON params",
|
||||
},
|
||||
"ui.screenshot": {
|
||||
value: "optional",
|
||||
description: "Take a screenshot with optional JSON params and return its path",
|
||||
},
|
||||
"ui.capture": {
|
||||
value: false,
|
||||
description: "Capture the terminal frame as JSON",
|
||||
},
|
||||
"ui.state": {
|
||||
value: false,
|
||||
description: "Return focus, elements, and available UI actions",
|
||||
},
|
||||
"ui.snapshot": {
|
||||
value: false,
|
||||
description: "Return the semantic UI tree as JSON",
|
||||
},
|
||||
"ui.matches": {
|
||||
value: true,
|
||||
description: "Check for literal screen text using JSON params",
|
||||
},
|
||||
"ui.recording.finish": {
|
||||
value: false,
|
||||
description: "Finish recording and return the timeline path",
|
||||
},
|
||||
} as const satisfies Record<
|
||||
Exclude<Frontend.Capability, "ui.click.semantic">,
|
||||
{ readonly value: boolean | "optional"; readonly description: string }
|
||||
>
|
||||
|
||||
type CommandName = Exclude<Frontend.Capability, "ui.click.semantic">
|
||||
|
||||
export function isCommandName(operation: string): operation is CommandName {
|
||||
return Object.hasOwn(commandInfo, operation)
|
||||
}
|
||||
|
||||
export function commandAcceptsValue(operation: CommandName) {
|
||||
return commandInfo[operation].value
|
||||
}
|
||||
|
||||
export function commandNames() {
|
||||
return Object.keys(commandInfo).sort()
|
||||
}
|
||||
|
||||
export class SimulationError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly method?: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = "SimulationError"
|
||||
}
|
||||
}
|
||||
|
||||
export class CommandBatchError extends Error {
|
||||
constructor(
|
||||
readonly results: ReadonlyArray<{
|
||||
readonly command: string
|
||||
readonly result: unknown
|
||||
}>,
|
||||
readonly reason: unknown,
|
||||
) {
|
||||
super(reason instanceof Error ? reason.message : String(reason))
|
||||
this.name = "CommandBatchError"
|
||||
}
|
||||
}
|
||||
|
||||
const callTimeout = 30_000
|
||||
|
||||
export async function executeCommands(endpoint: string, commands: ReadonlyArray<DriveCommand>) {
|
||||
const exit = await Effect.runPromiseExit(Effect.scoped(executeBatch(endpoint, commands)))
|
||||
if (Exit.isSuccess(exit)) return exit.value
|
||||
const reason = Cause.squash(exit.cause)
|
||||
throw reason instanceof CommandBatchError ? reason : new CommandBatchError([], reason)
|
||||
}
|
||||
|
||||
const executeBatch = Effect.fn("DriveCli.executeBatch")(function* (
|
||||
endpoint: string,
|
||||
commands: ReadonlyArray<DriveCommand>,
|
||||
) {
|
||||
const connection = yield* SimulationConnector.ui(endpoint, {
|
||||
connectTimeout: callTimeout,
|
||||
}).pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new SimulationError(cause instanceof Error ? cause.message : `cannot connect to ${endpoint}`),
|
||||
),
|
||||
)
|
||||
const results: Array<{ readonly command: string; readonly result: unknown }> = []
|
||||
for (const command of commands) {
|
||||
const result = yield* execute(connection, command).pipe(
|
||||
Effect.mapError((error) => new CommandBatchError(results, error)),
|
||||
)
|
||||
results.push({ command: command.operation, result })
|
||||
}
|
||||
return { results }
|
||||
})
|
||||
|
||||
const execute = (
|
||||
connection: SimulationConnector.UiConnection,
|
||||
command: DriveCommand,
|
||||
): Effect.Effect<unknown, SimulationError> =>
|
||||
Effect.suspend(() => {
|
||||
recordLog("INFO", `ui command ${command.operation} params=${command.value ?? "undefined"}`)
|
||||
return dispatch(connection, decodeCommand(command))
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: callTimeout,
|
||||
orElse: () => Effect.fail(new SimulationError(`timed out after ${callTimeout}ms`, command.operation)),
|
||||
}),
|
||||
Effect.mapError((cause) =>
|
||||
cause instanceof SimulationError
|
||||
? cause
|
||||
: new SimulationError(cause instanceof Error ? cause.message : String(cause), command.operation),
|
||||
),
|
||||
Effect.tap(() => Effect.sync(() => recordLog("INFO", `ui command ${command.operation} completed`))),
|
||||
Effect.tapError((error) =>
|
||||
Effect.sync(() => recordLog("ERROR", `ui command ${command.operation} failed: ${error.message}`)),
|
||||
),
|
||||
)
|
||||
|
||||
function decodeCommand(command: DriveCommand): Frontend.Request {
|
||||
if (command.value === undefined && commandInfo[command.operation].value === true)
|
||||
throw new Error(`${command.operation} requires a value`)
|
||||
return Frontend.decodeRequest(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
method: command.operation,
|
||||
...(command.value === undefined ? {} : { params: JSON.parse(command.value) }),
|
||||
},
|
||||
{ onExcessProperty: "error" },
|
||||
)
|
||||
}
|
||||
|
||||
function dispatch(
|
||||
connection: SimulationConnector.UiConnection,
|
||||
request: Frontend.Request,
|
||||
): Effect.Effect<unknown, unknown> {
|
||||
if (
|
||||
request.method === "ui.snapshot" &&
|
||||
!SimulationConnector.supportsCapability(connection.compatibility, "ui.snapshot")
|
||||
)
|
||||
return Effect.fail(new SimulationError("ui.snapshot is not available on this OpenCode endpoint", request.method))
|
||||
if (
|
||||
request.method === "ui.click" &&
|
||||
request.params.semantic !== undefined &&
|
||||
!SimulationConnector.supportsCapability(connection.compatibility, "ui.click.semantic")
|
||||
)
|
||||
return Effect.fail(
|
||||
new SimulationError("semantic ui.click is not available on this OpenCode endpoint", request.method),
|
||||
)
|
||||
switch (request.method) {
|
||||
case "ui.type":
|
||||
return connection.rpc["ui.type"](request.params)
|
||||
case "ui.press":
|
||||
return connection.rpc["ui.press"](request.params)
|
||||
case "ui.enter":
|
||||
return connection.rpc["ui.enter"]()
|
||||
case "ui.arrow":
|
||||
return connection.rpc["ui.arrow"](request.params)
|
||||
case "ui.focus":
|
||||
return connection.rpc["ui.focus"](request.params)
|
||||
case "ui.click":
|
||||
return connection.rpc["ui.click"](request.params)
|
||||
case "ui.resize":
|
||||
return connection.rpc["ui.resize"](request.params)
|
||||
case "ui.screenshot":
|
||||
return connection.rpc["ui.screenshot"](request.params)
|
||||
case "ui.capture":
|
||||
return connection.rpc["ui.capture"]()
|
||||
case "ui.state":
|
||||
return connection.rpc["ui.state"]()
|
||||
case "ui.snapshot":
|
||||
return connection.rpc["ui.snapshot"]()
|
||||
case "ui.matches":
|
||||
return connection.rpc["ui.matches"](request.params)
|
||||
case "ui.recording.finish":
|
||||
return connection.rpc["ui.recording.finish"]()
|
||||
}
|
||||
throw new Error(`unsupported UI method ${request.method}`)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { resolveInstance } from "../instance/registry.js"
|
||||
|
||||
export async function dir(name?: string) {
|
||||
const manifest = await resolveInstance(name)
|
||||
console.log(manifest.artifacts)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env bun
|
||||
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
|
||||
import * as NodeServices from "@effect/platform-node/NodeServices"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Argument, Command, Flag } from "effect/unstable/cli"
|
||||
import packageJson from "../../package.json" with { type: "json" }
|
||||
import { extractCommands } from "./parse.js"
|
||||
import { check } from "./check.js"
|
||||
import { dir } from "./dir.js"
|
||||
import { init } from "./init.js"
|
||||
import { list } from "./list.js"
|
||||
import { prune } from "./prune.js"
|
||||
import { restart } from "./restart.js"
|
||||
import { runProgram } from "./run.js"
|
||||
import { send } from "./send.js"
|
||||
import { initScript } from "./script-init.js"
|
||||
import { start } from "./start.js"
|
||||
import { stop } from "./stop.js"
|
||||
import { logError } from "../log.js"
|
||||
import type { DriveCommand, SendOptions, StartOptions } from "./types.js"
|
||||
|
||||
const extracted = extract()
|
||||
const initName = Flag.string("name").pipe(Flag.withDescription("Instance name"))
|
||||
const startName = Flag.string("name").pipe(
|
||||
Flag.optional,
|
||||
Flag.withDescription("Instance name (optional with --visible)"),
|
||||
)
|
||||
const name = Flag.string("name").pipe(
|
||||
Flag.optional,
|
||||
Flag.withDescription("Instance name (defaults to the visible instance)"),
|
||||
)
|
||||
const pruneName = Flag.string("name").pipe(Flag.optional, Flag.withDescription("Instance name"))
|
||||
|
||||
const initCommand = Command.make("init", { name: initName }, (config) => execute(() => init(config.name))).pipe(
|
||||
Command.withDescription("Initialize an instance without launching OpenCode"),
|
||||
Command.withExamples([
|
||||
{
|
||||
command: "opencode-drive init --name demo",
|
||||
description: "Create an instance and print its artifact directory",
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
const checkCommand = Command.make("check", { file: Argument.string("script") }, (config) =>
|
||||
execute(() => check(config.file)),
|
||||
).pipe(
|
||||
Command.withDescription("Type-check an OpenCode Drive script"),
|
||||
Command.withExamples([
|
||||
{
|
||||
command: "opencode-drive check ./drive.ts",
|
||||
description: "Type-check a script with the bundled script API",
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
const scriptInitCommand = Command.make("init", { file: Argument.string("file") }, (config) =>
|
||||
execute(() => initScript(config.file)),
|
||||
).pipe(
|
||||
Command.withDescription("Create an Effect-native OpenCode Drive script"),
|
||||
Command.withExamples([
|
||||
{
|
||||
command: "opencode-drive script init ./drive.ts",
|
||||
description: "Create a type-checkable script without overwriting existing files",
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
const scriptCommand = Command.make("script").pipe(
|
||||
Command.withDescription("Create and manage OpenCode Drive scripts"),
|
||||
Command.withSubcommands([scriptInitCommand]),
|
||||
)
|
||||
|
||||
const runCommand = Command.make("run", { module: Argument.string("module") }, (config) =>
|
||||
executeEffect(
|
||||
Effect.try({
|
||||
try: () => toRunModule(config.module, extracted.commands, extracted.app),
|
||||
catch: (error) => error,
|
||||
}).pipe(Effect.flatMap(runProgram), Effect.asVoid),
|
||||
),
|
||||
).pipe(
|
||||
Command.withDescription("Type-check and run a fully provided Effect program"),
|
||||
Command.withExamples([
|
||||
{
|
||||
command: "opencode-drive run ./drive.ts",
|
||||
description: "Run a default-exported Effect program",
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
const startCommand = Command.make(
|
||||
"start",
|
||||
{
|
||||
name: startName,
|
||||
daemon: Flag.boolean("daemon").pipe(Flag.withHidden, Flag.withDescription("Run as detached instance owner")),
|
||||
script: Flag.string("script").pipe(
|
||||
Flag.optional,
|
||||
Flag.withDescription("JavaScript or TypeScript automation module"),
|
||||
),
|
||||
visible: Flag.boolean("visible").pipe(Flag.withDescription("Show OpenCode in the terminal")),
|
||||
record: Flag.boolean("record").pipe(
|
||||
Flag.withDescription("Record the complete headless session and export it on stop"),
|
||||
),
|
||||
dev: Flag.string("dev").pipe(Flag.optional, Flag.withDescription("Path to an OpenCode development checkout")),
|
||||
},
|
||||
(config) =>
|
||||
executeEffect(
|
||||
Effect.try({
|
||||
try: () => toStartOptions(config, extracted.commands, extracted.app),
|
||||
catch: (error) => error,
|
||||
}).pipe(Effect.flatMap(start)),
|
||||
),
|
||||
).pipe(
|
||||
Command.withDescription("Launch a local simulated OpenCode instance"),
|
||||
Command.withExamples([
|
||||
{
|
||||
command: "opencode-drive start --name demo",
|
||||
description: "Launch headless OpenCode on the default ports",
|
||||
},
|
||||
{
|
||||
command: "opencode-drive start --visible",
|
||||
description: "Launch visible OpenCode on the default ports",
|
||||
},
|
||||
{
|
||||
command: "opencode-drive start --name demo --script ./drive.ts",
|
||||
description: "Launch headless OpenCode and run a script",
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
const sendCommand = Command.make("send", { name }, (config) =>
|
||||
execute(() => send(toSendOptions(Option.getOrUndefined(config.name), extracted.commands, extracted.app))),
|
||||
).pipe(
|
||||
Command.withDescription("Send UI commands to OpenCode on the default port"),
|
||||
Command.withExamples([
|
||||
{
|
||||
command: 'opencode-drive send --command.ui.type \'{"text":"hello"}\' --command.ui.state',
|
||||
description: "Execute an ordered UI command batch",
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
const restartCommand = Command.make("restart", { name }, (config) =>
|
||||
execute(() => restart(Option.getOrUndefined(config.name))),
|
||||
).pipe(Command.withDescription("Restart a named OpenCode instance and rerun its script"))
|
||||
|
||||
const stopCommand = Command.make("stop", { name }, (config) =>
|
||||
execute(() => stop(Option.getOrUndefined(config.name))),
|
||||
).pipe(Command.withDescription("Stop a named OpenCode instance"))
|
||||
|
||||
const dirCommand = Command.make("dir", { name }, (config) =>
|
||||
execute(() => dir(Option.getOrUndefined(config.name))),
|
||||
).pipe(Command.withDescription("Print the artifact directory for a named OpenCode instance"))
|
||||
|
||||
const listCommand = Command.make("list", {}, () => execute(list)).pipe(
|
||||
Command.withDescription("List active OpenCode instances"),
|
||||
)
|
||||
|
||||
const pruneCommand = Command.make(
|
||||
"prune",
|
||||
{
|
||||
name: pruneName,
|
||||
force: Flag.boolean("force").pipe(
|
||||
Flag.withDescription("Delete all matching artifact directories, including active ones"),
|
||||
),
|
||||
},
|
||||
(config) => execute(() => prune({ name: Option.getOrUndefined(config.name), force: config.force })),
|
||||
).pipe(Command.withDescription("Delete artifact directories for inactive OpenCode instances"))
|
||||
|
||||
const root = Command.make("opencode-drive").pipe(
|
||||
Command.withDescription("Drive real and simulated OpenCode instances"),
|
||||
Command.withSubcommands([
|
||||
initCommand,
|
||||
scriptCommand,
|
||||
checkCommand,
|
||||
runCommand,
|
||||
startCommand,
|
||||
sendCommand,
|
||||
listCommand,
|
||||
pruneCommand,
|
||||
dirCommand,
|
||||
restartCommand,
|
||||
stopCommand,
|
||||
]),
|
||||
)
|
||||
|
||||
Command.runWith(root, { version: packageJson.version })(extracted.args).pipe(
|
||||
Effect.provide(NodeServices.layer),
|
||||
NodeRuntime.runMain,
|
||||
)
|
||||
|
||||
function toStartOptions(
|
||||
config: {
|
||||
readonly script: Option.Option<string>
|
||||
readonly name: Option.Option<string>
|
||||
readonly daemon: boolean
|
||||
readonly visible: boolean
|
||||
readonly record: boolean
|
||||
readonly dev: Option.Option<string>
|
||||
},
|
||||
commands: ReadonlyArray<DriveCommand>,
|
||||
app: ReadonlyArray<string>,
|
||||
): StartOptions {
|
||||
if (commands.length > 0) throw new Error("start does not accept command flags; use send or --script")
|
||||
const name = Option.getOrUndefined(config.name)
|
||||
if (name === undefined && !config.visible) throw new Error("start requires --name unless --visible is passed")
|
||||
const options = {
|
||||
kind: "start" as const,
|
||||
name: name ?? `visible-${process.pid}`,
|
||||
daemon: config.daemon,
|
||||
script: Option.getOrUndefined(config.script),
|
||||
visible: config.visible,
|
||||
record: config.record,
|
||||
dev: Option.getOrUndefined(config.dev),
|
||||
command: app,
|
||||
}
|
||||
if (options.dev !== undefined && app.length > 0) throw new Error("--dev cannot be combined with a command after --")
|
||||
return options
|
||||
}
|
||||
|
||||
function toRunModule(module: string, commands: ReadonlyArray<DriveCommand>, app: ReadonlyArray<string>) {
|
||||
if (commands.length > 0) throw new Error("run does not accept command flags")
|
||||
if (app.length > 0) throw new Error("run does not accept arguments after --")
|
||||
return module
|
||||
}
|
||||
|
||||
function toSendOptions(
|
||||
name: string | undefined,
|
||||
commands: ReadonlyArray<DriveCommand>,
|
||||
app: ReadonlyArray<string>,
|
||||
): SendOptions {
|
||||
if (app.length > 0) throw new Error("send does not accept a command after --")
|
||||
return { kind: "send", name, commands }
|
||||
}
|
||||
|
||||
function execute(task: () => Promise<void>) {
|
||||
return executeEffect(Effect.tryPromise({ try: task, catch: (error) => error }))
|
||||
}
|
||||
|
||||
function executeEffect<R>(task: Effect.Effect<void, unknown, R>) {
|
||||
return task.pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
logError(error instanceof Error ? error.message : String(error))
|
||||
process.exitCode = 1
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function extract() {
|
||||
try {
|
||||
return extractCommands(process.argv.slice(2))
|
||||
} catch (error) {
|
||||
logError(error instanceof Error ? error.message : String(error))
|
||||
return process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { initializeInstance } from "../instance/instance.js"
|
||||
import { initializeManifest } from "../instance/registry.js"
|
||||
import { configureLogFile, logSuccess } from "../log.js"
|
||||
|
||||
export async function init(name: string) {
|
||||
const manifest = await initializeManifest(name, process.cwd(), () => initializeInstance(name))
|
||||
configureLogFile(manifest.artifacts)
|
||||
logSuccess(`initialized ${name}`)
|
||||
console.log(manifest.artifacts)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { listManifests, manifestPath } from "../instance/registry.js"
|
||||
|
||||
export async function list() {
|
||||
const instances = await listManifests()
|
||||
console.log(instances.map((instance) => `${instance.name}: ${manifestPath(instance.name)}`).join("\n"))
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as Exit from "effect/Exit"
|
||||
import * as Scope from "effect/Scope"
|
||||
import * as Stream from "effect/Stream"
|
||||
import type { Backend } from "../client/protocol.js"
|
||||
import { logError } from "../log.js"
|
||||
import * as SimulationConnector from "../simulation/connector.js"
|
||||
import { generateResponse } from "./response-generator.js"
|
||||
import type { createResponseSettings } from "./response-generator.js"
|
||||
|
||||
const connectTimeout = 30_000
|
||||
|
||||
export async function connectMockBackend(endpoint: string, responses: ReturnType<typeof createResponseSettings>) {
|
||||
const scope = await Effect.runPromise(Scope.make())
|
||||
const connect = Effect.gen(function* () {
|
||||
const backend = yield* SimulationConnector.backend(endpoint, {
|
||||
connectTimeout,
|
||||
requestTimeout: connectTimeout,
|
||||
attach: false,
|
||||
})
|
||||
yield* backend.requests.pipe(
|
||||
Stream.runForEach((request) =>
|
||||
respond(backend, request, responses).pipe(
|
||||
Effect.matchCauseEffect({
|
||||
onFailure: (cause) => Effect.sync(() => logError(String(cause))),
|
||||
onSuccess: () => Effect.void,
|
||||
}),
|
||||
Effect.forkIn(scope),
|
||||
),
|
||||
),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
yield* backend.attach()
|
||||
})
|
||||
try {
|
||||
await Effect.runPromise(connect.pipe(Scope.provide(scope)))
|
||||
} catch (cause) {
|
||||
await Effect.runPromise(Scope.close(scope, Exit.void))
|
||||
throw cause
|
||||
}
|
||||
return {
|
||||
close() {
|
||||
Effect.runFork(Scope.close(scope, Exit.void))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const respond = Effect.fn("DriveCli.mockRespond")(function* (
|
||||
backend: SimulationConnector.BackendConnection,
|
||||
request: Backend.ProviderInvocation,
|
||||
responses: ReturnType<typeof createResponseSettings>,
|
||||
) {
|
||||
const response = generateResponse(responses.current(), request)
|
||||
for (const item of response.items) {
|
||||
if (item.type !== "textDelta" && item.type !== "reasoningDelta") {
|
||||
yield* backend.rpc["llm.chunk"]({ id: request.id, items: [item] })
|
||||
continue
|
||||
}
|
||||
for (const text of splitText(item.text)) {
|
||||
yield* backend.rpc["llm.chunk"]({ id: request.id, items: [{ ...item, text }] })
|
||||
yield* Effect.sleep(45 + Math.floor(Math.random() * 35))
|
||||
}
|
||||
}
|
||||
yield* backend.rpc["llm.finish"]({ id: request.id, reason: response.finish })
|
||||
})
|
||||
|
||||
export function splitText(text: string) {
|
||||
const words = text.match(/\S+\s*/g) ?? [text]
|
||||
return Array.from({ length: Math.ceil(words.length / 3) }, (_, index) =>
|
||||
words.slice(index * 3, index * 3 + 3).join(""),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { commandAcceptsValue, isCommandName } from "./commands.js"
|
||||
import type { DriveCommand } from "./types.js"
|
||||
|
||||
export function extractCommands(args: ReadonlyArray<string>) {
|
||||
const commands: DriveCommand[] = []
|
||||
const remaining: string[] = []
|
||||
const separator = args.indexOf("--")
|
||||
const cli = separator === -1 ? args : args.slice(0, separator)
|
||||
const app = separator === -1 ? [] : args.slice(separator + 1)
|
||||
|
||||
for (let index = 0; index < cli.length; index++) {
|
||||
const flag = cli[index]!
|
||||
if (!flag.startsWith("--command.")) {
|
||||
remaining.push(flag)
|
||||
continue
|
||||
}
|
||||
const operation = flag.slice("--command.".length)
|
||||
if (!isCommandName(operation)) throw new Error(`unknown drive command "${operation}"`)
|
||||
const valueMode = commandAcceptsValue(operation)
|
||||
const next = cli[index + 1]
|
||||
const takesValue = valueMode === true || (valueMode === "optional" && next !== undefined && !next.startsWith("--"))
|
||||
const value = takesValue ? cli[++index] : undefined
|
||||
if (valueMode === true && (value === undefined || value.startsWith("--")))
|
||||
throw new Error(`${flag} requires a value`)
|
||||
commands.push({ operation, ...(value === undefined ? {} : { value }) })
|
||||
}
|
||||
return { args: remaining, app, commands }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readdir, rm } from "node:fs/promises"
|
||||
import { join, resolve } from "node:path"
|
||||
import { artifactDirectory } from "../instance/instance.js"
|
||||
import { listManifests, validateName } from "../instance/registry.js"
|
||||
|
||||
export async function prune(options: { readonly name?: string; readonly force?: boolean } = {}) {
|
||||
if (options.name !== undefined) validateName(options.name)
|
||||
const directory = artifactDirectory()
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => {
|
||||
if (isNodeError(error) && error.code === "ENOENT") return []
|
||||
throw error
|
||||
})
|
||||
const manifests = await listManifests()
|
||||
const active = new Set(manifests.map((manifest) => resolve(manifest.artifacts)))
|
||||
const manifestNames = new Map(manifests.map((manifest) => [resolve(manifest.artifacts), manifest.name]))
|
||||
const artifacts = entries
|
||||
.filter((entry) => entry.isDirectory() && entry.name.startsWith("run-"))
|
||||
.map((entry) => join(directory, entry.name))
|
||||
const matched = await Promise.all(
|
||||
artifacts.map(async (artifacts) => {
|
||||
if (options.name === undefined) return artifacts
|
||||
const storedName = await Bun.file(join(artifacts, "drive", "name"))
|
||||
.text()
|
||||
.then((value) => value.trim())
|
||||
.catch(() => undefined)
|
||||
return storedName === options.name || manifestNames.get(resolve(artifacts)) === options.name
|
||||
? artifacts
|
||||
: undefined
|
||||
}),
|
||||
)
|
||||
const pruned = matched
|
||||
.filter((artifacts): artifacts is string => artifacts !== undefined)
|
||||
.filter((artifacts) => options.force || !active.has(resolve(artifacts)))
|
||||
|
||||
await Promise.all(pruned.map((artifacts) => rm(artifacts, { recursive: true, force: true })))
|
||||
console.log(pruned.length)
|
||||
}
|
||||
|
||||
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && "code" in error
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import { Backend } from "../client/protocol.js"
|
||||
import type { JsonValue } from "../project.js"
|
||||
|
||||
export const responseTypes = ["text", "reasoning", "tool", "diff"] as const
|
||||
export type ResponseType = (typeof responseTypes)[number]
|
||||
|
||||
export interface ResponseConfiguration {
|
||||
readonly types: ReadonlyArray<ResponseType>
|
||||
readonly tools: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export interface ResponseUpdate {
|
||||
readonly types?: ReadonlyArray<string>
|
||||
readonly tools?: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
const textResponses = [
|
||||
"I took a careful look at the problem and followed it through the parts of the system that actually shape the behavior. The result is simpler than it first appeared: one clear boundary, one owner, and fewer opportunities for state to drift. There is a quiet satisfaction in watching the pieces settle into place.",
|
||||
"The important path is working now, and the surrounding behavior remains intact. I kept the change focused, made the failure case visible, and checked the point where control passes from one process to another. It is a small adjustment, but it lets a little more daylight into the design.",
|
||||
"I traced the request from its first input to its final effect and found the useful seam in between. The implementation now says what it means without asking the reader to remember hidden state. Nothing dramatic happened, which is often the nicest possible ending for this kind of work.",
|
||||
"The pieces fit together cleanly after the change. Inputs are handled where they arrive, ownership stays explicit, and cleanup follows the same path every time. The code feels calmer now, like a room after someone has opened a window and put the books back in order.",
|
||||
"I checked the current behavior, made the narrow change, and followed it through the edge cases that mattered. The result is direct enough to explain and ordinary enough to trust. Somewhere in the background, the event loop continues its patient little orbit.",
|
||||
]
|
||||
|
||||
const reasoningResponses = [
|
||||
"I should inspect the available context before choosing the smallest reliable path through this.",
|
||||
"I need to preserve the working behavior while checking the boundary where ownership changes hands.",
|
||||
"The request contains enough information to proceed, though the assumptions deserve one careful pass first.",
|
||||
"I will separate the observed behavior from the implementation detail, then test the seam between them.",
|
||||
"The safest approach is to validate the current state, make one deliberate change, and follow its effects.",
|
||||
]
|
||||
|
||||
export function createResponseSettings() {
|
||||
let configuration: ResponseConfiguration = {
|
||||
types: ["text", "reasoning", "diff", "tool"],
|
||||
tools: ["write", "apply_patch"],
|
||||
}
|
||||
return {
|
||||
current: () => configuration,
|
||||
update(input: ResponseUpdate) {
|
||||
const updated = {
|
||||
types: input.types ? parseTypes(input.types) : configuration.types,
|
||||
tools: input.tools ? parseTools(input.tools) : configuration.tools,
|
||||
}
|
||||
if (
|
||||
updated.types.includes("diff") &&
|
||||
!updated.tools.includes("*") &&
|
||||
!updated.tools.some((tool) => diffTools.has(tool))
|
||||
)
|
||||
throw new Error("diff responses require apply_patch, write, or edit in --tools")
|
||||
configuration = updated
|
||||
return configuration
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function generateResponse(
|
||||
configuration: ResponseConfiguration,
|
||||
request: Backend.ProviderInvocation,
|
||||
): {
|
||||
readonly items: ReadonlyArray<Backend.Item>
|
||||
readonly finish: Backend.FinishReason
|
||||
} {
|
||||
if (hasToolResult(request.body)) return textResponse()
|
||||
const tools = offeredTools(request.body).filter(
|
||||
(tool) => configuration.tools.includes("*") || configuration.tools.includes(tool.name),
|
||||
)
|
||||
const available = configuration.types.filter(
|
||||
(type) =>
|
||||
(type !== "tool" || tools.length > 0) && (type !== "diff" || tools.some((tool) => diffTools.has(tool.name))),
|
||||
)
|
||||
const type = pick(available)
|
||||
if (type === "reasoning")
|
||||
return {
|
||||
items: [
|
||||
{ type: "reasoningDelta", text: pick(reasoningResponses) },
|
||||
{ type: "textDelta", text: pick(textResponses) },
|
||||
],
|
||||
finish: "stop",
|
||||
}
|
||||
if (type === "tool") return toolResponse(tools.slice(0, 3), false)
|
||||
if (type === "diff")
|
||||
return toolResponse(
|
||||
[
|
||||
["apply_patch", "write", "edit"]
|
||||
.map((name) => tools.find((tool) => tool.name === name))
|
||||
.find((tool) => tool !== undefined)!,
|
||||
],
|
||||
true,
|
||||
)
|
||||
if (type === "text") return textResponse()
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
type: "textDelta",
|
||||
text: "No configured response type matched the tools offered by this request.",
|
||||
},
|
||||
],
|
||||
finish: "stop",
|
||||
}
|
||||
}
|
||||
|
||||
function textResponse() {
|
||||
return {
|
||||
items: [{ type: "textDelta" as const, text: pick(textResponses) }],
|
||||
finish: "stop" as const,
|
||||
}
|
||||
}
|
||||
|
||||
interface ToolDefinition {
|
||||
readonly name: string
|
||||
readonly parameters: unknown
|
||||
}
|
||||
|
||||
const diffTools = new Set(["apply_patch", "edit", "write"])
|
||||
let gardenExpanded = false
|
||||
|
||||
function toolResponse(tools: ReadonlyArray<ToolDefinition>, diff: boolean) {
|
||||
return {
|
||||
items: tools.map((tool, index) => ({
|
||||
type: "toolCall" as const,
|
||||
index,
|
||||
id: `call_${crypto.randomUUID().replaceAll("-", "").slice(0, 16)}`,
|
||||
name: tool.name,
|
||||
input: toolInput(tool, diff),
|
||||
})),
|
||||
finish: "tool-calls" as const,
|
||||
}
|
||||
}
|
||||
|
||||
function offeredTools(body: unknown) {
|
||||
if (!isRecord(body) || !Array.isArray(body.tools)) return []
|
||||
return body.tools.flatMap((value): ToolDefinition[] => {
|
||||
if (!isRecord(value)) return []
|
||||
const definition = isRecord(value.function) ? value.function : value
|
||||
if (typeof definition.name !== "string") return []
|
||||
return [
|
||||
{
|
||||
name: definition.name,
|
||||
parameters: definition.parameters ?? definition.inputSchema,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function toolInput(tool: ToolDefinition, diff: boolean) {
|
||||
const generated = schemaValue(tool.parameters, "input")
|
||||
const input = isJsonRecord(generated) ? generated : {}
|
||||
const known = knownInput(tool.name, diff)
|
||||
if (!isRecord(tool.parameters) || !isRecord(tool.parameters.properties)) return { ...input, ...known }
|
||||
const properties = tool.parameters.properties
|
||||
return {
|
||||
...input,
|
||||
...Object.fromEntries(
|
||||
Object.entries(known).filter(([key, value]) => key in properties && acceptsValue(properties[key], value)),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function knownInput(name: string, diff: boolean): Record<string, JsonValue> {
|
||||
const suffix = crypto.randomUUID().slice(0, 8)
|
||||
if (name === "apply_patch")
|
||||
return {
|
||||
patchText: gardenPatch(),
|
||||
}
|
||||
if (name === "write")
|
||||
return {
|
||||
path: "src/garden.js",
|
||||
filePath: "src/garden.js",
|
||||
content:
|
||||
'export function greet(name, punctuation = "!") {\n const visitor = name.trim() || "traveler"\n return `Hello, ${visitor}${punctuation}`\n}\n',
|
||||
}
|
||||
if (name === "edit")
|
||||
return {
|
||||
path: ".opencode/opencode.jsonc",
|
||||
filePath: ".opencode/opencode.jsonc",
|
||||
oldString: '"name": "Simulation"',
|
||||
newString: `"name": "Simulation ${suffix}"`,
|
||||
}
|
||||
if (name === "read")
|
||||
return { path: ".opencode/opencode.jsonc", filePath: ".opencode/opencode.jsonc", offset: 1, limit: 120 }
|
||||
if (name === "glob") return { pattern: "**/*", path: ".", limit: 20 }
|
||||
if (name === "grep") return { pattern: "simulation", path: ".opencode", include: "*.jsonc", limit: 20 }
|
||||
if (name === "shell" || name === "bash")
|
||||
return { command: diff ? "git diff --stat" : "pwd", description: "Inspect the workspace" }
|
||||
return {}
|
||||
}
|
||||
|
||||
function gardenPatch() {
|
||||
const patch = gardenExpanded
|
||||
? '*** Begin Patch\n*** Update File: src/garden.js\n@@\n-export function greet(name, punctuation = "!") {\n- const visitor = name.trim() || "traveler"\n- return `Hello, ${visitor}${punctuation}`\n+export function greet(name) {\n+ return `Hello, ${name}.`\n }\n*** End Patch'
|
||||
: '*** Begin Patch\n*** Update File: src/garden.js\n@@\n-export function greet(name) {\n- return `Hello, ${name}.`\n+export function greet(name, punctuation = "!") {\n+ const visitor = name.trim() || "traveler"\n+ return `Hello, ${visitor}${punctuation}`\n }\n*** End Patch'
|
||||
gardenExpanded = !gardenExpanded
|
||||
return patch
|
||||
}
|
||||
|
||||
function schemaValue(schema: unknown, key: string, root: unknown = schema): JsonValue {
|
||||
if (!isRecord(schema)) return {}
|
||||
if (typeof schema.$ref === "string" && schema.$ref.startsWith("#/$defs/")) {
|
||||
const name = schema.$ref.slice("#/$defs/".length)
|
||||
if (isRecord(root) && isRecord(root.$defs)) return schemaValue(root.$defs[name], key, root)
|
||||
}
|
||||
if ("const" in schema && isJson(schema.const)) return schema.const
|
||||
if (Array.isArray(schema.enum) && schema.enum.length > 0 && isJson(schema.enum[0])) return schema.enum[0]
|
||||
const alternative = Array.isArray(schema.anyOf)
|
||||
? schema.anyOf[0]
|
||||
: Array.isArray(schema.oneOf)
|
||||
? schema.oneOf[0]
|
||||
: undefined
|
||||
if (alternative !== undefined) return schemaValue(alternative, key, root)
|
||||
if (Array.isArray(schema.allOf) && schema.allOf.length > 0 && schema.type === undefined)
|
||||
return schemaValue(schema.allOf[0], key, root)
|
||||
if (schema.type === "object" || isRecord(schema.properties)) {
|
||||
const properties = isRecord(schema.properties) ? schema.properties : {}
|
||||
const required = Array.isArray(schema.required)
|
||||
? schema.required.filter((value): value is string => typeof value === "string")
|
||||
: []
|
||||
return Object.fromEntries(required.map((name) => [name, schemaValue(properties[name], name, root)]))
|
||||
}
|
||||
if (schema.type === "array") {
|
||||
const count = typeof schema.minItems === "number" ? Math.max(1, schema.minItems) : 1
|
||||
return Array.from({ length: count }, () => schemaValue(schema.items, key, root))
|
||||
}
|
||||
if (schema.type === "boolean") return false
|
||||
if (schema.type === "integer" || schema.type === "number") {
|
||||
if (typeof schema.minimum === "number") return schema.minimum
|
||||
if (typeof schema.exclusiveMinimum === "number") return schema.exclusiveMinimum + 1
|
||||
return 1
|
||||
}
|
||||
if (schema.type === "null") return null
|
||||
if (key.toLowerCase().includes("path")) return "."
|
||||
if (key.toLowerCase().includes("pattern")) return "TODO"
|
||||
if (key.toLowerCase().includes("command")) return "pwd"
|
||||
if (key.toLowerCase().includes("content")) return "Generated by the simulated model."
|
||||
const value = "sample"
|
||||
return typeof schema.minLength === "number" ? value.padEnd(schema.minLength, "x") : value
|
||||
}
|
||||
|
||||
function hasToolResult(body: unknown) {
|
||||
if (!isRecord(body) || !Array.isArray(body.messages)) return false
|
||||
const message = body.messages.at(-1)
|
||||
if (!isRecord(message)) return false
|
||||
if (message.role === "tool") return true
|
||||
if (!Array.isArray(message.content)) return false
|
||||
return message.content.some((part) => isRecord(part) && (part.type === "tool-result" || part.type === "tool"))
|
||||
}
|
||||
|
||||
function acceptsValue(schema: unknown, value: JsonValue) {
|
||||
if (!isRecord(schema)) return true
|
||||
if (Array.isArray(schema.enum) && !schema.enum.some((item) => item === value)) return false
|
||||
if (schema.type === "string") return typeof value === "string"
|
||||
if (schema.type === "number" || schema.type === "integer") return typeof value === "number"
|
||||
if (schema.type === "boolean") return typeof value === "boolean"
|
||||
if (schema.type === "array") return Array.isArray(value)
|
||||
if (schema.type === "object") return isJsonRecord(value)
|
||||
return true
|
||||
}
|
||||
|
||||
function parseTypes(values: ReadonlyArray<string>) {
|
||||
const types = unique(values)
|
||||
if (types.length === 0) throw new Error("responses requires at least one type")
|
||||
const valid = types.filter(isResponseType)
|
||||
const unknown = types.filter((value) => !isResponseType(value))
|
||||
if (unknown.length > 0) throw new Error(`unknown response types: ${unknown.join(", ")}`)
|
||||
return valid
|
||||
}
|
||||
|
||||
function parseTools(values: ReadonlyArray<string>) {
|
||||
const tools = unique(values)
|
||||
if (tools.length === 0) throw new Error("responses requires at least one tool or *")
|
||||
if (tools.some((tool) => tool !== "*" && !/^[a-zA-Z0-9_.:-]+$/.test(tool)))
|
||||
throw new Error("tool names may contain only letters, numbers, dots, underscores, colons, or dashes")
|
||||
return tools
|
||||
}
|
||||
|
||||
function unique(values: ReadonlyArray<string>) {
|
||||
return [...new Set(values.map((value) => value.trim()).filter(Boolean))]
|
||||
}
|
||||
|
||||
function pick<T>(values: ReadonlyArray<T>) {
|
||||
return values[Math.floor(Math.random() * values.length)]!
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isJsonRecord(value: JsonValue): value is { readonly [key: string]: JsonValue } {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isJson(value: unknown): value is JsonValue {
|
||||
if (value === null) return true
|
||||
if (["boolean", "number", "string"].includes(typeof value)) return true
|
||||
if (Array.isArray(value)) return value.every(isJson)
|
||||
if (!isRecord(value)) return false
|
||||
return Object.values(value).every(isJson)
|
||||
}
|
||||
|
||||
function isResponseType(value: string): value is ResponseType {
|
||||
return responseTypes.some((type) => type === value)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { request } from "../instance/control.js"
|
||||
import { resolveInstance } from "../instance/registry.js"
|
||||
import { configureLogFile, logSuccess } from "../log.js"
|
||||
|
||||
export async function restart(name?: string) {
|
||||
const manifest = await resolveInstance(name)
|
||||
configureLogFile(manifest.artifacts)
|
||||
logSuccess(`restarting ${manifest.name}`)
|
||||
const recording = await request(manifest.control, "restart")
|
||||
console.log(recording ?? "success")
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join, resolve } from "node:path"
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as Process from "../instance/process.js"
|
||||
import { prepareProgram } from "../script/tooling.js"
|
||||
|
||||
export const runProgram = Effect.fn("Cli.runProgram")((file: string) =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.tryPromise({
|
||||
try: () => mkdtemp(join(tmpdir(), "opencode-drive-run-")),
|
||||
catch: (cause) => cause,
|
||||
}),
|
||||
(artifacts) =>
|
||||
Effect.gen(function* () {
|
||||
const runner = yield* Effect.tryPromise({
|
||||
try: () => prepareProgram(artifacts, resolve(file)),
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
const result = yield* Process.run([process.execPath, runner], {
|
||||
extendEnv: true,
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
if (result.status !== 0) return yield* Effect.fail(new Error(`program exited with status ${result.status}`))
|
||||
return undefined
|
||||
}),
|
||||
(artifacts) => Effect.promise(() => rm(artifacts, { recursive: true, force: true })),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
import { mkdir, open, rm } from "node:fs/promises"
|
||||
import { dirname, resolve } from "node:path"
|
||||
import { logSuccess } from "../log.js"
|
||||
|
||||
const template = `import { defineScript, Effect, Llm } from "opencode-drive"
|
||||
|
||||
export default defineScript({
|
||||
project: {
|
||||
files: {
|
||||
"src/example.ts": "export const value = 1\\n",
|
||||
},
|
||||
},
|
||||
run: ({ ui, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* llm.queue(Llm.text("The value is 1."))
|
||||
yield* ui.submit("Read src/example.ts")
|
||||
yield* ui.waitFor("The value is 1.")
|
||||
yield* ui.screenshot("result")
|
||||
}),
|
||||
})
|
||||
`
|
||||
|
||||
export async function initScript(path: string) {
|
||||
const file = resolve(path)
|
||||
await mkdir(dirname(file), { recursive: true })
|
||||
const handle = await open(file, "wx").catch((error: unknown) => {
|
||||
if (isAlreadyExists(error)) throw new Error(`script already exists: ${file}`, { cause: error })
|
||||
throw error
|
||||
})
|
||||
try {
|
||||
await handle.writeFile(template)
|
||||
} catch (error) {
|
||||
await handle.close().catch(() => undefined)
|
||||
await rm(file, { force: true })
|
||||
throw error
|
||||
}
|
||||
await handle.close()
|
||||
logSuccess("created script")
|
||||
console.log(file)
|
||||
}
|
||||
|
||||
function isAlreadyExists(error: unknown): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && "code" in error && error.code === "EEXIST"
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { join, resolve } from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import * as Deferred from "effect/Deferred"
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as Schema from "effect/Schema"
|
||||
import * as OpenCodeDriver from "../driver/index.js"
|
||||
import type * as OpenCodeTui from "../driver/client.js"
|
||||
import * as OpenCodeUi from "../driver/ui.js"
|
||||
import * as PreparedDriver from "../driver/prepared.js"
|
||||
import type * as OpenCodeInstance from "../instance/runtime.js"
|
||||
import { createScriptFileSystem } from "../script/filesystem.js"
|
||||
import { hasGitMetadata } from "../script/project.js"
|
||||
import { Names as ToolNames } from "../tool/types.js"
|
||||
import type { AutomaticScriptDefinition, ScriptDefinition } from "../script/types.js"
|
||||
|
||||
export const loadScript = Effect.fn("DriveCli.loadScript")((file: string) =>
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
const module: unknown = await import(pathToFileURL(resolve(file)).href)
|
||||
return isRecord(module) ? { default: module.default } : {}
|
||||
},
|
||||
catch: (cause) => cause,
|
||||
}).pipe(
|
||||
Effect.flatMap((module) =>
|
||||
isScriptDefinition(module.default)
|
||||
? Effect.succeed(module.default)
|
||||
: Effect.fail(new Error("script must default-export defineScript(...)")),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
export const runScript = Effect.fn("DriveCli.runScript")(function* (
|
||||
script: ScriptDefinition,
|
||||
instance: OpenCodeInstance.Instance,
|
||||
onScreenshot?: (path: string) => void,
|
||||
onRecording?: (path: string) => void,
|
||||
onReady?: () => void,
|
||||
) {
|
||||
const prepared = yield* PreparedDriver.make(instance, {
|
||||
visible: instance.visible,
|
||||
launch: "launch" in script ? "manual" : "automatic",
|
||||
tuiName: "default",
|
||||
tui: script.tui,
|
||||
})
|
||||
const protectGit = yield* Effect.promise(() => hasGitMetadata(join(instance.artifacts, "files")))
|
||||
const operationFailure = yield* Deferred.make<never, unknown>()
|
||||
const runUi = <A, E>(effect: Effect.Effect<A, E>) =>
|
||||
effect.pipe(
|
||||
Effect.tapError((cause) =>
|
||||
cause instanceof OpenCodeDriver.UiTimeoutError
|
||||
? Deferred.fail(operationFailure, cause).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
),
|
||||
)
|
||||
const recordings = new Set<string>()
|
||||
const reportRecording = (path: string) => {
|
||||
if (recordings.has(path)) return
|
||||
recordings.add(path)
|
||||
onRecording?.(path)
|
||||
}
|
||||
const adaptUi = (ui: OpenCodeUi.Ui): OpenCodeUi.Ui => {
|
||||
const transformed = OpenCodeUi.transform(ui, runUi)
|
||||
return {
|
||||
...transformed,
|
||||
screenshot: (name) =>
|
||||
transformed.screenshot(name).pipe(Effect.tap((path) => Effect.sync(() => onScreenshot?.(path)))),
|
||||
}
|
||||
}
|
||||
const adaptTui = (tui: OpenCodeTui.Tui): OpenCodeTui.Tui => {
|
||||
const recording = tui.recording
|
||||
return {
|
||||
ui: adaptUi(tui.ui),
|
||||
close: tui.close,
|
||||
...(recording === undefined
|
||||
? {}
|
||||
: {
|
||||
recording: {
|
||||
path: recording.path,
|
||||
timeline: recording.timeline,
|
||||
finish: () =>
|
||||
runUi(recording.finish()).pipe(Effect.tap((path) => Effect.sync(() => reportRecording(path)))),
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
const tuiOptions = (options?: OpenCodeTui.TuiOptions) => ({
|
||||
...("launch" in script ? script.tui : undefined),
|
||||
...options,
|
||||
})
|
||||
function launchTui(options?: OpenCodeTui.TuiOptions): ReturnType<OpenCodeTui.Tuis["launch"]>
|
||||
function launchTui(name: string, options?: OpenCodeTui.TuiOptions): ReturnType<OpenCodeTui.Tuis["launch"]>
|
||||
function launchTui(nameOrOptions?: string | OpenCodeTui.TuiOptions, options?: OpenCodeTui.TuiOptions) {
|
||||
const launched =
|
||||
typeof nameOrOptions === "string"
|
||||
? prepared.tuis.launch(nameOrOptions, tuiOptions(options))
|
||||
: prepared.tuis.launch(tuiOptions(nameOrOptions))
|
||||
return launched.pipe(
|
||||
Effect.tap(() => Effect.sync(() => onReady?.())),
|
||||
Effect.map(adaptTui),
|
||||
)
|
||||
}
|
||||
const tuis: OpenCodeTui.Tuis = { launch: launchTui }
|
||||
const context = {
|
||||
fs: createScriptFileSystem(join(instance.artifacts, "files"), {
|
||||
git: protectGit,
|
||||
}),
|
||||
tuis,
|
||||
server: {
|
||||
launch: prepared.server.launch,
|
||||
kill: prepared.server.kill,
|
||||
},
|
||||
llm: prepared.llm,
|
||||
tools: prepared.tools,
|
||||
artifacts: instance.artifacts,
|
||||
}
|
||||
const primaryTui = prepared.primary
|
||||
const automatic = (definition: AutomaticScriptDefinition) => {
|
||||
if (primaryTui === undefined || prepared.driver === undefined)
|
||||
return Effect.fail(new Error("automatic script did not launch its primary TUI"))
|
||||
const tui = adaptTui(primaryTui)
|
||||
return definition.run({
|
||||
...context,
|
||||
opencode: prepared.driver.opencode,
|
||||
tui,
|
||||
ui: tui.ui,
|
||||
})
|
||||
}
|
||||
const execution = "launch" in script ? script.run({ ...context, tui: null, ui: null }) : automatic(script)
|
||||
if (!Effect.isEffect(execution)) return yield* Effect.fail(new Error("script run must return an Effect"))
|
||||
if (primaryTui !== undefined) onReady?.()
|
||||
yield* Effect.raceAllFirst([
|
||||
execution,
|
||||
Deferred.await(operationFailure),
|
||||
prepared.failure.pipe(Effect.catchIf(isZeroStatusTuiExit, () => Effect.void)),
|
||||
])
|
||||
const report = yield* prepared.settle()
|
||||
for (const path of report.recordings) reportRecording(path)
|
||||
return undefined
|
||||
})
|
||||
|
||||
function isZeroStatusTuiExit(cause: unknown) {
|
||||
return (
|
||||
cause instanceof OpenCodeDriver.OpenCodeDriverError &&
|
||||
cause.operation === "tui.exit" &&
|
||||
cause.message.endsWith("status 0")
|
||||
)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isScriptDefinition(value: unknown): value is ScriptDefinition {
|
||||
if (!isRecord(value)) return false
|
||||
return (
|
||||
value.kind === "opencode-drive/script" &&
|
||||
typeof value.run === "function" &&
|
||||
(value.project === undefined || isScriptProject(value.project)) &&
|
||||
(value.config === undefined || isJsonObject(value.config)) &&
|
||||
(value.tuiConfig === undefined || isJsonObject(value.tuiConfig)) &&
|
||||
(value.setup === undefined || typeof value.setup === "function") &&
|
||||
(value.tools === undefined || isToolConfiguration(value.tools)) &&
|
||||
(value.tui === undefined || isTuiOptions(value.tui)) &&
|
||||
(!("launch" in value) || value.launch === "manual")
|
||||
)
|
||||
}
|
||||
|
||||
function isToolConfiguration(value: unknown) {
|
||||
return typeof value === "function" || Schema.is(ToolNames)(value)
|
||||
}
|
||||
|
||||
function isTuiOptions(value: unknown) {
|
||||
if (!isRecord(value)) return false
|
||||
if (value.recording !== undefined && typeof value.recording !== "boolean") return false
|
||||
if (value.viewport === undefined) return true
|
||||
if (!isRecord(value.viewport)) return false
|
||||
return (
|
||||
typeof value.viewport.cols === "number" &&
|
||||
Number.isFinite(value.viewport.cols) &&
|
||||
typeof value.viewport.rows === "number" &&
|
||||
Number.isFinite(value.viewport.rows)
|
||||
)
|
||||
}
|
||||
|
||||
function isJsonObject(value: unknown) {
|
||||
if (!isRecord(value)) return false
|
||||
const prototype = Object.getPrototypeOf(value)
|
||||
return prototype === Object.prototype || prototype === null
|
||||
}
|
||||
|
||||
function isScriptProject(value: unknown) {
|
||||
if (!isRecord(value)) return false
|
||||
if (value.git !== undefined && typeof value.git !== "boolean") return false
|
||||
if (value.files === undefined) return true
|
||||
if (!isRecord(value.files)) return false
|
||||
const prototype = Object.getPrototypeOf(value.files)
|
||||
if (prototype !== Object.prototype && prototype !== null) return false
|
||||
return Object.values(value.files).every((contents) => typeof contents === "string" || contents instanceof Uint8Array)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { executeCommands } from "./commands.js"
|
||||
import type { SendOptions } from "./types.js"
|
||||
import { defaultPort } from "../client/index.js"
|
||||
import { resolveInstance, resolveVisibleInstance } from "../instance/registry.js"
|
||||
import { configureLogFile } from "../log.js"
|
||||
|
||||
export async function send(options: SendOptions) {
|
||||
if (options.commands.length === 0) throw new Error("send requires at least one --command.ui.* flag")
|
||||
const result = await executeCommands(await resolveSendEndpoint(options.name), options.commands)
|
||||
if (
|
||||
options.commands.length === 1 &&
|
||||
["ui.screenshot", "ui.matches", "ui.recording.finish"].includes(options.commands[0]?.operation ?? "")
|
||||
) {
|
||||
console.log(result.results[0]?.result)
|
||||
return
|
||||
}
|
||||
if (
|
||||
options.commands.length === 1 &&
|
||||
["ui.state", "ui.snapshot", "ui.capture"].includes(options.commands[0]?.operation ?? "")
|
||||
) {
|
||||
console.log(JSON.stringify(result.results[0]?.result, undefined, 2))
|
||||
return
|
||||
}
|
||||
console.log("success")
|
||||
}
|
||||
|
||||
export async function resolveSendEndpoint(name?: string) {
|
||||
if (name) {
|
||||
const manifest = await resolveInstance(name)
|
||||
configureLogFile(manifest.artifacts)
|
||||
return manifest.endpoints.ui
|
||||
}
|
||||
const manifest = await resolveVisibleInstance()
|
||||
if (manifest) {
|
||||
configureLogFile(manifest.artifacts)
|
||||
return manifest.endpoints.ui
|
||||
}
|
||||
return `ws://127.0.0.1:${defaultPort}`
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as Cause from "effect/Cause"
|
||||
import * as Exit from "effect/Exit"
|
||||
import { toStringUnknown } from "effect/Inspectable"
|
||||
import { initializeInstance } from "../instance/instance.js"
|
||||
import * as DriveProcess from "../instance/process.js"
|
||||
import * as OpenCodeInstance from "../instance/runtime.js"
|
||||
import { mkdir, rm } from "node:fs/promises"
|
||||
import { join } from "node:path"
|
||||
import * as SimulationConnector from "../simulation/connector.js"
|
||||
import { connectMockBackend } from "./mock-backend.js"
|
||||
import { createResponseSettings } from "./response-generator.js"
|
||||
import { loadScript, runScript } from "./script.js"
|
||||
import type { ScriptDefinition } from "../script/types.js"
|
||||
import { prepareScriptModule } from "../script/tooling.js"
|
||||
import { finalizeRecording } from "../recording/finalize.js"
|
||||
import { listenControl } from "../instance/control.js"
|
||||
import { configureLogFile, logError, logReadyPaths, logSuccess } from "../log.js"
|
||||
import {
|
||||
controlPath,
|
||||
markReady,
|
||||
markStarting,
|
||||
initializeManifest,
|
||||
register,
|
||||
registryDirectory,
|
||||
resolveInstance,
|
||||
unregister,
|
||||
} from "../instance/registry.js"
|
||||
import type { StartOptions } from "./types.js"
|
||||
|
||||
export const start = Effect.fn("DriveCli.start")((options: StartOptions) => Effect.scoped(startScoped(options)))
|
||||
|
||||
const startScoped = Effect.fn("DriveCli.startScoped")(function* (options: StartOptions) {
|
||||
const initializerPid = Number.parseInt(options.daemon ? (process.env.OPENCODE_DRIVE_INITIALIZER_PID ?? "") : "", 10)
|
||||
if (options.daemon) delete process.env.OPENCODE_DRIVE_INITIALIZER_PID
|
||||
const initialized = yield* fromPromise(() =>
|
||||
initializeManifest(options.name, process.cwd(), () => initializeInstance(options.name), {
|
||||
temporary: true,
|
||||
...(Number.isInteger(initializerPid) && initializerPid > 0 ? { adoptPid: initializerPid } : {}),
|
||||
}),
|
||||
)
|
||||
configureLogFile(initialized.artifacts)
|
||||
logSuccess(`starting ${options.name}`)
|
||||
logSuccess(`using artifacts ${initialized.artifacts}`)
|
||||
if (!options.visible && !options.script && !options.daemon)
|
||||
return yield* startDetached(options, initialized.artifacts)
|
||||
const scriptPath = options.script
|
||||
const scriptModule = scriptPath
|
||||
? yield* fromPromise(async () => {
|
||||
logSuccess(`preparing script ${scriptPath}`)
|
||||
return prepareScriptModule(initialized.artifacts, scriptPath)
|
||||
})
|
||||
: undefined
|
||||
const script = scriptModule
|
||||
? yield* loadScript(scriptModule).pipe(
|
||||
Effect.tap(() => Effect.sync(() => logSuccess(`loading script ${scriptModule}`))),
|
||||
)
|
||||
: undefined
|
||||
if (script && "launch" in script && options.record) {
|
||||
return yield* Effect.fail(new Error("--record is not supported when launch is manual"))
|
||||
}
|
||||
const responses = createResponseSettings()
|
||||
logSuccess("launching instance")
|
||||
const log = options.visible ? (message: string) => logSuccess(message, { terminal: false }) : logSuccess
|
||||
const instance = yield* OpenCodeInstance.make({
|
||||
artifacts: initialized.artifacts,
|
||||
name: options.name,
|
||||
command: options.command,
|
||||
dev: options.dev,
|
||||
scripted: options.script !== undefined,
|
||||
visible: options.visible,
|
||||
record: options.record,
|
||||
viewport: script?.tui?.viewport,
|
||||
project: script?.project,
|
||||
config: script?.config,
|
||||
tui: script?.tuiConfig,
|
||||
setup: script?.setup,
|
||||
tools: script?.tools,
|
||||
log,
|
||||
})
|
||||
yield* Effect.acquireRelease(
|
||||
fromPromise(() =>
|
||||
register({
|
||||
version: 1,
|
||||
name: options.name,
|
||||
pid: process.pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
cwd: process.cwd(),
|
||||
artifacts: instance.artifacts,
|
||||
visible: options.visible,
|
||||
status: "starting",
|
||||
endpoints: instance.endpoints,
|
||||
control: controlPath(options.name),
|
||||
}),
|
||||
),
|
||||
() => fromPromise(() => unregister(options.name, process.pid)).pipe(Effect.ignore),
|
||||
)
|
||||
return yield* lifecycle(options, instance, responses, script, log)
|
||||
})
|
||||
|
||||
function lifecycle(
|
||||
options: StartOptions,
|
||||
instance: OpenCodeInstance.Instance,
|
||||
responses: ReturnType<typeof createResponseSettings>,
|
||||
script: ScriptDefinition | undefined,
|
||||
log: (message: string) => void,
|
||||
) {
|
||||
return Effect.callback<void, unknown>((resume) => {
|
||||
const abort = new AbortController()
|
||||
const promise = runLifecycle(options, instance, responses, script, log, abort.signal)
|
||||
void promise.then(
|
||||
() => resume(Effect.void),
|
||||
(error) => resume(Effect.fail(error)),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
abort.abort(new Error("opencode-drive interrupted"))
|
||||
yield* Effect.promise(() => promise.catch(() => undefined))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function runLifecycle(
|
||||
options: StartOptions,
|
||||
instance: OpenCodeInstance.Instance,
|
||||
responses: ReturnType<typeof createResponseSettings>,
|
||||
script: ScriptDefinition | undefined,
|
||||
log: (message: string) => void,
|
||||
signal: AbortSignal,
|
||||
) {
|
||||
let completed = false
|
||||
let current: ReturnType<typeof run> | undefined
|
||||
let restarting: Promise<string | undefined> | undefined
|
||||
let stopping = false
|
||||
const screenshots: string[] = []
|
||||
const recordings: string[] = []
|
||||
let driveReady = false
|
||||
let recording: Promise<string | undefined> | undefined
|
||||
const finishCurrentRecording = (onProgress?: (percent: number) => void) => {
|
||||
if (!options.record || options.visible || !driveReady || options.script !== undefined)
|
||||
return Promise.resolve(undefined)
|
||||
recording ??= finishRecording(instance, onProgress)
|
||||
return recording
|
||||
}
|
||||
const interrupt = () => {
|
||||
stopping = true
|
||||
current?.abort.abort(signal.reason)
|
||||
if (!options.script) void stopInstance().catch((error) => logError(`failed to stop interrupted instance: ${error}`))
|
||||
}
|
||||
signal.addEventListener("abort", interrupt, { once: true })
|
||||
const stopInstance = async (onProgress?: (percent: number) => void) => {
|
||||
try {
|
||||
const output = await finishCurrentRecording(onProgress)
|
||||
return {
|
||||
...(output ? { recording: output } : {}),
|
||||
screenshots: [...screenshots],
|
||||
}
|
||||
} finally {
|
||||
stopping = true
|
||||
current?.abort.abort(new Error("opencode-drive stopped"))
|
||||
await runEffect(instance.stop)
|
||||
}
|
||||
}
|
||||
const completeScript = async () => {
|
||||
completed = true
|
||||
const result = await stopInstance()
|
||||
for (const screenshot of result.screenshots) console.log(screenshot)
|
||||
}
|
||||
let closeControl: (() => Promise<void>) | undefined
|
||||
let failure: unknown
|
||||
try {
|
||||
closeControl = await listenControl(controlPath(options.name), {
|
||||
restart: () => {
|
||||
if (restarting) return restarting
|
||||
restarting = (async () => {
|
||||
await markStarting(options.name, process.pid)
|
||||
const output = await finishCurrentRecording()
|
||||
const previous = current
|
||||
const restartReason = new Error("script restarted")
|
||||
previous?.abort.abort(restartReason)
|
||||
await previous?.promise
|
||||
driveReady = false
|
||||
await runEffect(instance.restart)
|
||||
recording = undefined
|
||||
current = run(
|
||||
options,
|
||||
instance,
|
||||
responses,
|
||||
script,
|
||||
(path) => screenshots.push(path),
|
||||
(path) => recordings.push(path),
|
||||
log,
|
||||
)
|
||||
await current.ready
|
||||
driveReady = true
|
||||
await markReady(options.name, process.pid)
|
||||
await logReadyPaths(instance.artifacts, {
|
||||
terminal: !options.visible,
|
||||
})
|
||||
return output
|
||||
})().finally(() => {
|
||||
restarting = undefined
|
||||
})
|
||||
return restarting
|
||||
},
|
||||
stop: stopInstance,
|
||||
responses: async (input) => {
|
||||
if (options.script) throw new Error("responses are unavailable when --script owns the simulation backend")
|
||||
return responses.update(input)
|
||||
},
|
||||
})
|
||||
if (signal.aborted) return
|
||||
current = run(
|
||||
options,
|
||||
instance,
|
||||
responses,
|
||||
script,
|
||||
(path) => screenshots.push(path),
|
||||
(path) => recordings.push(path),
|
||||
log,
|
||||
)
|
||||
await current.ready
|
||||
driveReady = true
|
||||
log(`ready ${options.name}`)
|
||||
await markReady(options.name, process.pid)
|
||||
await logReadyPaths(instance.artifacts, { terminal: !options.visible })
|
||||
if (options.visible) {
|
||||
while (true) {
|
||||
const active: NonNullable<typeof current> = current
|
||||
let result: { readonly script: true } | { readonly script: false; readonly status: number }
|
||||
try {
|
||||
result = options.script
|
||||
? await Promise.race([
|
||||
active.promise.then(() => ({ script: true as const })),
|
||||
runEffect(instance.wait).then((status) => ({ script: false as const, status })),
|
||||
])
|
||||
: { script: false as const, status: await runEffect(instance.wait) }
|
||||
} catch (error) {
|
||||
if (stopping) return
|
||||
if (restarting || active !== current) {
|
||||
await restarting
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (restarting || active !== current) {
|
||||
await restarting
|
||||
continue
|
||||
}
|
||||
if (result.script) {
|
||||
await completeScript()
|
||||
}
|
||||
const status = result.script ? await runEffect(instance.wait) : result.status
|
||||
if (status !== 0 && !stopping) process.exitCode = status
|
||||
return
|
||||
}
|
||||
}
|
||||
while (true) {
|
||||
const active: NonNullable<typeof current> = current
|
||||
try {
|
||||
await active.promise
|
||||
} catch (error) {
|
||||
if (stopping) break
|
||||
if (restarting || active !== current) {
|
||||
await restarting
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (stopping) break
|
||||
if (restarting) {
|
||||
await restarting
|
||||
continue
|
||||
}
|
||||
if (active !== current) continue
|
||||
if (options.script) {
|
||||
await completeScript()
|
||||
break
|
||||
}
|
||||
completed = true
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
failure = error
|
||||
throw error
|
||||
} finally {
|
||||
signal.removeEventListener("abort", interrupt)
|
||||
current?.abort.abort(new Error("opencode-drive stopped"))
|
||||
let cleanupFailure: unknown
|
||||
const recordingPath = await finishCurrentRecording().catch((error) => {
|
||||
logError(`failed to export recording: ${error}`)
|
||||
return undefined
|
||||
})
|
||||
await closeControl?.().catch((error) => {
|
||||
cleanupFailure ??= error
|
||||
logError(`failed to close control socket: ${error}`)
|
||||
})
|
||||
await runEffect(instance.stop).catch((error) => {
|
||||
cleanupFailure ??= error
|
||||
logError(`failed to stop OpenCode: ${error}`)
|
||||
})
|
||||
await unregister(options.name, process.pid).catch((error) => {
|
||||
cleanupFailure ??= error
|
||||
logError(`failed to unregister ${options.name}: ${error}`)
|
||||
})
|
||||
if (options.script && !options.visible) report(completed ? "completed" : undefined)
|
||||
if (options.script && recordingPath) logSuccess(`recording ${recordingPath}`)
|
||||
if (options.script) for (const output of recordings) logSuccess(`recording ${output}`)
|
||||
if (shouldCleanArtifacts(options.script, completed, failure, cleanupFailure))
|
||||
await rm(instance.artifacts, { recursive: true, force: true }).catch((error) => {
|
||||
cleanupFailure ??= error
|
||||
logError(`failed to clean artifacts ${instance.artifacts}: ${error}`)
|
||||
})
|
||||
if (options.script && failure !== undefined) {
|
||||
logError(failure instanceof Error ? failure.message : toStringUnknown(failure))
|
||||
process.exit(1)
|
||||
}
|
||||
if (failure === undefined && cleanupFailure !== undefined) process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
function shouldCleanArtifacts(
|
||||
script: string | undefined,
|
||||
completed: boolean,
|
||||
failure: unknown,
|
||||
cleanupFailure: unknown,
|
||||
) {
|
||||
return (
|
||||
script !== undefined &&
|
||||
completed &&
|
||||
failure === undefined &&
|
||||
cleanupFailure === undefined &&
|
||||
(process.exitCode === undefined || process.exitCode === 0) &&
|
||||
process.env.OPENCODE_DRIVE_KEEP_ARTIFACTS !== "1"
|
||||
)
|
||||
}
|
||||
|
||||
async function finishRecording(instance: OpenCodeInstance.Instance, onProgress?: (percent: number) => void) {
|
||||
const expected = await runEffect(instance.recording)
|
||||
if (!expected) throw new Error("recording was not enabled for this instance")
|
||||
let timeline: string
|
||||
const process = await runEffect(instance.primary)
|
||||
if (!(await runEffect(process.isRunning))) {
|
||||
timeline = expected.timeline
|
||||
} else {
|
||||
timeline = await runEffect(
|
||||
Effect.scoped(
|
||||
SimulationConnector.ui(instance.endpoints.ui, {
|
||||
connectTimeout: 60_000,
|
||||
}).pipe(
|
||||
Effect.flatMap((connection) => connection.rpc["ui.recording.finish"]()),
|
||||
Effect.timeoutOrElse({
|
||||
duration: 60_000,
|
||||
orElse: () => Effect.fail(new Error("ui.recording.finish timed out")),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
return finalizeRecording(timeline, expected, { onProgress })
|
||||
}
|
||||
|
||||
const startDetached = Effect.fn("DriveCli.startDetached")(function* (options: StartOptions, artifacts: string) {
|
||||
const ownerLog = join(registryDirectory(), `${options.name}.log`)
|
||||
yield* fromPromise(() => mkdir(registryDirectory(), { recursive: true }))
|
||||
yield* fromPromise(() => rm(ownerLog, { force: true }))
|
||||
logSuccess(`launching detached owner for ${options.name}`)
|
||||
const child = yield* DriveProcess.spawn(
|
||||
[
|
||||
process.execPath,
|
||||
process.argv[1]!,
|
||||
"start",
|
||||
"--daemon",
|
||||
"--name",
|
||||
options.name,
|
||||
...(options.script ? ["--script", options.script] : []),
|
||||
...(options.dev ? ["--dev", options.dev] : []),
|
||||
...(options.record ? ["--record"] : []),
|
||||
...(options.command.length ? ["--", ...options.command] : []),
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCODE_DRIVE_LOG: configureLogFile(artifacts),
|
||||
OPENCODE_DRIVE_OWNER_LOG: ownerLog,
|
||||
OPENCODE_DRIVE_INITIALIZER_PID: String(process.pid),
|
||||
},
|
||||
stdin: "ignore",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
detached: true,
|
||||
},
|
||||
)
|
||||
logSuccess(`waiting for ${options.name} to become ready`)
|
||||
const deadline = Date.now() + 60_000
|
||||
while (Date.now() < deadline) {
|
||||
const manifest = yield* fromPromise(() => resolveInstance(options.name).catch(() => undefined))
|
||||
if (manifest?.pid === child.pid) {
|
||||
logSuccess(`ready ${options.name}`)
|
||||
yield* fromPromise(() => logReadyPaths(manifest.artifacts))
|
||||
yield* child.detach
|
||||
return
|
||||
}
|
||||
if (!(yield* child.isRunning)) {
|
||||
const status = yield* child.exitCode
|
||||
yield* Effect.fail(new Error(`detached instance exited with status ${status}; see ${ownerLog}`))
|
||||
}
|
||||
yield* Effect.sleep(50)
|
||||
}
|
||||
yield* child.terminate
|
||||
yield* Effect.fail(new Error(`timed out starting drive instance "${options.name}"; see ${ownerLog}`))
|
||||
})
|
||||
|
||||
function run(
|
||||
options: StartOptions,
|
||||
instance: OpenCodeInstance.Instance,
|
||||
responses: ReturnType<typeof createResponseSettings>,
|
||||
driveScript: ScriptDefinition | undefined,
|
||||
onScreenshot: (path: string) => void,
|
||||
onRecording: (path: string) => void,
|
||||
log: (message: string) => void,
|
||||
) {
|
||||
const abort = new AbortController()
|
||||
const readiness = Promise.withResolvers<void>()
|
||||
let markedReady = false
|
||||
const ready = () => {
|
||||
if (markedReady) return
|
||||
markedReady = true
|
||||
readiness.resolve()
|
||||
}
|
||||
const promise = (async () => {
|
||||
if (!driveScript) {
|
||||
log("waiting for OpenCode")
|
||||
await runEffect(instance.waitForDrive("both"))
|
||||
log("OpenCode ready")
|
||||
}
|
||||
if (driveScript) {
|
||||
log("running script")
|
||||
const exit = await Effect.runPromiseExit(
|
||||
Effect.scoped(runScript(driveScript, instance, onScreenshot, onRecording, ready)),
|
||||
{ signal: abort.signal },
|
||||
)
|
||||
if (Exit.isFailure(exit)) {
|
||||
if (abort.signal.aborted && Cause.hasInterruptsOnly(exit.cause)) return
|
||||
throw Cause.squash(exit.cause)
|
||||
}
|
||||
ready()
|
||||
log("script completed")
|
||||
return
|
||||
}
|
||||
const child = await runEffect(instance.primary)
|
||||
const mock = await connectMockBackend(instance.endpoints.backend, responses)
|
||||
ready()
|
||||
abort.signal.addEventListener("abort", () => mock.close(), {
|
||||
once: true,
|
||||
})
|
||||
const status = await Promise.race([
|
||||
runEffect(child.exitCode),
|
||||
new Promise<number>((resolve) =>
|
||||
abort.signal.addEventListener("abort", () => resolve(0), {
|
||||
once: true,
|
||||
}),
|
||||
),
|
||||
])
|
||||
mock.close()
|
||||
if (status !== 0 && !abort.signal.aborted) process.exitCode = status
|
||||
})().catch((error) => {
|
||||
if (!markedReady) readiness.reject(error)
|
||||
throw error
|
||||
})
|
||||
void promise.catch(() => undefined)
|
||||
return {
|
||||
abort,
|
||||
ready: readiness.promise,
|
||||
promise,
|
||||
}
|
||||
}
|
||||
|
||||
const runEffect = <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromise(effect)
|
||||
|
||||
const fromPromise = <A>(task: () => Promise<A>) => Effect.tryPromise({ try: task, catch: (error) => error })
|
||||
|
||||
function report(status?: string) {
|
||||
if (status) logSuccess(status)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { rm } from "node:fs/promises"
|
||||
import { basename, dirname, resolve } from "node:path"
|
||||
import { artifactDirectory } from "../instance/instance.js"
|
||||
import { requestStop } from "../instance/control.js"
|
||||
import { manifestPath, resolveInstance } from "../instance/registry.js"
|
||||
import { configureLogFile, logSuccess } from "../log.js"
|
||||
|
||||
export async function stop(name?: string) {
|
||||
const manifest = await resolveInstance(name)
|
||||
configureLogFile(manifest.artifacts)
|
||||
const result = await requestStop(manifest.control, (percent) => {
|
||||
logSuccess(`Rendering video: ${percent}%`)
|
||||
})
|
||||
const deadline = Date.now() + 5 * 60_000
|
||||
while (Date.now() < deadline) {
|
||||
const current: unknown = await Bun.file(manifestPath(manifest.name))
|
||||
.json()
|
||||
.catch(() => undefined)
|
||||
if (typeof current !== "object" || current === null || !("pid" in current) || current.pid !== manifest.pid) {
|
||||
for (const screenshot of result.screenshots) console.log(screenshot)
|
||||
if (result.recording) {
|
||||
logSuccess(`Video successfully created: ${result.recording}`)
|
||||
} else if (result.screenshots.length === 0) {
|
||||
console.log("success")
|
||||
}
|
||||
await pruneArtifacts(manifest.artifacts)
|
||||
return
|
||||
}
|
||||
await Bun.sleep(25)
|
||||
}
|
||||
throw new Error(`timed out stopping drive instance "${manifest.name}"`)
|
||||
}
|
||||
|
||||
async function pruneArtifacts(artifacts: string) {
|
||||
const directory = resolve(artifacts)
|
||||
if (dirname(directory) !== artifactDirectory() || !/^run-[^/\\]+$/.test(basename(directory))) return
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Frontend } from "../client/index.js"
|
||||
|
||||
export interface DriveCommand {
|
||||
readonly operation: Exclude<Frontend.Capability, "ui.click.semantic">
|
||||
readonly value?: string
|
||||
}
|
||||
|
||||
export interface StartOptions {
|
||||
readonly kind: "start"
|
||||
readonly name: string
|
||||
readonly daemon: boolean
|
||||
readonly script?: string
|
||||
readonly visible: boolean
|
||||
readonly record: boolean
|
||||
readonly dev?: string
|
||||
readonly command: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export interface SendOptions {
|
||||
readonly kind: "send"
|
||||
readonly name?: string
|
||||
readonly commands: ReadonlyArray<DriveCommand>
|
||||
}
|
||||
|
||||
export type CliOptions = StartOptions | SendOptions
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Backend } from "./protocol.js"
|
||||
|
||||
/** Default port of the OpenCode UI simulation server. */
|
||||
export const defaultPort = 40900
|
||||
/** Default port of the OpenCode backend (LLM) simulation server. */
|
||||
export const defaultBackendPort = 40950
|
||||
|
||||
export { Backend, Frontend, Handshake, JsonRpc, SimulationProtocol } from "./protocol.js"
|
||||
export type BackendFinishReason = Backend.FinishReason
|
||||
export type BackendItem = Backend.Item
|
||||
export type OpenedExchange = Backend.ProviderInvocation
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Backend, Frontend, Handshake, JsonRpc } from "@opencode-ai/protocol/simulation"
|
||||
|
||||
export { Backend, Frontend, Handshake, JsonRpc }
|
||||
export const SimulationProtocol = { Backend, Frontend, Handshake, JsonRpc }
|
||||
@@ -0,0 +1,277 @@
|
||||
import * as Cause from "effect/Cause"
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as Exit from "effect/Exit"
|
||||
import * as Ref from "effect/Ref"
|
||||
import * as Semaphore from "effect/Semaphore"
|
||||
import * as Scope from "effect/Scope"
|
||||
import * as Deferred from "effect/Deferred"
|
||||
import type * as OpenCodeInstance from "../instance/runtime.js"
|
||||
import type * as SimulationConnector from "../simulation/connector.js"
|
||||
import type { Frontend } from "../client/protocol.js"
|
||||
import { finalizeRecording } from "../recording/finalize.js"
|
||||
import { error, type OpenCodeDriverError } from "./error.js"
|
||||
import * as OpenCodeUi from "./ui.js"
|
||||
import * as SharedEffect from "./shared.js"
|
||||
|
||||
export interface TuiOptions {
|
||||
readonly recording?: boolean
|
||||
readonly viewport?: Frontend.ResizeParams
|
||||
}
|
||||
|
||||
export interface Tui {
|
||||
readonly ui: OpenCodeUi.Ui
|
||||
readonly recording?: Recording
|
||||
readonly close: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface Recording {
|
||||
readonly path: string
|
||||
readonly timeline: string
|
||||
readonly finish: () => Effect.Effect<string, OpenCodeDriverError | OpenCodeUi.OperationError>
|
||||
}
|
||||
|
||||
interface ManagedTui extends Tui {
|
||||
readonly compatibility: SimulationConnector.EndpointCompatibility
|
||||
readonly _exitCode: Effect.Effect<number, OpenCodeDriverError>
|
||||
readonly _recording?: {
|
||||
readonly finishTimeline: Effect.Effect<string, OpenCodeDriverError | OpenCodeUi.OperationError>
|
||||
readonly exportRecording: Effect.Effect<string, OpenCodeDriverError | OpenCodeUi.OperationError>
|
||||
}
|
||||
}
|
||||
|
||||
export const make = Effect.fn("OpenCodeTui.make")(function* (
|
||||
instance: OpenCodeInstance.Instance,
|
||||
visible: boolean,
|
||||
identity: string,
|
||||
options: TuiOptions,
|
||||
connector: SimulationConnector.Interface,
|
||||
compatibility?: SimulationConnector.CompatibilityPolicy,
|
||||
) {
|
||||
if (visible && options.recording)
|
||||
return yield* Effect.fail(error("tui.launch", "recording requires a headless OpenCode TUI"))
|
||||
const launched = yield* Effect.acquireRelease(
|
||||
instance
|
||||
.launchTui(identity, {
|
||||
record: options.recording,
|
||||
viewport: options.viewport,
|
||||
})
|
||||
.pipe(Effect.mapError((cause) => error("tui.launch", cause))),
|
||||
(client) => client.close.pipe(Effect.catchCause((cause) => Effect.logError("OpenCode TUI cleanup failed", cause))),
|
||||
)
|
||||
const connection = yield* connector.ui(launched.endpoint, { compatibility })
|
||||
const ui = OpenCodeUi.make(connection)
|
||||
yield* ui.waitFor((state) => state.focused.editor, {
|
||||
timeout: 30_000,
|
||||
interval: 50,
|
||||
})
|
||||
|
||||
const recording = launched.recording
|
||||
let managedRecording: ManagedTui["_recording"]
|
||||
if (recording !== undefined) {
|
||||
const finishTimeline = yield* SharedEffect.make(
|
||||
Effect.gen(function* () {
|
||||
const timeline = yield* ui.finishRecording()
|
||||
if (timeline !== recording.timeline)
|
||||
return yield* Effect.fail(
|
||||
error("recording.finish", `OpenCode returned an unexpected recording path: ${timeline}`),
|
||||
)
|
||||
return timeline
|
||||
}),
|
||||
)
|
||||
const exportFinishedRecording = yield* SharedEffect.make(
|
||||
Effect.flatMap(finishTimeline, (timeline) =>
|
||||
Effect.tryPromise({
|
||||
try: (signal) => finalizeRecording(timeline, recording, { signal }),
|
||||
catch: (cause) => error("recording.export", cause),
|
||||
}),
|
||||
),
|
||||
)
|
||||
managedRecording = {
|
||||
finishTimeline,
|
||||
exportRecording: exportFinishedRecording,
|
||||
}
|
||||
yield* Effect.addFinalizer(() =>
|
||||
finishTimeline.pipe(
|
||||
Effect.asVoid,
|
||||
Effect.catchCause((cause) => Effect.logError("OpenCode TUI recording finalization failed", cause)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
ui,
|
||||
compatibility: connection.compatibility,
|
||||
close: () => Effect.void,
|
||||
_exitCode: launched.process.exitCode.pipe(Effect.mapError((cause) => error("tui.exit", cause))),
|
||||
...(recording === undefined || managedRecording === undefined
|
||||
? {}
|
||||
: {
|
||||
recording: {
|
||||
path: recording.video,
|
||||
timeline: recording.timeline,
|
||||
finish: () => managedRecording.exportRecording,
|
||||
},
|
||||
_recording: managedRecording,
|
||||
}),
|
||||
} satisfies ManagedTui
|
||||
})
|
||||
|
||||
export interface Tuis {
|
||||
readonly launch: {
|
||||
(options?: TuiOptions): Effect.Effect<Tui, TuiLaunchError>
|
||||
/** Launches a named TUI. The name is released when that TUI closes. */
|
||||
(name: string, options?: TuiOptions): Effect.Effect<Tui, TuiLaunchError>
|
||||
}
|
||||
}
|
||||
|
||||
export type TuiLaunchError =
|
||||
| OpenCodeDriverError
|
||||
| SimulationConnector.SimulationCompatibilityError
|
||||
| OpenCodeUi.OperationError
|
||||
| OpenCodeUi.UiPredicateError
|
||||
| OpenCodeUi.UiWaitOptionsError
|
||||
|
||||
export interface UnexpectedExit {
|
||||
readonly name: string
|
||||
readonly status: number
|
||||
}
|
||||
|
||||
export interface Control extends Tuis {
|
||||
readonly compatibility: Effect.Effect<ReadonlyArray<SimulationConnector.EndpointCompatibility>>
|
||||
readonly unexpectedExit: Effect.Effect<UnexpectedExit>
|
||||
readonly settle: () => Effect.Effect<ReadonlyArray<string>, OpenCodeDriverError | OpenCodeUi.OperationError>
|
||||
}
|
||||
|
||||
export const makeTuis = Effect.fn("OpenCodeTuis.make")(function* (
|
||||
instance: OpenCodeInstance.Instance,
|
||||
visible: boolean,
|
||||
connector: SimulationConnector.Interface,
|
||||
compatibilityPolicy?: SimulationConnector.CompatibilityPolicy,
|
||||
) {
|
||||
const parentScope = yield* Scope.Scope
|
||||
const tuisScope = yield* Scope.fork(parentScope, "parallel")
|
||||
const lock = yield* Semaphore.make(1)
|
||||
let closed = false
|
||||
let recordings: ReadonlyArray<NonNullable<ManagedTui["_recording"]>> = []
|
||||
const nextIdentity = yield* Ref.make(0)
|
||||
let active: ReadonlyMap<string, Scope.Scope> = new Map()
|
||||
const unexpectedExit = yield* Deferred.make<UnexpectedExit>()
|
||||
let compatibility: ReadonlyArray<SimulationConnector.EndpointCompatibility> = []
|
||||
|
||||
const launchNamed = Effect.fn("OpenCodeTuis.launchNamed")(function* (identity: string, options: TuiOptions = {}) {
|
||||
return yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (closed) return yield* Effect.fail(error("tui.launch", "OpenCode TUIs are closed"))
|
||||
if (active.has(identity))
|
||||
return yield* Effect.fail(error("tui.launch", `TUI "${identity}" is already connected`))
|
||||
const scope = yield* Scope.fork(tuisScope)
|
||||
active = new Map(active).set(identity, scope)
|
||||
const client = yield* make(instance, visible, identity, options, connector, compatibilityPolicy).pipe(
|
||||
Scope.provide(scope),
|
||||
Effect.onError(() =>
|
||||
Effect.sync(() => {
|
||||
const next = new Map(active)
|
||||
next.delete(identity)
|
||||
active = next
|
||||
}).pipe(Effect.andThen(Scope.close(scope, Exit.void))),
|
||||
),
|
||||
)
|
||||
compatibility = [...compatibility, client.compatibility]
|
||||
const recording = client._recording
|
||||
if (recording !== undefined) recordings = [...recordings, recording]
|
||||
const claim = lock.withPermit(
|
||||
Effect.sync(() => {
|
||||
if (active.get(identity) !== scope) return false
|
||||
const next = new Map(active)
|
||||
next.delete(identity)
|
||||
active = next
|
||||
return true
|
||||
}),
|
||||
)
|
||||
const release = claim.pipe(Effect.flatMap((owned) => (owned ? Scope.close(scope, Exit.void) : Effect.void)))
|
||||
yield* client._exitCode.pipe(
|
||||
Effect.flatMap((status) =>
|
||||
claim.pipe(
|
||||
Effect.flatMap((owned) =>
|
||||
owned
|
||||
? Deferred.succeed(unexpectedExit, {
|
||||
name: identity,
|
||||
status,
|
||||
}).pipe(Effect.andThen(Scope.close(scope, Exit.void)))
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.catchCause(() => Effect.void),
|
||||
Effect.forkIn(tuisScope),
|
||||
)
|
||||
const publicTui: Tui = {
|
||||
ui: client.ui,
|
||||
...(client.recording === undefined ? {} : { recording: client.recording }),
|
||||
close: () => release,
|
||||
}
|
||||
return publicTui
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function launch(options?: TuiOptions): Effect.Effect<Tui, TuiLaunchError>
|
||||
function launch(name: string, options?: TuiOptions): Effect.Effect<Tui, TuiLaunchError>
|
||||
function launch(nameOrOptions: string | TuiOptions = {}, options: TuiOptions = {}) {
|
||||
return typeof nameOrOptions === "string"
|
||||
? launchNamed(nameOrOptions, options)
|
||||
: Ref.getAndUpdate(nextIdentity, (value) => value + 1).pipe(
|
||||
Effect.flatMap((identity) => launchNamed(String(identity), nameOrOptions)),
|
||||
)
|
||||
}
|
||||
|
||||
const finishTimelines = yield* SharedEffect.make(
|
||||
Effect.gen(function* () {
|
||||
const active = yield* lock.withPermit(
|
||||
Effect.sync(() => {
|
||||
closed = true
|
||||
return recordings
|
||||
}),
|
||||
)
|
||||
const finished = yield* Effect.forEach(active, (recording) => Effect.exit(recording.finishTimeline), {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
yield* Scope.close(tuisScope, Exit.void)
|
||||
return { active, finished }
|
||||
}),
|
||||
)
|
||||
|
||||
const settle = Effect.fn("OpenCodeTuis.settle")(function* () {
|
||||
const { active, finished } = yield* finishTimelines
|
||||
const exported = yield* Effect.forEach(
|
||||
active,
|
||||
(recording, index) =>
|
||||
Exit.isSuccess(finished[index]!)
|
||||
? Effect.exit(recording.exportRecording).pipe(
|
||||
Effect.map(
|
||||
(result): Exit.Exit<string | undefined, OpenCodeDriverError | OpenCodeUi.OperationError> => result,
|
||||
),
|
||||
)
|
||||
: Effect.succeed(Exit.succeed<string | undefined>(undefined)),
|
||||
{
|
||||
concurrency: 2,
|
||||
},
|
||||
)
|
||||
let failure: Cause.Cause<OpenCodeDriverError | OpenCodeUi.OperationError> | undefined
|
||||
for (const result of [...finished, ...exported]) {
|
||||
if (!Exit.isFailure(result)) continue
|
||||
failure = failure === undefined ? result.cause : Cause.combine(failure, result.cause)
|
||||
}
|
||||
if (failure !== undefined) return yield* Effect.failCause(failure)
|
||||
return exported.flatMap((result) => (Exit.isSuccess(result) && result.value !== undefined ? [result.value] : []))
|
||||
})
|
||||
|
||||
return {
|
||||
launch,
|
||||
unexpectedExit: Deferred.await(unexpectedExit),
|
||||
compatibility: Effect.sync(() => compatibility),
|
||||
settle,
|
||||
} satisfies Control
|
||||
})
|
||||
|
||||
export * as OpenCodeTui from "./client.js"
|
||||
@@ -0,0 +1,14 @@
|
||||
import * as Schema from "effect/Schema"
|
||||
|
||||
export class OpenCodeDriverError extends Schema.TaggedErrorClass<OpenCodeDriverError>()("OpenCodeDriverError", {
|
||||
operation: Schema.String,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export const error = (operation: string, cause: unknown) =>
|
||||
cause instanceof OpenCodeDriverError
|
||||
? cause
|
||||
: new OpenCodeDriverError({
|
||||
operation,
|
||||
message: cause instanceof Error ? cause.message : String(cause),
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as Cause from "effect/Cause"
|
||||
import * as Exit from "effect/Exit"
|
||||
import * as Layer from "effect/Layer"
|
||||
import type * as Scope from "effect/Scope"
|
||||
import * as NodeServices from "@effect/platform-node/NodeServices"
|
||||
import * as OpenCodeInstance from "../instance/runtime.js"
|
||||
import * as SimulationConnector from "../simulation/connector.js"
|
||||
import type { OpenCodeConfig, OpenCodeTuiConfig, Project, Setup } from "../project.js"
|
||||
import * as OpenCodeTui from "./client.js"
|
||||
import type * as OpenCodeSdk from "./opencode.js"
|
||||
import { error, type OpenCodeDriverError } from "./error.js"
|
||||
import type { LlmControllerError, LlmSettlementError } from "./llm-controller.js"
|
||||
import * as OpenCodeProject from "./project.js"
|
||||
import * as PreparedDriver from "./prepared.js"
|
||||
import * as OpenCodeServer from "./server.js"
|
||||
import type * as OpenCodeUi from "./ui.js"
|
||||
import type { Llm } from "./llm.js"
|
||||
import type { RunReport } from "./report.js"
|
||||
import * as ToolController from "../tool/controller.js"
|
||||
import type * as Tool from "../tool/index.js"
|
||||
|
||||
export interface Options {
|
||||
readonly project?: Project
|
||||
readonly config?: OpenCodeConfig
|
||||
readonly tuiConfig?: OpenCodeTuiConfig
|
||||
readonly setup?: Setup
|
||||
readonly tools?: Tool.Configuration
|
||||
readonly tui?: OpenCodeTui.TuiOptions
|
||||
readonly opencode?: OpenCodeServer.Target
|
||||
readonly keepArtifacts?: boolean
|
||||
}
|
||||
|
||||
export interface Driver {
|
||||
/** Generated SDK client connected to this driver's private OpenCode service. */
|
||||
readonly opencode: OpenCodeSdk.OpenCode
|
||||
readonly tui: OpenCodeTui.Tui
|
||||
/** Convenience alias for the primary TUI's UI. */
|
||||
readonly ui: OpenCodeUi.Ui
|
||||
readonly llm: Llm
|
||||
/** Runtime controls for tools declared by name in the driver options. */
|
||||
readonly tools: Tool.Controls
|
||||
readonly tuis: OpenCodeTui.Tuis
|
||||
readonly artifacts: string
|
||||
/** Validates queued LLM work, stops TUIs, and exports recordings. */
|
||||
readonly settle: () => Effect.Effect<
|
||||
RunReport,
|
||||
LlmControllerError | LlmSettlementError | OpenCodeDriverError | OpenCodeUi.OperationError
|
||||
>
|
||||
}
|
||||
|
||||
const makeWithServices = Effect.fn("OpenCodeDriver.makeWithServices")(function* (options: Options = {}) {
|
||||
const toolController = yield* ToolController.make(options.tools)
|
||||
const project = yield* OpenCodeProject.make({
|
||||
project: options.project,
|
||||
config: options.config,
|
||||
tui: options.tuiConfig,
|
||||
setup: ToolController.composeSetup(toolController, options.setup),
|
||||
keepArtifacts: options.keepArtifacts,
|
||||
})
|
||||
const instance = yield* OpenCodeInstance.make(
|
||||
{
|
||||
artifacts: project.artifacts,
|
||||
name: `library-${crypto.randomUUID().slice(0, 12)}`,
|
||||
scripted: true,
|
||||
command: options.opencode?.command,
|
||||
dev: options.opencode?.dev,
|
||||
env: options.opencode?.env,
|
||||
visible: options.opencode?.visible,
|
||||
},
|
||||
toolController,
|
||||
).pipe(Effect.mapError((cause) => error("server.prepare", cause)))
|
||||
const prepared = yield* PreparedDriver.makeWithServices(instance, {
|
||||
visible: options.opencode?.visible,
|
||||
tui: options.tui,
|
||||
artifactsRetained: options.keepArtifacts ?? false,
|
||||
compatibility: options.opencode?.compatibility,
|
||||
})
|
||||
if (prepared.driver === undefined) return yield* Effect.die(new Error("automatic driver did not launch a TUI"))
|
||||
return { driver: prepared.driver, failure: prepared.failure }
|
||||
})
|
||||
|
||||
type MakeWithServices = ReturnType<typeof makeWithServices>
|
||||
const layer = Layer.merge(SimulationConnector.layer, NodeServices.layer)
|
||||
|
||||
const makeManaged = (
|
||||
options: Options = {},
|
||||
): Effect.Effect<Effect.Success<MakeWithServices>, Effect.Error<MakeWithServices>, Scope.Scope> =>
|
||||
makeWithServices(options).pipe(Effect.provide(layer))
|
||||
|
||||
export const make = (options: Options = {}) => makeManaged(options).pipe(Effect.map(({ driver }) => driver))
|
||||
|
||||
type Program<A, E, R> = (driver: Driver) => Effect.Effect<A, E, R>
|
||||
|
||||
const runReport = <A, E, R>(options: Options, f: Program<A, E, R>) =>
|
||||
Effect.scoped(
|
||||
Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const { driver, failure } = yield* makeManaged(options)
|
||||
const useExit = yield* Effect.exit(restore(Effect.raceFirst(f(driver), failure)))
|
||||
const settlement = yield* Effect.exit(driver.settle())
|
||||
if (Exit.isFailure(useExit) && Exit.isFailure(settlement))
|
||||
return yield* Effect.failCause(Cause.combine(useExit.cause, settlement.cause))
|
||||
if (Exit.isFailure(useExit)) return yield* Effect.failCause(useExit.cause)
|
||||
if (Exit.isFailure(settlement)) return yield* Effect.failCause(settlement.cause)
|
||||
return { value: useExit.value, report: settlement.value }
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
export function useReport<A, E, R>(f: Program<A, E, R>): ReturnType<typeof runReport<A, E, R>>
|
||||
export function useReport<A, E, R>(options: Options, f: Program<A, E, R>): ReturnType<typeof runReport<A, E, R>>
|
||||
export function useReport<A, E, R>(optionsOrProgram: Options | Program<A, E, R>, program?: Program<A, E, R>) {
|
||||
if (typeof optionsOrProgram === "function") return runReport({}, optionsOrProgram)
|
||||
if (program === undefined) return Effect.die(new Error("OpenCodeDriver.useReport requires a program"))
|
||||
return runReport(optionsOrProgram, program)
|
||||
}
|
||||
|
||||
const run = <A, E, R>(options: Options, program: Program<A, E, R>) =>
|
||||
runReport(options, program).pipe(Effect.map(({ value }) => value))
|
||||
|
||||
export function use<A, E, R>(f: Program<A, E, R>): ReturnType<typeof run<A, E, R>>
|
||||
export function use<A, E, R>(options: Options, f: Program<A, E, R>): ReturnType<typeof run<A, E, R>>
|
||||
export function use<A, E, R>(optionsOrProgram: Options | Program<A, E, R>, program?: Program<A, E, R>) {
|
||||
return typeof optionsOrProgram === "function"
|
||||
? run({}, optionsOrProgram)
|
||||
: program === undefined
|
||||
? Effect.die(new Error("OpenCodeDriver.use requires a program"))
|
||||
: run(optionsOrProgram, program)
|
||||
}
|
||||
|
||||
export { OpenCodeDriverError } from "./error.js"
|
||||
export { LlmControllerError, LlmModeError, LlmSettlementError } from "./llm-controller.js"
|
||||
export {
|
||||
UiCapabilityError,
|
||||
UiElementAmbiguousError,
|
||||
UiNodeAmbiguousError,
|
||||
UiPredicateError,
|
||||
UiTimeoutError,
|
||||
UiWaitOptionsError,
|
||||
} from "./ui.js"
|
||||
export { SimulationRequestError } from "@opencode-ai/protocol/simulation"
|
||||
export { SimulationCompatibilityError, SimulationConnectionError } from "../simulation/connector.js"
|
||||
export type { CompatibilityPolicy, EndpointCompatibility } from "../simulation/connector.js"
|
||||
export type { Recording, Tui, TuiLaunchError, TuiOptions, Tuis } from "./client.js"
|
||||
export type { Llm } from "./llm.js"
|
||||
export type { Target as OpenCodeTarget } from "./server.js"
|
||||
export type { OpenCode } from "./opencode.js"
|
||||
export type { Ui } from "./ui.js"
|
||||
export type { Project, ProjectFileSystem, Setup, SetupContext } from "../project.js"
|
||||
export * from "./report.js"
|
||||
@@ -0,0 +1,409 @@
|
||||
import type * as Cause from "effect/Cause"
|
||||
import * as Deferred from "effect/Deferred"
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as Exit from "effect/Exit"
|
||||
import * as FiberSet from "effect/FiberSet"
|
||||
import * as Queue from "effect/Queue"
|
||||
import * as Ref from "effect/Ref"
|
||||
import * as Schema from "effect/Schema"
|
||||
import * as Scope from "effect/Scope"
|
||||
import * as Semaphore from "effect/Semaphore"
|
||||
import * as Stream from "effect/Stream"
|
||||
import * as Llm from "../llm/index.js"
|
||||
import { isTitleRequest } from "../llm/internal.js"
|
||||
import type { BackendConnection } from "../simulation/connector.js"
|
||||
import { causeError, controllerError, LlmControllerError, LlmSettlementError } from "./llm-errors.js"
|
||||
import * as LlmResponder from "./llm-responder.js"
|
||||
import * as LlmState from "./llm-state.js"
|
||||
|
||||
/**
|
||||
* The concurrency shell of the LLM controller. Decision logic lives in the
|
||||
* pure `llm-state.ts` module; wire streaming lives in `llm-responder.ts`.
|
||||
* This module owns the lock, the state ref, job fibers, and deferreds.
|
||||
*/
|
||||
|
||||
export { LlmControllerError, LlmModeError, LlmSettlementError } from "./llm-errors.js"
|
||||
export type { Response } from "./llm-responder.js"
|
||||
export type { ServeHandler, TitleHandler } from "./llm-state.js"
|
||||
|
||||
export interface Options {
|
||||
/** Per-backend-RPC timeout in milliseconds. Defaults to 30,000. */
|
||||
readonly requestTimeout?: number
|
||||
/** Time allowed for queued and active responses to settle. Defaults to 30,000. */
|
||||
readonly settlementTimeout?: number
|
||||
}
|
||||
|
||||
export interface Controller {
|
||||
/** Attaches one backend generation while preserving response state. */
|
||||
readonly attach: (backend: BackendConnection) => Effect.Effect<Attachment, LlmControllerError>
|
||||
readonly queue: (...output: ReadonlyArray<Llm.Output>) => Effect.Effect<void, LlmState.RejectionError>
|
||||
readonly send: (...output: ReadonlyArray<Llm.Output>) => Effect.Effect<void, LlmState.RejectionError>
|
||||
readonly serve: (handler: LlmState.ServeHandler) => Effect.Effect<void, LlmState.RejectionError>
|
||||
readonly title: (handler: LlmState.TitleHandler) => Effect.Effect<void, LlmState.RejectionError>
|
||||
readonly settle: () => Effect.Effect<void, LlmControllerError | LlmSettlementError>
|
||||
/** Interrupts request routing and response workers. Used by the driver coordinator. */
|
||||
readonly shutdown: () => Effect.Effect<void>
|
||||
/** Fails when request routing or the backend connection fails. */
|
||||
readonly failure: Effect.Effect<never, LlmControllerError>
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
readonly detach: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
/** A committed normal job: its selection plus the shell-owned completion. */
|
||||
interface NormalJob extends LlmState.NormalStart {
|
||||
readonly completion: LlmState.Completion
|
||||
}
|
||||
|
||||
const NonNegativeMilliseconds = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0))
|
||||
const decodeOutputs = Schema.decodeUnknownEffect(Schema.Array(Llm.Output))
|
||||
|
||||
export const make = Effect.fn("LlmController.make")(function* (
|
||||
backendOrOptions?: BackendConnection | Options,
|
||||
explicitOptions?: Options,
|
||||
) {
|
||||
const initialBackend = isBackendConnection(backendOrOptions) ? backendOrOptions : undefined
|
||||
const options = isBackendConnection(backendOrOptions) ? explicitOptions : backendOrOptions
|
||||
const requestTimeout = NonNegativeMilliseconds.make(options?.requestTimeout ?? 30_000)
|
||||
const settlementTimeout = NonNegativeMilliseconds.make(options?.settlementTimeout ?? 30_000)
|
||||
const responder = LlmResponder.make({ requestTimeout })
|
||||
|
||||
const state = yield* Ref.make(LlmState.initial)
|
||||
const lock = yield* Semaphore.make(1)
|
||||
const changes = yield* Queue.sliding<void>(1)
|
||||
const failureSignal = yield* Deferred.make<never, LlmControllerError>()
|
||||
const tasks = yield* FiberSet.make<void, never>()
|
||||
const parentScope = yield* Scope.Scope
|
||||
const attached = yield* Ref.make<{ readonly backend: BackendConnection; readonly scope: Scope.Scope } | undefined>(
|
||||
undefined,
|
||||
)
|
||||
|
||||
yield* Effect.addFinalizer(() => Queue.shutdown(changes))
|
||||
|
||||
const notify = Queue.offer(changes, undefined).pipe(Effect.asVoid)
|
||||
|
||||
const respondTo = (request: LlmState.AttachedRequest, output: LlmResponder.Response) =>
|
||||
responder.respond(request.backend, request.request.id, output)
|
||||
|
||||
const failCompletions = (completions: ReadonlyArray<LlmState.Completion>, error: LlmControllerError) =>
|
||||
Effect.forEach(completions, (completion) => Deferred.fail(completion, error), { discard: true })
|
||||
|
||||
/** Must run while holding the lock. */
|
||||
const recordFailureLocked = Effect.fn("LlmController.recordFailureLocked")(function* (error: LlmControllerError) {
|
||||
const current = yield* Ref.get(state)
|
||||
const [next, { failure, isFirst }] = LlmState.recordFailure(current, error)
|
||||
yield* Ref.set(state, next)
|
||||
if (isFirst) yield* Deferred.fail(failureSignal, failure)
|
||||
yield* failCompletions(current.sendCompletions, failure)
|
||||
yield* notify
|
||||
return failure
|
||||
})
|
||||
|
||||
const completeNormal = Effect.fn("LlmController.completeNormal")(function* (
|
||||
job: NormalJob,
|
||||
error?: LlmControllerError,
|
||||
) {
|
||||
const sendCompletion = LlmState.NormalSource.$is("Queued")(job.source) ? job.source.response.completed : undefined
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.update(state, (current) => LlmState.finishNormal(current, job.completion, sendCompletion))
|
||||
if (error === undefined) {
|
||||
yield* Deferred.succeed(job.completion, undefined)
|
||||
if (sendCompletion !== undefined) yield* Deferred.succeed(sendCompletion, undefined)
|
||||
} else {
|
||||
yield* Deferred.fail(job.completion, error)
|
||||
if (sendCompletion !== undefined) yield* Deferred.fail(sendCompletion, error)
|
||||
yield* recordFailureLocked(error)
|
||||
}
|
||||
yield* notify
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const runNormal = (job: NormalJob): Effect.Effect<void> => {
|
||||
const output = LlmState.NormalSource.$match(job.source, {
|
||||
Queued: ({ response }) => respondTo(job.request, Stream.fromIterable(response.output)),
|
||||
Served: ({ handler }) => Effect.suspend(() => respondTo(job.request, handler(job.request.request, job.index))),
|
||||
})
|
||||
return Effect.matchCauseEffect(output, {
|
||||
onFailure: (cause) => completeNormal(job, causeError("respond", cause, job.request.request.id)),
|
||||
onSuccess: () => completeNormal(job),
|
||||
})
|
||||
}
|
||||
|
||||
/** Starts every runnable normal job. Must run while holding the lock. */
|
||||
const drainLocked = Effect.fn("LlmController.drainLocked")(function* () {
|
||||
while (true) {
|
||||
const current = yield* Ref.get(state)
|
||||
const start = LlmState.nextNormal(current)
|
||||
if (start === undefined) return
|
||||
const completion = yield* Deferred.make<void, LlmControllerError>()
|
||||
yield* Ref.set(state, LlmState.startNormal(current, start, completion))
|
||||
yield* FiberSet.run(tasks, runNormal({ ...start, completion }))
|
||||
yield* notify
|
||||
}
|
||||
})
|
||||
|
||||
const completeTitle = Effect.fn("LlmController.completeTitle")(function* (
|
||||
completion: LlmState.Completion,
|
||||
error?: LlmControllerError,
|
||||
) {
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.update(state, (current) => LlmState.finishTitle(current, completion))
|
||||
if (error === undefined) yield* Deferred.succeed(completion, undefined)
|
||||
else {
|
||||
yield* Deferred.fail(completion, error)
|
||||
yield* recordFailureLocked(error)
|
||||
}
|
||||
yield* notify
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
/** Titles respond after in-flight normal jobs, outside request sequencing. */
|
||||
const startTitleLocked = Effect.fn("LlmController.startTitleLocked")(function* (request: LlmState.AttachedRequest) {
|
||||
const completion = yield* Deferred.make<void, LlmControllerError>()
|
||||
const current = yield* Ref.get(state)
|
||||
const [next, title] = LlmState.startTitle(current, completion)
|
||||
yield* Ref.set(state, next)
|
||||
const respond = Effect.gen(function* () {
|
||||
yield* Effect.forEach(title.awaiting, Deferred.await, { discard: true })
|
||||
const text = yield* Effect.suspend(() => title.handler(request.request, title.index))
|
||||
yield* respondTo(request, Stream.make(Llm.text(text)))
|
||||
})
|
||||
yield* FiberSet.run(
|
||||
tasks,
|
||||
Effect.matchCauseEffect(respond, {
|
||||
onFailure: (cause) => completeTitle(completion, causeError("title", cause, request.request.id)),
|
||||
onSuccess: () => completeTitle(completion),
|
||||
}),
|
||||
)
|
||||
yield* notify
|
||||
})
|
||||
|
||||
const routeRequest = (request: LlmState.AttachedRequest) =>
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Ref.get(state)
|
||||
if (current.failure !== undefined || current.settled) return
|
||||
if (isTitleRequest(request.request.body)) {
|
||||
yield* startTitleLocked(request)
|
||||
return
|
||||
}
|
||||
yield* Ref.set(state, LlmState.pushRequest(current, request))
|
||||
yield* drainLocked()
|
||||
yield* notify
|
||||
}),
|
||||
)
|
||||
|
||||
const recordRouterFailure = (cause: Cause.Cause<Schema.SchemaError>) =>
|
||||
lock.withPermit(recordFailureLocked(causeError("route requests", cause))).pipe(Effect.asVoid)
|
||||
|
||||
const attach = Effect.fn("LlmController.attach")(function* (backend: BackendConnection) {
|
||||
const scope = yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if ((yield* Ref.get(attached)) !== undefined)
|
||||
return yield* Effect.fail(controllerError("attach", "LLM backend is already attached"))
|
||||
const scope = yield* Scope.fork(parentScope)
|
||||
yield* Ref.set(attached, { backend, scope })
|
||||
return scope
|
||||
}),
|
||||
)
|
||||
yield* backend.requests.pipe(
|
||||
Stream.runForEach((request) => routeRequest({ request, backend })),
|
||||
Effect.matchCauseEffect({
|
||||
onFailure: recordRouterFailure,
|
||||
onSuccess: () => Effect.void,
|
||||
}),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
yield* backend.closed.pipe(
|
||||
Effect.andThen(
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const active = yield* Ref.get(attached)
|
||||
if (active?.backend !== backend) return
|
||||
const current = yield* Ref.get(state)
|
||||
if (current.settled) return
|
||||
yield* recordFailureLocked(controllerError("backend", "backend connection closed"))
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
const detach = Effect.fn("LlmController.detach")(function* () {
|
||||
const shouldClose = yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const active = yield* Ref.get(attached)
|
||||
if (active?.backend !== backend) return false
|
||||
yield* Ref.set(attached, undefined)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
if (shouldClose) yield* Scope.close(scope, Exit.void)
|
||||
})
|
||||
return { detach } satisfies Attachment
|
||||
})
|
||||
|
||||
const enqueue = Effect.fn("LlmController.enqueue")(function* (
|
||||
operation: "queue" | "send",
|
||||
output: ReadonlyArray<Llm.Output>,
|
||||
completed?: LlmState.Completion,
|
||||
) {
|
||||
const decoded = yield* decodeOutputs(output).pipe(Effect.mapError((cause) => controllerError(operation, cause)))
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Ref.get(state)
|
||||
const rejection = LlmState.rejectEnqueue(current, operation)
|
||||
if (rejection !== undefined) return yield* Effect.fail(rejection)
|
||||
yield* Ref.set(state, LlmState.enqueue(current, { output: decoded, completed }))
|
||||
yield* drainLocked()
|
||||
yield* notify
|
||||
return undefined
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const queue = Effect.fn("LlmController.queue")((...output: ReadonlyArray<Llm.Output>) => enqueue("queue", output))
|
||||
|
||||
const send = Effect.fn("LlmController.send")(function* (...output: ReadonlyArray<Llm.Output>) {
|
||||
const completed = yield* Deferred.make<void, LlmControllerError>()
|
||||
yield* enqueue("send", output, completed)
|
||||
yield* Deferred.await(completed).pipe(
|
||||
Effect.onInterrupt(() =>
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.update(state, (current) => LlmState.abandonSend(current, completed))
|
||||
yield* notify
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const serve = Effect.fn("LlmController.serve")((handler: LlmState.ServeHandler) =>
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Ref.get(state)
|
||||
const rejection = LlmState.rejectServe(current)
|
||||
if (rejection !== undefined) return yield* Effect.fail(rejection)
|
||||
yield* Ref.set(state, LlmState.serve(current, handler))
|
||||
yield* drainLocked()
|
||||
yield* notify
|
||||
return undefined
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const title = Effect.fn("LlmController.title")((handler: LlmState.TitleHandler) =>
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Ref.get(state)
|
||||
const rejection = LlmState.rejectTitle(current)
|
||||
if (rejection !== undefined) return yield* Effect.fail(rejection)
|
||||
yield* Ref.set(state, LlmState.configureTitle(current, handler))
|
||||
yield* notify
|
||||
return undefined
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const inspectSettlement = lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Ref.get(state)
|
||||
const settlement = LlmState.inspectSettlement(current)
|
||||
if (LlmState.Settlement.$is("Done")(settlement)) yield* Ref.set(state, LlmState.markSettled(current))
|
||||
return settlement
|
||||
}),
|
||||
)
|
||||
|
||||
const awaitSettlement = (): Effect.Effect<void, LlmControllerError | LlmSettlementError> =>
|
||||
Effect.suspend(() =>
|
||||
Effect.flatMap(
|
||||
inspectSettlement,
|
||||
LlmState.Settlement.$match({
|
||||
Done: () => Effect.void,
|
||||
Fail: ({ error }) => Effect.fail(error),
|
||||
Wait: () => Effect.andThen(Queue.take(changes), awaitSettlement()),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const failSettlementTimeout = lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Ref.get(state)
|
||||
const error = LlmState.settlementTimeoutError(current)
|
||||
const failure = controllerError("settle", error)
|
||||
yield* Ref.set(
|
||||
state,
|
||||
LlmState.markSettled({
|
||||
...current,
|
||||
failure: current.failure ?? failure,
|
||||
}),
|
||||
)
|
||||
yield* failCompletions(current.sendCompletions, failure)
|
||||
yield* notify
|
||||
return error
|
||||
}),
|
||||
)
|
||||
|
||||
const settle = Effect.fn("LlmController.settle")(function* () {
|
||||
yield* Effect.yieldNow
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.update(state, LlmState.beginSettling)
|
||||
yield* drainLocked()
|
||||
yield* notify
|
||||
}),
|
||||
)
|
||||
yield* awaitSettlement().pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: settlementTimeout,
|
||||
orElse: () => Effect.flatMap(failSettlementTimeout, Effect.fail),
|
||||
}),
|
||||
Effect.tapError((error) => (error instanceof LlmSettlementError ? FiberSet.clear(tasks) : Effect.void)),
|
||||
)
|
||||
})
|
||||
|
||||
const shutdown = Effect.fn("LlmController.shutdown")(function* () {
|
||||
const active = yield* Ref.get(attached)
|
||||
if (active !== undefined) {
|
||||
yield* Ref.set(attached, undefined)
|
||||
yield* Scope.close(active.scope, Exit.void)
|
||||
}
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Ref.get(state)
|
||||
const failure = current.failure ?? controllerError("shutdown", "LLM controller is closed")
|
||||
yield* Ref.set(state, LlmState.close(current, failure))
|
||||
yield* failCompletions(current.sendCompletions, failure)
|
||||
yield* notify
|
||||
}),
|
||||
)
|
||||
yield* FiberSet.clear(tasks)
|
||||
})
|
||||
|
||||
if (initialBackend !== undefined) yield* attach(initialBackend)
|
||||
|
||||
return {
|
||||
attach,
|
||||
queue,
|
||||
send,
|
||||
serve,
|
||||
title,
|
||||
settle,
|
||||
shutdown,
|
||||
failure: Deferred.await(failureSignal),
|
||||
} satisfies Controller
|
||||
})
|
||||
|
||||
/** Builds a response stream from output values. */
|
||||
export const response = (...output: ReadonlyArray<Llm.Output>): LlmResponder.Response => Stream.fromIterable(output)
|
||||
|
||||
function isBackendConnection(value: BackendConnection | Options | undefined): value is BackendConnection {
|
||||
return value !== undefined && "rpc" in value
|
||||
}
|
||||
|
||||
export * as LlmController from "./llm-controller.js"
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as Cause from "effect/Cause"
|
||||
import * as Option from "effect/Option"
|
||||
import * as Schema from "effect/Schema"
|
||||
|
||||
/** Rejection of an LLM control call made in an incompatible response mode. */
|
||||
export class LlmModeError extends Schema.TaggedErrorClass<LlmModeError>()("LlmModeError", {
|
||||
operation: Schema.Literals(["queue", "send", "serve", "title"]),
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
/** Failure of an LLM controller operation or its backend connection. */
|
||||
export class LlmControllerError extends Schema.TaggedErrorClass<LlmControllerError>()("LlmControllerError", {
|
||||
operation: Schema.String,
|
||||
requestId: Schema.optionalKey(Schema.String),
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
/** Settlement ended with unused responses or unexpected requests. */
|
||||
export class LlmSettlementError extends Schema.TaggedErrorClass<LlmSettlementError>()("LlmSettlementError", {
|
||||
unusedResponses: Schema.Number,
|
||||
unexpectedRequests: Schema.Number,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
/** Coerces any cause into an `LlmControllerError`, preserving existing ones. */
|
||||
export const controllerError = (operation: string, cause: unknown, requestId?: string): LlmControllerError => {
|
||||
if (cause instanceof LlmControllerError) return cause
|
||||
return new LlmControllerError({
|
||||
operation,
|
||||
...(requestId === undefined ? {} : { requestId }),
|
||||
message: cause instanceof Error ? cause.message : String(cause),
|
||||
})
|
||||
}
|
||||
|
||||
/** Extracts the most useful failure from a cause and coerces it. */
|
||||
export const causeError = (operation: string, cause: Cause.Cause<unknown>, requestId?: string): LlmControllerError => {
|
||||
const failure = Cause.findErrorOption(cause)
|
||||
return Option.isSome(failure)
|
||||
? controllerError(operation, failure.value, requestId)
|
||||
: controllerError(operation, Cause.squash(cause), requestId)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as Exit from "effect/Exit"
|
||||
import * as Schema from "effect/Schema"
|
||||
import * as Stream from "effect/Stream"
|
||||
import * as Llm from "../llm/index.js"
|
||||
import { chunkText } from "../llm/internal.js"
|
||||
import { supportsCapability, type BackendConnection } from "../simulation/connector.js"
|
||||
import { controllerError, LlmControllerError } from "./llm-errors.js"
|
||||
|
||||
/**
|
||||
* The wire layer of the LLM controller: plays one `Response` stream onto a
|
||||
* backend connection as `llm.chunk` / `llm.finish` / `llm.disconnect` RPCs,
|
||||
* chunking text with optional pacing and guaranteeing a terminal event.
|
||||
*/
|
||||
|
||||
/** A stream of simulated model output for one LLM exchange. */
|
||||
export type Response = Stream.Stream<Llm.Output, LlmControllerError>
|
||||
|
||||
export interface Options {
|
||||
/** Per-backend-RPC timeout in milliseconds. */
|
||||
readonly requestTimeout: number
|
||||
}
|
||||
|
||||
export interface Responder {
|
||||
/** Plays one response for one exchange, guaranteeing a terminal event. */
|
||||
readonly respond: (
|
||||
backend: BackendConnection,
|
||||
requestId: string,
|
||||
output: Response,
|
||||
) => Effect.Effect<void, LlmControllerError>
|
||||
}
|
||||
|
||||
class InvocationTerminated extends Schema.TaggedErrorClass<InvocationTerminated>()("InvocationTerminated", {}) {}
|
||||
|
||||
const decodeOutput = Schema.decodeUnknownEffect(Llm.Output)
|
||||
|
||||
export const make = ({ requestTimeout }: Options): Responder => {
|
||||
const call = <A, E>(
|
||||
backend: BackendConnection,
|
||||
operation: string,
|
||||
requestId: string,
|
||||
effect: Effect.Effect<A, E>,
|
||||
): Effect.Effect<A, LlmControllerError | InvocationTerminated> =>
|
||||
Effect.timeoutOrElse(effect, {
|
||||
duration: requestTimeout,
|
||||
orElse: () =>
|
||||
Effect.fail(
|
||||
new LlmControllerError({
|
||||
operation,
|
||||
requestId,
|
||||
message: `${operation} timed out after ${requestTimeout}ms`,
|
||||
}),
|
||||
),
|
||||
}).pipe(
|
||||
Effect.catch((error) => classifyWriteFailure(backend, requestId, error)),
|
||||
Effect.mapError((cause) =>
|
||||
cause instanceof InvocationTerminated ? cause : controllerError(operation, cause, requestId),
|
||||
),
|
||||
)
|
||||
|
||||
const classifyWriteFailure = <E>(
|
||||
backend: BackendConnection,
|
||||
requestId: string,
|
||||
error: E,
|
||||
): Effect.Effect<never, E | InvocationTerminated> =>
|
||||
Effect.gen(function* () {
|
||||
if (!supportsCapability(backend.compatibility, "llm.pending")) return yield* Effect.fail(error)
|
||||
const pending = yield* Effect.exit(backend.rpc["llm.pending"]().pipe(Effect.timeout(requestTimeout)))
|
||||
if (Exit.isFailure(pending)) return yield* Effect.fail(error)
|
||||
if (pending.value.invocations.some((invocation) => invocation.id === requestId)) return yield* Effect.fail(error)
|
||||
return yield* Effect.fail(new InvocationTerminated())
|
||||
})
|
||||
|
||||
const streamDelta = Effect.fn("LlmResponder.streamDelta")(function* (
|
||||
backend: BackendConnection,
|
||||
id: string,
|
||||
type: "textDelta" | "reasoningDelta",
|
||||
text: string,
|
||||
options: Llm.StreamOptions | undefined,
|
||||
) {
|
||||
const delay = options?.delay ?? 2
|
||||
const chunkSize = options?.chunkSize ?? 15
|
||||
const chunks = [...chunkText(text, chunkSize)]
|
||||
for (let index = 0; index < chunks.length; index++) {
|
||||
const chunk = chunks[index]
|
||||
if (chunk === undefined) continue
|
||||
yield* call(backend, "llm.chunk", id, backend.rpc["llm.chunk"]({ id, items: [{ type, text: chunk }] }))
|
||||
if (index < chunks.length - 1 && delay > 0) yield* Effect.sleep(delay)
|
||||
}
|
||||
})
|
||||
|
||||
const streamToolCall = Effect.fn("LlmResponder.streamToolCall")(function* (
|
||||
backend: BackendConnection,
|
||||
requestId: string,
|
||||
toolCall: Llm.ToolCall,
|
||||
) {
|
||||
const delay = toolCall.options?.delay ?? 2
|
||||
const chunkSize = toolCall.options?.chunkSize ?? 15
|
||||
const chunks = [...chunkText(JSON.stringify(toolCall.input), chunkSize)]
|
||||
const providerNeutral = supportsCapability(backend.compatibility, "llm.tool-input-delta")
|
||||
if (providerNeutral)
|
||||
yield* call(
|
||||
backend,
|
||||
"llm.chunk",
|
||||
requestId,
|
||||
backend.rpc["llm.chunk"]({
|
||||
id: requestId,
|
||||
items: [
|
||||
{
|
||||
type: "toolInputStart",
|
||||
index: toolCall.index,
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
for (let index = 0; index < chunks.length; index++) {
|
||||
const text = chunks[index]
|
||||
if (text === undefined) continue
|
||||
const item = providerNeutral
|
||||
? { type: "toolInputDelta" as const, index: toolCall.index, text }
|
||||
: {
|
||||
type: "raw" as const,
|
||||
chunk: {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
index === 0
|
||||
? {
|
||||
index: toolCall.index,
|
||||
id: toolCall.id,
|
||||
function: { name: toolCall.name, arguments: text },
|
||||
}
|
||||
: {
|
||||
index: toolCall.index,
|
||||
function: { arguments: text },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
yield* call(
|
||||
backend,
|
||||
"llm.chunk",
|
||||
requestId,
|
||||
backend.rpc["llm.chunk"]({
|
||||
id: requestId,
|
||||
items: [item],
|
||||
}),
|
||||
)
|
||||
if (index < chunks.length - 1 && delay > 0) yield* Effect.sleep(delay)
|
||||
}
|
||||
})
|
||||
|
||||
const respond = Effect.fn("LlmResponder.respond")(function* (
|
||||
backend: BackendConnection,
|
||||
requestId: string,
|
||||
output: Response,
|
||||
) {
|
||||
let terminal = false
|
||||
yield* output.pipe(
|
||||
Stream.mapEffect((value) => decodeOutput(value)),
|
||||
Stream.runForEach((item) => {
|
||||
if (terminal)
|
||||
return Effect.fail(
|
||||
new LlmControllerError({
|
||||
operation: "respond",
|
||||
requestId,
|
||||
message: `LLM response ${requestId} emitted output after its terminal event`,
|
||||
}),
|
||||
)
|
||||
switch (item.type) {
|
||||
case "finish":
|
||||
terminal = true
|
||||
return call(
|
||||
backend,
|
||||
"llm.finish",
|
||||
requestId,
|
||||
backend.rpc["llm.finish"]({
|
||||
id: requestId,
|
||||
...(item.reason === undefined ? {} : { reason: item.reason }),
|
||||
}),
|
||||
).pipe(Effect.asVoid)
|
||||
case "disconnect":
|
||||
terminal = true
|
||||
return call(backend, "llm.disconnect", requestId, backend.rpc["llm.disconnect"]({ id: requestId })).pipe(
|
||||
Effect.asVoid,
|
||||
)
|
||||
case "text":
|
||||
return streamDelta(backend, requestId, "textDelta", item.text, item.options)
|
||||
case "reasoning":
|
||||
return streamDelta(backend, requestId, "reasoningDelta", item.text, item.options)
|
||||
case "pause":
|
||||
return item.milliseconds === 0 ? Effect.void : Effect.sleep(item.milliseconds)
|
||||
case "toolCall": {
|
||||
if (item.options !== undefined) return streamToolCall(backend, requestId, item)
|
||||
const { options: _, ...toolCall } = item
|
||||
return call(
|
||||
backend,
|
||||
"llm.chunk",
|
||||
requestId,
|
||||
backend.rpc["llm.chunk"]({
|
||||
id: requestId,
|
||||
items: [toolCall],
|
||||
}),
|
||||
).pipe(Effect.asVoid)
|
||||
}
|
||||
case "raw":
|
||||
return call(
|
||||
backend,
|
||||
"llm.chunk",
|
||||
requestId,
|
||||
backend.rpc["llm.chunk"]({ id: requestId, items: [item] }),
|
||||
).pipe(Effect.asVoid)
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.catchTag("InvocationTerminated", () => {
|
||||
terminal = true
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.mapError((cause) => controllerError("respond", cause, requestId)),
|
||||
)
|
||||
if (!terminal)
|
||||
yield* call(backend, "llm.finish", requestId, backend.rpc["llm.finish"]({ id: requestId, reason: "stop" })).pipe(
|
||||
Effect.catchTag("InvocationTerminated", () => Effect.void),
|
||||
)
|
||||
})
|
||||
|
||||
return { respond }
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import * as Data from "effect/Data"
|
||||
import type * as Deferred from "effect/Deferred"
|
||||
import * as Effect from "effect/Effect"
|
||||
import type { Backend } from "../client/protocol.js"
|
||||
import type * as Llm from "../llm/index.js"
|
||||
import type { BackendConnection } from "../simulation/connector.js"
|
||||
import { controllerError, LlmControllerError, LlmModeError, LlmSettlementError } from "./llm-errors.js"
|
||||
import type { Response } from "./llm-responder.js"
|
||||
|
||||
/**
|
||||
* Pure state and transitions for the LLM controller. The shell in
|
||||
* `llm-controller.ts` owns all concurrency: it holds the lock, creates
|
||||
* completions, runs jobs, and resolves deferreds. Completions appear here
|
||||
* only as opaque tokens tracked for membership — nothing in this module
|
||||
* awaits or completes them.
|
||||
*/
|
||||
|
||||
/** Opaque token for one in-flight job, resolved by the shell. */
|
||||
export type Completion = Deferred.Deferred<void, LlmControllerError>
|
||||
|
||||
export type ServeHandler = (request: Backend.ProviderInvocation, index: number) => Response
|
||||
|
||||
export type TitleHandler = (
|
||||
request: Backend.ProviderInvocation,
|
||||
index: number,
|
||||
) => Effect.Effect<string, LlmControllerError>
|
||||
|
||||
export interface QueuedResponse {
|
||||
readonly output: ReadonlyArray<Llm.Output>
|
||||
readonly completed?: Completion
|
||||
}
|
||||
|
||||
export interface AttachedRequest {
|
||||
readonly request: Backend.ProviderInvocation
|
||||
readonly backend: BackendConnection
|
||||
}
|
||||
|
||||
/** How the controller answers normal (non-title) requests. */
|
||||
export type Mode = Data.TaggedEnum<{
|
||||
Unset: {}
|
||||
Queue: {}
|
||||
Serve: { readonly handler: ServeHandler }
|
||||
}>
|
||||
export const Mode = Data.taggedEnum<Mode>()
|
||||
|
||||
export interface State {
|
||||
readonly mode: Mode
|
||||
readonly titleHandler: TitleHandler
|
||||
readonly titleConfigured: boolean
|
||||
readonly requests: ReadonlyArray<AttachedRequest>
|
||||
readonly responses: ReadonlyArray<QueuedResponse>
|
||||
readonly activeNormal: ReadonlyArray<Completion>
|
||||
readonly activeTitles: ReadonlyArray<Completion>
|
||||
readonly sendCompletions: ReadonlyArray<Completion>
|
||||
readonly requestIndex: number
|
||||
readonly titleIndex: number
|
||||
readonly failure: LlmControllerError | undefined
|
||||
readonly settling: boolean
|
||||
readonly settled: boolean
|
||||
}
|
||||
|
||||
export const initial: State = {
|
||||
mode: Mode.Unset(),
|
||||
titleHandler: () => Effect.succeed("OpenCode Drive"),
|
||||
titleConfigured: false,
|
||||
requests: [],
|
||||
responses: [],
|
||||
activeNormal: [],
|
||||
activeTitles: [],
|
||||
sendCompletions: [],
|
||||
requestIndex: 0,
|
||||
titleIndex: 0,
|
||||
failure: undefined,
|
||||
settling: false,
|
||||
settled: false,
|
||||
}
|
||||
|
||||
// ─── Guards ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Why a control call (queue/send/serve/title) was rejected. */
|
||||
export type RejectionError = LlmModeError | LlmControllerError
|
||||
|
||||
const rejectWhileSettling = (state: State, operation: string) =>
|
||||
state.settling || state.settled ? controllerError(operation, "LLM controller is settling") : undefined
|
||||
|
||||
/** Why a queue/send call must be rejected, or undefined to proceed. */
|
||||
export const rejectEnqueue = (state: State, operation: "queue" | "send"): RejectionError | undefined => {
|
||||
if (state.failure !== undefined) return state.failure
|
||||
if (Mode.$is("Serve")(state.mode))
|
||||
return new LlmModeError({
|
||||
operation,
|
||||
message: `llm.${operation} cannot be used after llm.serve`,
|
||||
})
|
||||
return rejectWhileSettling(state, operation)
|
||||
}
|
||||
|
||||
/** Why a serve call must be rejected, or undefined to proceed. */
|
||||
export const rejectServe = (state: State): RejectionError | undefined => {
|
||||
if (state.failure !== undefined) return state.failure
|
||||
if (!Mode.$is("Unset")(state.mode))
|
||||
return new LlmModeError({
|
||||
operation: "serve",
|
||||
message: "llm.serve must be the only LLM response mode",
|
||||
})
|
||||
return rejectWhileSettling(state, "serve")
|
||||
}
|
||||
|
||||
/** Why a title call must be rejected, or undefined to proceed. */
|
||||
export const rejectTitle = (state: State): RejectionError | undefined => {
|
||||
if (state.failure !== undefined) return state.failure
|
||||
if (state.titleConfigured)
|
||||
return new LlmModeError({
|
||||
operation: "title",
|
||||
message: "llm.title may only be configured once",
|
||||
})
|
||||
return rejectWhileSettling(state, "title")
|
||||
}
|
||||
|
||||
// ─── Transitions ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const enqueue = (state: State, response: QueuedResponse): State => ({
|
||||
...state,
|
||||
mode: Mode.Queue(),
|
||||
responses: [...state.responses, response],
|
||||
sendCompletions:
|
||||
response.completed === undefined ? state.sendCompletions : [...state.sendCompletions, response.completed],
|
||||
})
|
||||
|
||||
export const serve = (state: State, handler: ServeHandler): State => ({
|
||||
...state,
|
||||
mode: Mode.Serve({ handler }),
|
||||
})
|
||||
|
||||
export const configureTitle = (state: State, handler: TitleHandler): State => ({
|
||||
...state,
|
||||
titleConfigured: true,
|
||||
titleHandler: handler,
|
||||
})
|
||||
|
||||
export const pushRequest = (state: State, request: AttachedRequest): State => ({
|
||||
...state,
|
||||
requests: [...state.requests, request],
|
||||
})
|
||||
|
||||
/** Withdraws an interrupted send before it was matched to a request. */
|
||||
export const abandonSend = (state: State, completed: Completion): State => ({
|
||||
...state,
|
||||
responses: state.responses.filter((response) => response.completed !== completed),
|
||||
sendCompletions: state.sendCompletions.filter((candidate) => candidate !== completed),
|
||||
})
|
||||
|
||||
/** Records the first failure; later failures preserve the original. */
|
||||
export const recordFailure = (
|
||||
state: State,
|
||||
error: LlmControllerError,
|
||||
): readonly [State, { readonly failure: LlmControllerError; readonly isFirst: boolean }] => {
|
||||
const failure = state.failure ?? error
|
||||
const isFirst = state.failure === undefined
|
||||
return [isFirst ? { ...state, failure } : state, { failure, isFirst }]
|
||||
}
|
||||
|
||||
// ─── Normal jobs ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Where a normal job's response comes from. */
|
||||
export type NormalSource = Data.TaggedEnum<{
|
||||
Queued: { readonly response: QueuedResponse }
|
||||
Served: { readonly handler: ServeHandler }
|
||||
}>
|
||||
export const NormalSource = Data.taggedEnum<NormalSource>()
|
||||
|
||||
/** A runnable normal job selected by {@link nextNormal}. */
|
||||
export interface NormalStart {
|
||||
readonly request: AttachedRequest
|
||||
readonly index: number
|
||||
readonly source: NormalSource
|
||||
}
|
||||
|
||||
/** Selects the next runnable normal job, or undefined when nothing can run. */
|
||||
export const nextNormal = (state: State): NormalStart | undefined => {
|
||||
if (state.failure !== undefined) return undefined
|
||||
const request = state.requests[0]
|
||||
if (request === undefined) return undefined
|
||||
if (Mode.$is("Serve")(state.mode))
|
||||
return {
|
||||
request,
|
||||
index: state.requestIndex,
|
||||
source: NormalSource.Served({ handler: state.mode.handler }),
|
||||
}
|
||||
const response = state.responses[0]
|
||||
if (response === undefined) return undefined
|
||||
return {
|
||||
request,
|
||||
index: state.requestIndex,
|
||||
source: NormalSource.Queued({ response }),
|
||||
}
|
||||
}
|
||||
|
||||
/** Commits a selected job: consumes its inputs and tracks its completion. */
|
||||
export const startNormal = (state: State, start: NormalStart, completion: Completion): State => ({
|
||||
...state,
|
||||
requests: state.requests.slice(1),
|
||||
responses: NormalSource.$is("Queued")(start.source) ? state.responses.slice(1) : state.responses,
|
||||
activeNormal: [...state.activeNormal, completion],
|
||||
requestIndex: state.requestIndex + 1,
|
||||
})
|
||||
|
||||
/** Untracks a finished normal job and its optional send completion. */
|
||||
export const finishNormal = (state: State, completion: Completion, sendCompletion: Completion | undefined): State => ({
|
||||
...state,
|
||||
activeNormal: state.activeNormal.filter((active) => active !== completion),
|
||||
sendCompletions:
|
||||
sendCompletion === undefined
|
||||
? state.sendCompletions
|
||||
: state.sendCompletions.filter((active) => active !== sendCompletion),
|
||||
})
|
||||
|
||||
// ─── Title jobs ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** A title job selected by {@link startTitle}. */
|
||||
export interface TitleStart {
|
||||
readonly handler: TitleHandler
|
||||
readonly index: number
|
||||
/** Normal jobs the title must wait for before responding. */
|
||||
readonly awaiting: ReadonlyArray<Completion>
|
||||
}
|
||||
|
||||
/** Tracks a title job; titles run outside normal request sequencing. */
|
||||
export const startTitle = (state: State, completion: Completion): readonly [State, TitleStart] => [
|
||||
{
|
||||
...state,
|
||||
activeTitles: [...state.activeTitles, completion],
|
||||
titleIndex: state.titleIndex + 1,
|
||||
},
|
||||
{
|
||||
handler: state.titleHandler,
|
||||
index: state.titleIndex,
|
||||
awaiting: state.activeNormal,
|
||||
},
|
||||
]
|
||||
|
||||
export const finishTitle = (state: State, completion: Completion): State => ({
|
||||
...state,
|
||||
activeTitles: state.activeTitles.filter((active) => active !== completion),
|
||||
})
|
||||
|
||||
// ─── Settlement ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type Settlement = Data.TaggedEnum<{
|
||||
/** All work has drained; the controller is settled. */
|
||||
Done: {}
|
||||
/** Queued or active work remains; wait for the next change. */
|
||||
Wait: {}
|
||||
Fail: { readonly error: LlmControllerError | LlmSettlementError }
|
||||
}>
|
||||
export const Settlement = Data.taggedEnum<Settlement>()
|
||||
|
||||
/** Decides whether settlement is complete, failed, or must keep waiting. */
|
||||
export const inspectSettlement = (state: State): Settlement => {
|
||||
if (state.failure !== undefined) return Settlement.Fail({ error: state.failure })
|
||||
if (Mode.$is("Queue")(state.mode) && state.requests.length > 0 && state.responses.length === 0)
|
||||
return Settlement.Fail({
|
||||
error: new LlmSettlementError({
|
||||
unusedResponses: 0,
|
||||
unexpectedRequests: state.requests.length,
|
||||
message: `received ${state.requests.length} unexpected LLM request(s)`,
|
||||
}),
|
||||
})
|
||||
if (state.responses.length > 0 || state.activeNormal.length > 0 || state.activeTitles.length > 0)
|
||||
return Settlement.Wait()
|
||||
return Settlement.Done()
|
||||
}
|
||||
|
||||
/** The error reported when settlement times out. */
|
||||
export const settlementTimeoutError = (state: State): LlmSettlementError =>
|
||||
new LlmSettlementError({
|
||||
unusedResponses: state.responses.length,
|
||||
unexpectedRequests: state.requests.length,
|
||||
message:
|
||||
state.responses.length > 0
|
||||
? `timed out with ${state.responses.length} unused LLM response(s)`
|
||||
: "timed out waiting for active LLM responses",
|
||||
})
|
||||
|
||||
export const beginSettling = (state: State): State => (state.settling ? state : { ...state, settling: true })
|
||||
|
||||
export const markSettled = (state: State): State => ({
|
||||
...state,
|
||||
settled: true,
|
||||
})
|
||||
|
||||
/** Terminal shutdown: pending work is discarded and future calls fail. */
|
||||
export const close = (state: State, failure: LlmControllerError): State => ({
|
||||
...state,
|
||||
requests: [],
|
||||
responses: [],
|
||||
failure,
|
||||
settling: true,
|
||||
settled: true,
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import type * as OpenCodeServer from "./server.js"
|
||||
|
||||
/** Live control over the simulated model shared by every connected TUI. */
|
||||
export interface Llm {
|
||||
readonly queue: OpenCodeServer.Server["llm"]["queue"]
|
||||
readonly send: OpenCodeServer.Server["llm"]["send"]
|
||||
readonly serve: OpenCodeServer.Server["llm"]["serve"]
|
||||
readonly title: OpenCodeServer.Server["llm"]["title"]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { join } from "node:path"
|
||||
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
|
||||
import { OpenCode as OpenCodeService, type OpenCodeClient } from "@opencode-ai/client/effect"
|
||||
import * as Service from "@opencode-ai/client/effect/service"
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as FileSystem from "effect/FileSystem"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { error, type OpenCodeDriverError } from "./error.js"
|
||||
|
||||
export type OpenCode = OpenCodeClient
|
||||
|
||||
const makeWithServices = Effect.fn("OpenCode.make")(function* (artifacts: string) {
|
||||
const state = join(artifacts, "home", ".local", "state", "opencode")
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const discovered = yield* fs.readDirectory(state).pipe(Effect.catch(() => Effect.succeed([])))
|
||||
const names = [
|
||||
"service-local.json",
|
||||
"service.json",
|
||||
...discovered
|
||||
.filter((name) => /^service-[^.]+\.json$/.test(name))
|
||||
.sort()
|
||||
.filter((name) => name !== "service-local.json"),
|
||||
]
|
||||
let endpoint: Service.Endpoint | undefined
|
||||
for (const name of names) {
|
||||
endpoint = yield* Service.discover({ file: join(state, name) })
|
||||
if (endpoint !== undefined) break
|
||||
}
|
||||
if (endpoint === undefined)
|
||||
return yield* Effect.fail(error("opencode.connect", "OpenCode service registration was not found"))
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const base = yield* HttpClient.HttpClient
|
||||
const located = base.pipe(
|
||||
HttpClient.mapRequest(
|
||||
HttpClientRequest.setHeader("x-opencode-directory", encodeURIComponent(join(artifacts, "files"))),
|
||||
),
|
||||
)
|
||||
const http =
|
||||
endpoint.auth === undefined
|
||||
? located
|
||||
: located.pipe(
|
||||
HttpClient.mapRequest(HttpClientRequest.basicAuth(endpoint.auth.username, endpoint.auth.password)),
|
||||
)
|
||||
return yield* OpenCodeService.make({ baseUrl: endpoint.url }).pipe(
|
||||
Effect.provideService(HttpClient.HttpClient, http),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(FetchHttpClient.layer),
|
||||
Effect.mapError((cause) => error("opencode.connect", cause)),
|
||||
)
|
||||
})
|
||||
|
||||
export const make = (artifacts: string): Effect.Effect<OpenCode, OpenCodeDriverError> =>
|
||||
makeWithServices(artifacts).pipe(Effect.provide(NodeFileSystem.layer))
|
||||
@@ -0,0 +1,121 @@
|
||||
import * as Cause from "effect/Cause"
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as Exit from "effect/Exit"
|
||||
import * as OpenCodeInstance from "../instance/runtime.js"
|
||||
import * as SimulationConnector from "../simulation/connector.js"
|
||||
import * as OpenCodeTui from "./client.js"
|
||||
import { error, type OpenCodeDriverError } from "./error.js"
|
||||
import type { Driver, Llm } from "./index.js"
|
||||
import type { LlmControllerError, LlmSettlementError } from "./llm-controller.js"
|
||||
import * as OpenCodeServer from "./server.js"
|
||||
import * as SharedEffect from "./shared.js"
|
||||
import type * as OpenCodeUi from "./ui.js"
|
||||
import { decodeRunReport } from "./report.js"
|
||||
|
||||
export interface Options {
|
||||
readonly visible?: boolean
|
||||
readonly tui?: OpenCodeTui.TuiOptions
|
||||
readonly launch?: "automatic" | "manual"
|
||||
readonly tuiName?: string
|
||||
readonly artifactsRetained?: boolean
|
||||
readonly compatibility?: SimulationConnector.CompatibilityPolicy
|
||||
}
|
||||
|
||||
export interface Prepared {
|
||||
readonly driver: Driver | undefined
|
||||
readonly primary: OpenCodeTui.Tui | undefined
|
||||
readonly llm: Llm
|
||||
readonly tools: Driver["tools"]
|
||||
readonly tuis: OpenCodeTui.Tuis
|
||||
readonly server: Pick<OpenCodeServer.Server, "launch" | "kill">
|
||||
readonly artifacts: string
|
||||
readonly settle: Driver["settle"]
|
||||
readonly failure: Effect.Effect<never, LlmControllerError | OpenCodeDriverError>
|
||||
readonly unexpectedTuiExit: OpenCodeTui.Control["unexpectedExit"]
|
||||
}
|
||||
|
||||
export const makeWithServices = Effect.fn("OpenCodeDriver.makePreparedWithServices")(function* (
|
||||
instance: OpenCodeInstance.Instance,
|
||||
options: Options,
|
||||
) {
|
||||
const server = yield* OpenCodeServer.make({
|
||||
instance,
|
||||
target: {
|
||||
visible: options.visible,
|
||||
compatibility: options.compatibility,
|
||||
},
|
||||
})
|
||||
const opencode = (options.launch ?? "automatic") === "automatic" ? yield* server.launch() : undefined
|
||||
const primary =
|
||||
(options.launch ?? "automatic") === "automatic"
|
||||
? options.tuiName === undefined
|
||||
? yield* server.tuis.launch(options.tui)
|
||||
: yield* server.tuis.launch(options.tuiName, options.tui)
|
||||
: undefined
|
||||
const complete = (tuis: Effect.Effect<ReadonlyArray<string>, OpenCodeDriverError | OpenCodeUi.OperationError>) =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* Effect.exit(server.llm.settle())
|
||||
const tools = yield* Effect.exit(
|
||||
server.settleTools.pipe(Effect.mapError((cause) => error("tools.settle", cause))),
|
||||
)
|
||||
const shutdown = yield* Effect.exit(server.llm.shutdown())
|
||||
const tuiExit = yield* Effect.exit(tuis)
|
||||
let failure:
|
||||
| Cause.Cause<LlmControllerError | LlmSettlementError | OpenCodeDriverError | OpenCodeUi.OperationError>
|
||||
| undefined
|
||||
if (Exit.isFailure(llm)) failure = llm.cause
|
||||
if (Exit.isFailure(tools)) failure = failure === undefined ? tools.cause : Cause.combine(failure, tools.cause)
|
||||
if (Exit.isFailure(shutdown))
|
||||
failure = failure === undefined ? shutdown.cause : Cause.combine(failure, shutdown.cause)
|
||||
if (Exit.isFailure(tuiExit))
|
||||
failure = failure === undefined ? tuiExit.cause : Cause.combine(failure, tuiExit.cause)
|
||||
if (failure !== undefined) return yield* Effect.failCause(failure)
|
||||
const compatibility = [...(yield* server.compatibility), ...(yield* server.tuis.compatibility)]
|
||||
const recordings = Exit.isSuccess(tuiExit) ? tuiExit.value : []
|
||||
const report = yield* decodeRunReport({
|
||||
artifacts: instance.artifacts,
|
||||
retained: options.artifactsRetained ?? true,
|
||||
recordings,
|
||||
compatibility,
|
||||
}).pipe(Effect.mapError((cause) => error("report.make", cause)))
|
||||
return report
|
||||
})
|
||||
const settle = yield* SharedEffect.make(complete(server.tuis.settle()))
|
||||
yield* Effect.addFinalizer(() => server.llm.shutdown())
|
||||
const llm: Llm = server.llm
|
||||
const driver: Driver | undefined =
|
||||
primary === undefined || opencode === undefined
|
||||
? undefined
|
||||
: {
|
||||
opencode,
|
||||
tui: primary,
|
||||
ui: primary.ui,
|
||||
llm,
|
||||
tools: server.tools,
|
||||
tuis: server.tuis,
|
||||
artifacts: instance.artifacts,
|
||||
settle: () => settle,
|
||||
}
|
||||
return {
|
||||
driver,
|
||||
primary,
|
||||
llm,
|
||||
tools: server.tools,
|
||||
tuis: server.tuis,
|
||||
server,
|
||||
artifacts: instance.artifacts,
|
||||
settle: () => settle,
|
||||
failure: Effect.raceFirst(
|
||||
server.failure,
|
||||
server.tuis.unexpectedExit.pipe(
|
||||
Effect.flatMap(({ name, status }) =>
|
||||
Effect.fail(error("tui.exit", `OpenCode TUI "${name}" exited with status ${status}`)),
|
||||
),
|
||||
),
|
||||
),
|
||||
unexpectedTuiExit: server.tuis.unexpectedExit,
|
||||
} satisfies Prepared
|
||||
})
|
||||
|
||||
export const make = (instance: OpenCodeInstance.Instance, options: Options) =>
|
||||
makeWithServices(instance, options).pipe(Effect.provide(SimulationConnector.layer))
|
||||
@@ -0,0 +1,44 @@
|
||||
import { rm } from "node:fs/promises"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { initializeInstance, prepareInstanceProject } from "../instance/instance.js"
|
||||
import type { OpenCodeConfig, OpenCodeTuiConfig, Project as ProjectDefinition, Setup } from "../project.js"
|
||||
import { error } from "./error.js"
|
||||
|
||||
export interface Options {
|
||||
readonly project?: ProjectDefinition
|
||||
readonly config?: OpenCodeConfig
|
||||
readonly tui?: OpenCodeTuiConfig
|
||||
readonly setup?: Setup
|
||||
/** Retain the isolated artifact directory after the scope closes. */
|
||||
readonly keepArtifacts?: boolean
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
readonly artifacts: string
|
||||
}
|
||||
|
||||
export const make = Effect.fn("OpenCodeProject.make")(function* (options: Options = {}) {
|
||||
const artifacts = yield* Effect.acquireRelease(
|
||||
Effect.tryPromise({
|
||||
try: () => initializeInstance(),
|
||||
catch: (cause) => error("project.initialize", cause),
|
||||
}),
|
||||
(directory) =>
|
||||
options.keepArtifacts
|
||||
? Effect.void
|
||||
: Effect.tryPromise({
|
||||
try: () => rm(directory, { recursive: true, force: true }),
|
||||
catch: () => undefined,
|
||||
}).pipe(Effect.ignore),
|
||||
)
|
||||
yield* prepareInstanceProject({
|
||||
artifacts,
|
||||
project: options.project,
|
||||
config: options.config,
|
||||
tui: options.tui,
|
||||
setup: options.setup,
|
||||
}).pipe(Effect.mapError((cause) => error("project.prepare", cause)))
|
||||
return { artifacts }
|
||||
})
|
||||
|
||||
export * as OpenCodeProject from "./project.js"
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as Schema from "effect/Schema"
|
||||
import { EndpointCompatibility } from "../simulation/connector.js"
|
||||
|
||||
const absolutePathCheck = Schema.makeFilter<string>((path) => isAbsolutePath(path), {
|
||||
expected: "an absolute POSIX or Windows path without NUL bytes",
|
||||
})
|
||||
|
||||
/** A fully rooted POSIX or Windows filesystem path. */
|
||||
export const AbsolutePath = Schema.String.check(absolutePathCheck).pipe(Schema.brand("OpenCodeDrive.AbsolutePath"))
|
||||
export type AbsolutePath = typeof AbsolutePath.Type
|
||||
|
||||
/** The compact evidence returned after a Drive run settles. */
|
||||
export const RunReport = Schema.Struct({
|
||||
artifacts: AbsolutePath,
|
||||
retained: Schema.Boolean,
|
||||
recordings: Schema.Array(AbsolutePath),
|
||||
compatibility: Schema.Array(EndpointCompatibility),
|
||||
})
|
||||
export interface RunReport extends Schema.Schema.Type<typeof RunReport> {}
|
||||
|
||||
export const decodeAbsolutePath = Schema.decodeUnknownEffect(AbsolutePath)
|
||||
export const decodeRunReport = Schema.decodeUnknownEffect(RunReport)
|
||||
|
||||
function isAbsolutePath(path: string): boolean {
|
||||
if (path.length === 0 || path.includes("\0")) return false
|
||||
if (path.startsWith("/")) return true
|
||||
if (/^[A-Za-z]:[\\/]/.test(path)) return true
|
||||
if (/^\\\\[^\\/]+[\\/][^\\/]+(?:[\\/]|$)/.test(path)) return true
|
||||
return /^\\\\[?.]\\(?:[A-Za-z]:\\|UNC\\[^\\]+\\[^\\]+(?:\\|$))/.test(path)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as Cause from "effect/Cause"
|
||||
import * as Deferred from "effect/Deferred"
|
||||
import * as Ref from "effect/Ref"
|
||||
import * as Semaphore from "effect/Semaphore"
|
||||
import * as Scope from "effect/Scope"
|
||||
import * as Exit from "effect/Exit"
|
||||
import { RpcClientError } from "effect/unstable/rpc"
|
||||
import * as OpenCodeInstance from "../instance/runtime.js"
|
||||
import * as SimulationConnector from "../simulation/connector.js"
|
||||
import * as OpenCodeTui from "./client.js"
|
||||
import * as OpenCodeSdk from "./opencode.js"
|
||||
import { error, type OpenCodeDriverError } from "./error.js"
|
||||
import * as LlmController from "./llm-controller.js"
|
||||
import * as ToolProducer from "../tool/producer.js"
|
||||
import type * as Tool from "../tool/index.js"
|
||||
import { LifecycleError } from "../tool/types.js"
|
||||
|
||||
export interface Target {
|
||||
readonly command?: ReadonlyArray<string>
|
||||
readonly dev?: string
|
||||
readonly env?: Readonly<Record<string, string>>
|
||||
readonly visible?: boolean
|
||||
readonly compatibility?: SimulationConnector.CompatibilityPolicy
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
readonly instance: OpenCodeInstance.Instance
|
||||
readonly target?: Target
|
||||
}
|
||||
|
||||
export interface Server {
|
||||
readonly llm: LlmController.Controller
|
||||
readonly tools: Tool.Controls
|
||||
readonly settleTools: ToolProducer.Controller["settle"]
|
||||
readonly tuis: OpenCodeTui.Control
|
||||
readonly launch: () => Effect.Effect<
|
||||
OpenCodeSdk.OpenCode,
|
||||
OpenCodeDriverError | LlmController.LlmControllerError | SimulationConnector.SimulationCompatibilityError
|
||||
>
|
||||
readonly kill: () => Effect.Effect<void, OpenCodeDriverError>
|
||||
readonly failure: Effect.Effect<never, OpenCodeDriverError | LlmController.LlmControllerError>
|
||||
readonly compatibility: Effect.Effect<ReadonlyArray<SimulationConnector.EndpointCompatibility>>
|
||||
}
|
||||
|
||||
export const make = Effect.fn("OpenCodeServer.make")(function* (options: Options) {
|
||||
const connector = yield* SimulationConnector.Service
|
||||
const target = options.target ?? {}
|
||||
const instance = options.instance
|
||||
const llm = yield* LlmController.make()
|
||||
const toolProducer = yield* ToolProducer.make(instance.toolNames)
|
||||
const tools: Tool.Controls = {
|
||||
...instance.tools,
|
||||
...toolProducer.controls,
|
||||
}
|
||||
const tuis = yield* OpenCodeTui.makeTuis(instance, target.visible ?? false, connector, target.compatibility)
|
||||
const parentScope = yield* Scope.Scope
|
||||
const generation = yield* Ref.make<
|
||||
| {
|
||||
readonly scope: Scope.Scope
|
||||
readonly attachment: LlmController.Attachment
|
||||
readonly process: import("../instance/process.js").Running
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
const unexpectedExit = yield* Deferred.make<never, OpenCodeDriverError>()
|
||||
const toolConnectionFailure = yield* Deferred.make<never, OpenCodeDriverError>()
|
||||
const lifecycle = yield* Semaphore.make(1)
|
||||
let compatibility: ReadonlyArray<SimulationConnector.EndpointCompatibility> = []
|
||||
|
||||
const launchGeneration = Effect.fn("OpenCodeServer.launch")(function* () {
|
||||
if ((yield* Ref.get(generation)) !== undefined)
|
||||
return yield* Effect.fail(error("server.launch", "the script server has already been launched"))
|
||||
const scope = yield* Scope.fork(parentScope)
|
||||
const launched = yield* instance.launchServer.pipe(
|
||||
Effect.mapError((cause) => error("server.launch", cause)),
|
||||
Effect.onError(() => Scope.close(scope, Exit.void)),
|
||||
)
|
||||
let llmAttachment: LlmController.Attachment | undefined
|
||||
const rollbackLaunch = Effect.suspend(() =>
|
||||
(llmAttachment?.detach() ?? Effect.void).pipe(
|
||||
Effect.andThen(toolProducer.endGeneration),
|
||||
Effect.andThen(Scope.close(scope, Exit.void)),
|
||||
Effect.andThen(instance.killServer.pipe(Effect.ignore)),
|
||||
),
|
||||
)
|
||||
const backend = yield* connector
|
||||
.backend(launched.endpoint, {
|
||||
compatibility: target.compatibility,
|
||||
})
|
||||
.pipe(
|
||||
Scope.provide(scope),
|
||||
Effect.mapError((cause) => error("server.connect", cause)),
|
||||
Effect.onError(() => rollbackLaunch),
|
||||
)
|
||||
const connectTools = Effect.fn("OpenCodeServer.connectTools")(function* () {
|
||||
const connectionScope = yield* Scope.fork(scope)
|
||||
const connection = yield* toolProducer
|
||||
.connectFrom(
|
||||
connector
|
||||
.backend(launched.endpoint, {
|
||||
attach: false,
|
||||
compatibility: target.compatibility,
|
||||
})
|
||||
.pipe(Scope.provide(connectionScope)),
|
||||
)
|
||||
.pipe(Effect.onError(() => Scope.close(connectionScope, Exit.void)))
|
||||
return { ...connection, scope: connectionScope }
|
||||
})
|
||||
const failToolConnection = (cause: unknown) =>
|
||||
toolProducer.shutdown.pipe(
|
||||
Effect.andThen(Deferred.fail(toolConnectionFailure, error("tools.connect", cause))),
|
||||
Effect.asVoid,
|
||||
)
|
||||
function reconnectTools(): Effect.Effect<void> {
|
||||
return connectTools().pipe(
|
||||
Effect.flatMap(superviseTools),
|
||||
Effect.catchIf(isRetryableToolConnectionError, () => Effect.sleep(25).pipe(Effect.andThen(reconnectTools()))),
|
||||
Effect.catchIf(isClosedToolConnectionError, () => Effect.void),
|
||||
Effect.catch(failToolConnection),
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
(cause) => failToolConnection(Cause.pretty(cause)),
|
||||
),
|
||||
)
|
||||
}
|
||||
function superviseTools(connection: Effect.Success<ReturnType<typeof connectTools>>): Effect.Effect<void> {
|
||||
return connection.backend.closed.pipe(
|
||||
Effect.ensuring(connection.attachment.detach().pipe(Effect.andThen(Scope.close(connection.scope, Exit.void)))),
|
||||
Effect.andThen(Effect.sleep(25)),
|
||||
Effect.andThen(reconnectTools()),
|
||||
)
|
||||
}
|
||||
const toolConnection = yield* connectTools().pipe(
|
||||
Effect.mapError((cause) => error("tools.connect", cause)),
|
||||
Effect.onError(() => rollbackLaunch),
|
||||
)
|
||||
yield* superviseTools(toolConnection).pipe(Effect.forkIn(scope))
|
||||
const attachment = yield* llm.attach(backend).pipe(Effect.onError(() => rollbackLaunch))
|
||||
llmAttachment = attachment
|
||||
const opencode = yield* OpenCodeSdk.make(instance.artifacts).pipe(Effect.onError(() => rollbackLaunch))
|
||||
const process = yield* instance.primary.pipe(
|
||||
Effect.mapError((cause) => error("server.launch", cause)),
|
||||
Effect.onError(() => rollbackLaunch),
|
||||
)
|
||||
yield* Ref.set(generation, { scope, attachment, process })
|
||||
compatibility = [...compatibility, backend.compatibility]
|
||||
yield* process.exitCode.pipe(
|
||||
Effect.tap(() => Effect.sleep(25)),
|
||||
Effect.flatMap((status) =>
|
||||
Ref.get(generation).pipe(
|
||||
Effect.flatMap((active) =>
|
||||
active?.process === process
|
||||
? toolProducer.endGeneration.pipe(
|
||||
Effect.andThen(
|
||||
Deferred.fail(unexpectedExit, error("server.exit", `OpenCode server exited with status ${status}`)),
|
||||
),
|
||||
Effect.asVoid,
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.catchCause(() => Effect.void),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
return opencode
|
||||
})
|
||||
|
||||
const killGeneration = Effect.fn("OpenCodeServer.kill")(function* () {
|
||||
const active = yield* Ref.get(generation)
|
||||
if (active === undefined) return yield* Effect.fail(error("server.kill", "the script server is not running"))
|
||||
yield* Ref.set(generation, undefined)
|
||||
yield* active.attachment.detach()
|
||||
yield* toolProducer.endGeneration
|
||||
yield* Scope.close(active.scope, Exit.void)
|
||||
const stopped = yield* Effect.exit(
|
||||
instance.killServer.pipe(Effect.mapError((cause) => error("server.kill", cause))),
|
||||
)
|
||||
if (Exit.isFailure(stopped)) return yield* Effect.failCause(stopped.cause)
|
||||
return undefined
|
||||
})
|
||||
const launch = () => lifecycle.withPermit(launchGeneration())
|
||||
const kill = () => lifecycle.withPermit(killGeneration())
|
||||
|
||||
return {
|
||||
llm,
|
||||
tools,
|
||||
settleTools: toolProducer.settle,
|
||||
tuis,
|
||||
launch,
|
||||
kill,
|
||||
failure: Effect.raceFirst(
|
||||
toolProducer.failure.pipe(Effect.mapError((cause) => error("tools", cause))),
|
||||
Effect.raceFirst(
|
||||
Deferred.await(toolConnectionFailure),
|
||||
Effect.raceFirst(llm.failure, Deferred.await(unexpectedExit)),
|
||||
),
|
||||
).pipe(Effect.tapError(() => toolProducer.endGeneration)),
|
||||
compatibility: Effect.sync(() => compatibility),
|
||||
} satisfies Server
|
||||
})
|
||||
|
||||
function isRetryableToolConnectionError(cause: unknown) {
|
||||
return (
|
||||
cause instanceof SimulationConnector.SimulationConnectionError ||
|
||||
(cause instanceof RpcClientError.RpcClientError && isTransientRpcClientError(cause)) ||
|
||||
(cause instanceof LifecycleError && cause.reason === "transport-interrupted")
|
||||
)
|
||||
}
|
||||
|
||||
function isClosedToolConnectionError(cause: unknown) {
|
||||
return cause instanceof LifecycleError && cause.reason === "controller-closed"
|
||||
}
|
||||
|
||||
function isTransientRpcClientError(error: RpcClientError.RpcClientError) {
|
||||
if (error.reason._tag !== "RpcClientDefect") return true
|
||||
const message = error.reason.message
|
||||
return (
|
||||
message.startsWith("cannot connect") ||
|
||||
message === "connection closed" ||
|
||||
message === "connection error" ||
|
||||
message === "connection is not open" ||
|
||||
message === "failed to send request"
|
||||
)
|
||||
}
|
||||
|
||||
export * as OpenCodeServer from "./server.js"
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as Deferred from "effect/Deferred"
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as Ref from "effect/Ref"
|
||||
import * as Scope from "effect/Scope"
|
||||
import * as Semaphore from "effect/Semaphore"
|
||||
|
||||
/** Starts a terminal operation once in its owner's scope, independently of callers. */
|
||||
export const make = Effect.fn("SharedEffect.make")(function* <A, E>(effect: Effect.Effect<A, E>) {
|
||||
const scope = yield* Scope.Scope
|
||||
const result = yield* Deferred.make<A, E>()
|
||||
const started = yield* Ref.make(false)
|
||||
const lock = yield* Semaphore.make(1)
|
||||
|
||||
return Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (yield* Ref.get(started)) return
|
||||
yield* Ref.set(started, true)
|
||||
yield* Deferred.complete(result, effect).pipe(Effect.asVoid, Effect.forkIn(scope, { uninterruptible: true }))
|
||||
}),
|
||||
)
|
||||
return yield* restore(Deferred.await(result))
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,406 @@
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as Schedule from "effect/Schedule"
|
||||
import * as Schema from "effect/Schema"
|
||||
import type { RpcClientError } from "effect/unstable/rpc"
|
||||
import { supportsCapability, type UiConnection } from "../simulation/connector.js"
|
||||
import { Frontend } from "../client/protocol.js"
|
||||
import type { SimulationRequestError } from "@opencode-ai/protocol/simulation"
|
||||
|
||||
export interface WaitOptions {
|
||||
/** Maximum wait in milliseconds. Defaults to 5,000. */
|
||||
readonly timeout?: number
|
||||
/** Poll interval in milliseconds. Defaults to 50. */
|
||||
readonly interval?: number
|
||||
}
|
||||
|
||||
export interface ElementQuery {
|
||||
readonly id?: string
|
||||
readonly num?: number
|
||||
readonly focusable?: boolean
|
||||
readonly focused?: boolean
|
||||
readonly clickable?: boolean
|
||||
readonly editor?: boolean
|
||||
}
|
||||
|
||||
export type SemanticQuery = Partial<Frontend.SemanticNode>
|
||||
|
||||
export type Position = Pick<Frontend.ClickParams, "x" | "y">
|
||||
|
||||
export type Predicate = (state: Frontend.State) => boolean
|
||||
export type EffectPredicate<E> = (state: Frontend.State) => Effect.Effect<boolean, E>
|
||||
|
||||
export class UiTimeoutError extends Schema.TaggedErrorClass<UiTimeoutError>()("UiTimeoutError", {
|
||||
operation: Schema.String,
|
||||
milliseconds: Schema.Number,
|
||||
message: Schema.String,
|
||||
frame: Schema.optionalKey(Frontend.CapturedFrame),
|
||||
}) {}
|
||||
|
||||
export class UiElementAmbiguousError extends Schema.TaggedErrorClass<UiElementAmbiguousError>()(
|
||||
"UiElementAmbiguousError",
|
||||
{
|
||||
count: Schema.Number,
|
||||
},
|
||||
) {
|
||||
override get message() {
|
||||
return `ui.getElement matched ${this.count} elements`
|
||||
}
|
||||
}
|
||||
|
||||
export class UiNodeAmbiguousError extends Schema.TaggedErrorClass<UiNodeAmbiguousError>()("UiNodeAmbiguousError", {
|
||||
count: Schema.Number,
|
||||
}) {
|
||||
override get message() {
|
||||
return `ui.getNode matched ${this.count} semantic nodes`
|
||||
}
|
||||
}
|
||||
|
||||
export class UiCapabilityError extends Schema.TaggedErrorClass<UiCapabilityError>()("UiCapabilityError", {
|
||||
capability: Schema.Literals(["ui.snapshot", "ui.click.semantic"]),
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class UiWaitOptionsError extends Schema.TaggedErrorClass<UiWaitOptionsError>()("UiWaitOptionsError", {
|
||||
field: Schema.Literals(["timeout", "interval"]),
|
||||
value: Schema.Number,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class UiPredicateError extends Schema.TaggedErrorClass<UiPredicateError>()("UiPredicateError", {
|
||||
cause: Schema.Defect(),
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export interface Options {
|
||||
/** Per-RPC timeout in milliseconds. Defaults to 30,000. */
|
||||
readonly requestTimeout?: number
|
||||
}
|
||||
|
||||
const RequestTimeout = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0))
|
||||
|
||||
export type WaitError = UiTimeoutError | UiWaitOptionsError
|
||||
type RpcError = SimulationRequestError | RpcClientError.RpcClientError
|
||||
export type OperationError = RpcError | UiTimeoutError
|
||||
export type SemanticOperationError = OperationError | UiCapabilityError
|
||||
|
||||
export interface Ui {
|
||||
readonly state: () => Effect.Effect<Frontend.State, OperationError>
|
||||
readonly snapshot: () => Effect.Effect<Frontend.SemanticSnapshot, SemanticOperationError>
|
||||
readonly capture: () => Effect.Effect<Frontend.CapturedFrame, OperationError>
|
||||
readonly matches: (text: string) => Effect.Effect<boolean, OperationError>
|
||||
readonly screenshot: (name?: string) => Effect.Effect<string, OperationError>
|
||||
readonly type: (text: string) => Effect.Effect<Frontend.State, OperationError>
|
||||
readonly press: (key: string, modifiers?: Frontend.KeyModifiers) => Effect.Effect<Frontend.State, OperationError>
|
||||
readonly enter: () => Effect.Effect<Frontend.State, OperationError>
|
||||
readonly arrow: (direction: Frontend.ArrowParams["direction"]) => Effect.Effect<Frontend.State, OperationError>
|
||||
readonly focus: (target: number | Frontend.Element) => Effect.Effect<Frontend.State, OperationError>
|
||||
readonly click: (
|
||||
target: number | Frontend.Element | Frontend.SemanticNode,
|
||||
position?: Position,
|
||||
) => Effect.Effect<Frontend.State, OperationError | UiCapabilityError | UiElementAmbiguousError | UiWaitOptionsError>
|
||||
readonly resize: (viewport: Frontend.ResizeParams) => Effect.Effect<Frontend.State, OperationError>
|
||||
readonly submit: (text: string) => Effect.Effect<Frontend.State, OperationError>
|
||||
readonly waitFor: <E = never>(
|
||||
target: string | Predicate | EffectPredicate<E>,
|
||||
options?: WaitOptions,
|
||||
) => Effect.Effect<Frontend.State, OperationError | WaitError | UiPredicateError | E>
|
||||
readonly getElement: (
|
||||
target: number | string | ElementQuery,
|
||||
options?: WaitOptions,
|
||||
) => Effect.Effect<Frontend.Element, OperationError | WaitError | UiElementAmbiguousError>
|
||||
readonly getNode: (
|
||||
target: string | SemanticQuery,
|
||||
options?: WaitOptions,
|
||||
) => Effect.Effect<Frontend.SemanticNode, SemanticOperationError | WaitError | UiNodeAmbiguousError>
|
||||
}
|
||||
|
||||
interface Control extends Ui {
|
||||
readonly finishRecording: () => Effect.Effect<string, OperationError>
|
||||
}
|
||||
|
||||
export interface Transform {
|
||||
<A, E>(effect: Effect.Effect<A, E>): Effect.Effect<A, E>
|
||||
}
|
||||
|
||||
/** Applies one Effect transformation to every operation without changing the UI interface. */
|
||||
export const transform = (ui: Ui, apply: Transform): Ui => ({
|
||||
state: () => apply(ui.state()),
|
||||
snapshot: () => apply(ui.snapshot()),
|
||||
capture: () => apply(ui.capture()),
|
||||
matches: (text) => apply(ui.matches(text)),
|
||||
screenshot: (name) => apply(ui.screenshot(name)),
|
||||
type: (text) => apply(ui.type(text)),
|
||||
press: (key, modifiers) => apply(ui.press(key, modifiers)),
|
||||
enter: () => apply(ui.enter()),
|
||||
arrow: (direction) => apply(ui.arrow(direction)),
|
||||
focus: (target) => apply(ui.focus(target)),
|
||||
click: (target, position) => apply(ui.click(target, position)),
|
||||
resize: (viewport) => apply(ui.resize(viewport)),
|
||||
submit: (text) => apply(ui.submit(text)),
|
||||
waitFor: (target, options) => apply(ui.waitFor(target, options)),
|
||||
getElement: (target, options) => apply(ui.getElement(target, options)),
|
||||
getNode: (target, options) => apply(ui.getNode(target, options)),
|
||||
})
|
||||
|
||||
export const make = (connection: UiConnection, options?: Options): Control => {
|
||||
const requestTimeout = RequestTimeout.make(options?.requestTimeout ?? 30_000)
|
||||
const evidenceTimeout = Math.min(requestTimeout, 1_000)
|
||||
const { rpc } = connection
|
||||
const call = <A, E>(operation: string, effect: Effect.Effect<A, E>): Effect.Effect<A, E | UiTimeoutError> =>
|
||||
Effect.timeoutOrElse(effect, {
|
||||
duration: requestTimeout,
|
||||
orElse: () =>
|
||||
Effect.fail(
|
||||
new UiTimeoutError({
|
||||
operation,
|
||||
milliseconds: requestTimeout,
|
||||
message: `ui.${operation} timed out after ${requestTimeout}ms`,
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
const state = Effect.fn("Ui.state")(() => call("state", rpc["ui.state"]()))
|
||||
const snapshot = Effect.fn("Ui.snapshot")(function* () {
|
||||
if (!supportsCapability(connection.compatibility, "ui.snapshot"))
|
||||
return yield* Effect.fail(
|
||||
new UiCapabilityError({
|
||||
capability: "ui.snapshot",
|
||||
message: "ui.snapshot is not available on this OpenCode endpoint",
|
||||
}),
|
||||
)
|
||||
return yield* call("snapshot", rpc["ui.snapshot"]())
|
||||
})
|
||||
const capture = Effect.fn("Ui.capture")(() => call("capture", rpc["ui.capture"]()))
|
||||
const matches = Effect.fn("Ui.matches")((text: string) => call("matches", rpc["ui.matches"]({ text })))
|
||||
const screenshot = Effect.fn("Ui.screenshot")((name?: string) =>
|
||||
call("screenshot", rpc["ui.screenshot"](name === undefined ? undefined : { name })),
|
||||
)
|
||||
const finishRecording = Effect.fn("Ui.finishRecording")(() => call("finishRecording", rpc["ui.recording.finish"]()))
|
||||
const type = Effect.fn("Ui.type")((text: string) => call("type", rpc["ui.type"]({ text })))
|
||||
const press = Effect.fn("Ui.press")((key: string, modifiers?: Frontend.KeyModifiers) =>
|
||||
call("press", rpc["ui.press"](Frontend.pressParams(key, modifiers))),
|
||||
)
|
||||
const enter = Effect.fn("Ui.enter")(() => call("enter", rpc["ui.enter"]()))
|
||||
const arrow = Effect.fn("Ui.arrow")((direction: Frontend.ArrowParams["direction"]) =>
|
||||
call("arrow", rpc["ui.arrow"]({ direction })),
|
||||
)
|
||||
const focus = Effect.fn("Ui.focus")((target: number | Frontend.Element) =>
|
||||
call(
|
||||
"focus",
|
||||
rpc["ui.focus"]({
|
||||
target: typeof target === "number" ? target : target.num,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const resize = Effect.fn("Ui.resize")((viewport: Frontend.ResizeParams) => call("resize", rpc["ui.resize"](viewport)))
|
||||
const submit = Effect.fn("Ui.submit")(function* (text: string) {
|
||||
yield* type(text)
|
||||
return yield* enter()
|
||||
})
|
||||
|
||||
const pollingTimeout = (operation: string, milliseconds: number, message: string) =>
|
||||
rpc["ui.capture"]().pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: evidenceTimeout,
|
||||
orElse: () => Effect.succeed(undefined),
|
||||
}),
|
||||
Effect.catchCause(() => Effect.succeed(undefined)),
|
||||
Effect.flatMap((frame) =>
|
||||
Effect.fail(
|
||||
new UiTimeoutError({
|
||||
operation,
|
||||
milliseconds,
|
||||
message,
|
||||
...(frame === undefined ? {} : { frame }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const poll = <A, E>(
|
||||
operation: string,
|
||||
read: Effect.Effect<A | undefined, E>,
|
||||
options: WaitOptions | undefined,
|
||||
message: string,
|
||||
): Effect.Effect<A, E | WaitError> => {
|
||||
const timeout = options?.timeout ?? 5_000
|
||||
const interval = options?.interval ?? 50
|
||||
const validate = Effect.gen(function* () {
|
||||
if (!Number.isFinite(timeout) || timeout < 0) {
|
||||
yield* Effect.fail(
|
||||
new UiWaitOptionsError({
|
||||
field: "timeout",
|
||||
value: timeout,
|
||||
message: "ui wait timeout must be a finite non-negative number",
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (!Number.isFinite(interval) || interval <= 0) {
|
||||
yield* Effect.fail(
|
||||
new UiWaitOptionsError({
|
||||
field: "interval",
|
||||
value: interval,
|
||||
message: "ui wait interval must be a finite positive number",
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
return Effect.gen(function* () {
|
||||
yield* validate
|
||||
return yield* Effect.repeat(read, {
|
||||
until: (value): value is A => value !== undefined,
|
||||
schedule: Schedule.spaced(interval),
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: timeout,
|
||||
orElse: () => pollingTimeout(operation, timeout, message),
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const waitFor = Effect.fn("Ui.waitFor")(<E>(target: string | Predicate | EffectPredicate<E>, options?: WaitOptions) =>
|
||||
poll(
|
||||
"waitFor",
|
||||
typeof target === "string"
|
||||
? Effect.gen(function* () {
|
||||
if (!(yield* matches(target))) return undefined
|
||||
return yield* state()
|
||||
})
|
||||
: Effect.flatMap(state(), (value) =>
|
||||
predicateEffect(target, value).pipe(Effect.map((matches) => (matches ? value : undefined))),
|
||||
),
|
||||
options,
|
||||
typeof target === "string"
|
||||
? `timed out waiting for the UI to match ${JSON.stringify(target)}`
|
||||
: "timed out waiting for the UI to match",
|
||||
),
|
||||
)
|
||||
|
||||
const getElement = Effect.fn("Ui.getElement")((target: number | string | ElementQuery, options?: WaitOptions) =>
|
||||
poll(
|
||||
"getElement",
|
||||
Effect.flatMap(state(), (value) => {
|
||||
const elements = value.elements.filter((element) =>
|
||||
typeof target === "number"
|
||||
? element.num === target
|
||||
: typeof target === "string"
|
||||
? element.id === target
|
||||
: matchesQuery(element, target),
|
||||
)
|
||||
if (elements.length > 1) return Effect.fail(new UiElementAmbiguousError({ count: elements.length }))
|
||||
return Effect.succeed(elements[0])
|
||||
}),
|
||||
options,
|
||||
"timed out waiting for the UI element",
|
||||
),
|
||||
)
|
||||
|
||||
const getNode = Effect.fn("Ui.getNode")((target: string | SemanticQuery, options?: WaitOptions) =>
|
||||
poll(
|
||||
"getNode",
|
||||
Effect.flatMap(snapshot(), (value) => {
|
||||
const nodes = value.nodes.filter((node) =>
|
||||
typeof target === "string" ? node.id === target : matchesQuery(node, target),
|
||||
)
|
||||
if (nodes.length > 1) return Effect.fail(new UiNodeAmbiguousError({ count: nodes.length }))
|
||||
return Effect.succeed(nodes[0])
|
||||
}),
|
||||
options,
|
||||
"timed out waiting for the semantic UI node",
|
||||
),
|
||||
)
|
||||
|
||||
const click = Effect.fn("Ui.click")(function* (
|
||||
target: number | Frontend.Element | Frontend.SemanticNode,
|
||||
position?: Position,
|
||||
) {
|
||||
if (typeof target !== "number" && "element" in target) {
|
||||
if (!supportsCapability(connection.compatibility, "ui.click.semantic"))
|
||||
return yield* Effect.fail(
|
||||
new UiCapabilityError({
|
||||
capability: "ui.click.semantic",
|
||||
message: "semantic ui.click is not available on this OpenCode endpoint",
|
||||
}),
|
||||
)
|
||||
const element =
|
||||
position === undefined
|
||||
? (yield* state()).elements.find((candidate) => candidate.num === target.element)
|
||||
: undefined
|
||||
return yield* call(
|
||||
"click",
|
||||
rpc["ui.click"]({
|
||||
target: target.element,
|
||||
x: position?.x ?? (element === undefined ? 0 : Math.floor(element.width / 2)),
|
||||
y: position?.y ?? (element === undefined ? 0 : Math.floor(element.height / 2)),
|
||||
semantic: {
|
||||
id: target.id,
|
||||
...(target.instance === undefined ? {} : { instance: target.instance }),
|
||||
element: target.element,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
const element = typeof target === "number" ? yield* getElement(target) : target
|
||||
return yield* call(
|
||||
"click",
|
||||
rpc["ui.click"]({
|
||||
target: element.num,
|
||||
x: position?.x ?? Math.floor(element.width / 2),
|
||||
y: position?.y ?? Math.floor(element.height / 2),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
state,
|
||||
snapshot,
|
||||
capture,
|
||||
matches,
|
||||
screenshot,
|
||||
finishRecording,
|
||||
type,
|
||||
press,
|
||||
enter,
|
||||
arrow,
|
||||
focus,
|
||||
click,
|
||||
resize,
|
||||
submit,
|
||||
waitFor,
|
||||
getElement,
|
||||
getNode,
|
||||
}
|
||||
}
|
||||
|
||||
function predicateEffect<E>(
|
||||
predicate: Predicate | EffectPredicate<E>,
|
||||
state: Frontend.State,
|
||||
): Effect.Effect<boolean, E | UiPredicateError> {
|
||||
return Effect.gen(function* () {
|
||||
const result = yield* Effect.try({
|
||||
try: () => predicate(state),
|
||||
catch: (cause) =>
|
||||
new UiPredicateError({
|
||||
cause,
|
||||
message: `ui.waitFor predicate failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
}),
|
||||
})
|
||||
const value: unknown = Effect.isEffect(result) ? yield* result : result
|
||||
if (typeof value === "boolean") return value
|
||||
return yield* Effect.fail(
|
||||
new UiPredicateError({
|
||||
cause: value,
|
||||
message: "ui.waitFor predicate must return a boolean or Effect",
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function matchesQuery<Value extends object>(value: Value, query: Partial<Value>) {
|
||||
return Object.entries(query).every(
|
||||
([key, expected]) => expected === undefined || Reflect.get(value, key) === expected,
|
||||
)
|
||||
}
|
||||
|
||||
export * as OpenCodeUi from "./ui.js"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user