Compare commits

..

4 Commits

Author SHA1 Message Date
Aiden Cline dbc60fc47a test(provider): cover websocket timeout merge order 2026-06-03 16:50:40 -05:00
Aiden Cline 287057fbd5 fix(provider): preserve custom loader options 2026-06-03 16:21:39 -05:00
Aiden Cline 95252f66a6 fix(openai): scope websocket header timeout override 2026-06-03 16:15:59 -05:00
Aiden Cline 44208fcd09 fix(openai): disable header timeout for websockets 2026-06-03 16:11:02 -05:00
503 changed files with 10161 additions and 51763 deletions
-12
View File
@@ -90,7 +90,6 @@ jobs:
id: build
run: |
./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
@@ -108,12 +107,6 @@ jobs:
with:
name: opencode-cli-windows
path: packages/opencode/dist/opencode-windows*
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-preview-cli
path: packages/cli/dist/cli-*
outputs:
version: ${{ needs.version.outputs.version }}
@@ -453,11 +446,6 @@ jobs:
name: opencode-cli-signed-windows
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: opencode-preview-cli
path: packages/cli/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: needs.version.outputs.release
with:
+35
View File
@@ -0,0 +1,35 @@
name: "sync-zed-extension"
on:
workflow_dispatch:
release:
types: [published]
jobs:
zed:
name: Release Zed Extension
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0
- uses: ./.github/actions/setup-bun
- name: Get version tag
id: get_tag
run: |
if [ "${{ github.event_name }}" = "release" ]; then
TAG="${{ github.event.release.tag_name }}"
else
TAG=$(git tag --list 'v[0-9]*.*' --sort=-version:refname | head -n 1)
fi
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "Using tag: ${TAG}"
- name: Sync Zed extension
run: |
./script/sync-zed.ts ${{ steps.get_tag.outputs.tag }}
env:
ZED_EXTENSIONS_PAT: ${{ secrets.ZED_EXTENSIONS_PAT }}
ZED_PR_PAT: ${{ secrets.ZED_PR_PAT }}
+1 -2
View File
@@ -64,8 +64,7 @@ jobs:
turbo-${{ runner.os }}-
- name: Run unit tests
timeout-minutes: 20
run: bun turbo test:ci --log-order=stream --log-prefix=task
run: bun turbo test:ci
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
-2
View File
@@ -15,8 +15,6 @@ ts-dist
.turbo
**/.serena
.serena/
**/.omo
.omo/
/result
refs
Session.vim
+1 -1
View File
@@ -1,6 +1,6 @@
---
description: translate English to other languages
model: opencode/claude-opus-4-8
model: opencode/claude-opus-4-7
---
run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time.
-11
View File
@@ -138,14 +138,3 @@ const table = sqliteTable("session", {
## Type Checking
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
## V2 Session Core
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_input` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries.
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Historical projected prompts lazily synthesize promoted inbox records during exact retry.
- Keep `SessionExecution` process-global and Session-ID based. It discovers placement through the read-side `SessionStore` and `LocationServiceMap.get(session.location)`; no layer should take a Session ID.
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash activity recovery requires a separate explicit design before it may retry provider work.
- Keep delivery vocabulary explicit. Prompts steer by default and coalesce into the active activity at the next safe provider-turn boundary. Explicit `queue` inputs open FIFO future activities one at a time after the active activity settles.
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
-77
View File
@@ -1,77 +0,0 @@
# OpenCode Session Runtime
OpenCode sessions preserve durable conversational history while assembling the runtime context an agent needs to act correctly in its current environment.
## Language
**System Context**:
The structured collection of contextual facts presented to the model as initial instructions and chronological updates.
_Avoid_: System prompt
**Context Component**:
One independently loaded fact within the **System Context**, represented by a stable key and one effectfully loaded baseline/update rendering.
_Avoid_: Prompt fragment
**Mid-Conversation System Message**:
A durable chronological instruction that tells the model the newly effective state of a changed **Context Component**.
_Avoid_: System update, system notification, raw text diff
**Context Epoch**:
The span during which one initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition.
**Baseline System Context**:
The full **System Context** rendered at the start of a **Context Epoch**.
_Avoid_: Live system prompt
**Context Checkpoint**:
The durable model-hidden comparison state used to detect which **Context Components** changed since context was last admitted to a provider turn.
**Unavailable Context**:
An expected temporary inability to load a **Context Component** value; the runtime retains its prior effective state and emits no update, or omits it until first successfully loaded.
**Safe Provider-Turn Boundary**:
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
## Relationships
- A **System Context** contains one or more **Context Components**.
- A changed **Context Component** may produce one **Mid-Conversation System Message** containing its newly effective state.
- A **Mid-Conversation System Message** persists its originating **Context Component** key and the exact rendered text sent to the model.
- A **Context Checkpoint** advances atomically with the corresponding durable **Mid-Conversation System Message**.
- A **Context Checkpoint** stores one rendered-content hash per stable **Context Component** key so core and plugin-defined components can evolve independently.
- Changes from multiple **Context Components** admitted at one safe boundary combine into one **Mid-Conversation System Message**.
- Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes.
- At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**.
- The first provider turn renders the latest **Baseline System Context** and initializes its **Context Checkpoint** without emitting a redundant **Mid-Conversation System Message**.
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Checkpoint**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
- A **Context Checkpoint** is an evolvable component map; a newly registered core or plugin-defined **Context Component** absent from an existing checkpoint emits its current state once at the next **Safe Provider-Turn Boundary**.
- **Context Component** keys are stable and namespaced; duplicate keys fail assembly. Built-in components preserve declaration order and plugin-defined components append in lexicographic key order so rendered context is deterministic.
- Each **Context Component** loader returns its model-visible baseline string and absolute current-state update string from one coherent sample; the update string is hashed for change detection.
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
- Ordinary **Context Component** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
- Nested project instruction files discovered while reading join the effective instructions returned by the instruction service and are admitted durably at the next **Safe Provider-Turn Boundary**.
- A discovered nested project instruction remains active for the session while it stays in the same location and is folded into later **Baseline System Contexts** after compaction.
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
- Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values.
- Plugin-defined **Context Components** register through a scoped replayable registry so plugin hot reload adds and removes components predictably.
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry.
- **Mid-Conversation System Messages** remain durable model-projection history but are hidden from normal user-facing transcript surfaces.
- The date **Context Component** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
- A **Context Epoch** begins with one immutable **Baseline System Context**.
- A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
- A **Baseline System Context** durably preserves deterministic keyed top-level component strings rather than eagerly joining all text; request assembly lowers them into canonical LLM system parts.
- Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache.
- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
- When an effective instruction file changes, its **Mid-Conversation System Message** includes the complete current contents and supersedes the prior version from that source; when it is removed, the message states that it no longer applies.
## Example dialogue
> **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?"
> **Domain expert:** "No. Emit the newly effective date so the agent can act on the current **System Context**."
## Flagged ambiguities
- Legacy `experimental.chat.system.transform` can mutate the assembled baseline system prompt arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, replace dynamic uses with plugin-defined **Context Components**, or narrow its semantics.
- A location change likely starts a new **Context Epoch** so location-dependent instructions and discovery can be rebuilt cleanly, but implementation should verify whether an append-only update is sufficient and meaningfully preserves cache.
+1040 -631
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "gitlab-ai-provider"]
minimumReleaseAgeExcludes = ["@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-x64", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "gitlab-ai-provider"]
[test]
root = "./do-not-run-tests-from-root"
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-mXTzANDuuy+BY4vzhuuL5Q6JVVTJCKdHuD/Fo8pSfgI=",
"aarch64-linux": "sha256-t1Uf+PIDvj9bogsSo2Dg1e+zJM2CHQ8lpA/I3vFQA1Q=",
"aarch64-darwin": "sha256-HKpMwzpYhCQOu0xHugi4ZIC/Va2BSiQpM2TbA6BEZDU=",
"x86_64-darwin": "sha256-m5h7h9KxkcIrdTO2QzQftq68d0Ru0IsCfu3WzMp4P68="
"x86_64-linux": "sha256-rQ8kz/fChREJWnwY2Jp2zp06TYesyd3hia44hdo8l+s=",
"aarch64-linux": "sha256-t5WKzAN8NRO/4g2l+4V5SatK/LO3ZPBfmKjJFf/MsD4=",
"aarch64-darwin": "sha256-QbNaxGNiKdJ0/mKaTUk392qsOvlRYVi5mTuMmFByEic=",
"x86_64-darwin": "sha256-lewm6WvqxphR+rvXz9e7ZKvgu98MH3cxosvQkz3mLuA="
}
}
+3 -4
View File
@@ -37,11 +37,10 @@
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.3.2",
"@opentui/keymap": "0.3.2",
"@opentui/solid": "0.3.2",
"@opentui/core": "0.3.1",
"@opentui/keymap": "0.3.1",
"@opentui/solid": "0.3.1",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -1,86 +0,0 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "C:/OpenCode/PromptThinkingLevelRegression"
const projectID = "proj_prompt_thinking_level_regression"
const sessionID = "ses_prompt_thinking_level_regression"
test("shows the V2 thinking level control while relevant", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "prompt-thinking-level-regression",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: {
"thinking-model": {
id: "thinking-model",
name: "Thinking Model",
limit: { context: 200_000 },
variants: { high: {} },
},
},
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "thinking-model" },
},
sessions: [
{
id: sessionID,
slug: "prompt-thinking-level-regression",
projectID,
directory,
title: "Prompt thinking level regression",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
const composer = page.locator('[data-component="session-composer"]')
const input = composer.locator('[data-component="prompt-input"]')
const control = composer.locator('[data-component="prompt-variant-control"]')
await expect(composer).toBeVisible()
await idleComposer(page)
await expect(control).toBeHidden()
await composer.hover()
await expect(control).toBeVisible()
await control.locator('[data-action="prompt-model-variant"]').click()
const high = page.getByRole("option", { name: "high" })
await expect(high).toBeVisible()
await page.mouse.move(0, 0)
await expect(control).toBeVisible()
await expect(high).toBeVisible()
await high.click()
await idleComposer(page)
await input.focus()
await expect(control).toBeVisible()
await idleComposer(page)
await expect(control).toBeVisible()
})
async function idleComposer(page: Page) {
await page.mouse.move(0, 0)
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.16.0",
"version": "1.15.13",
"description": "",
"type": "module",
"exports": {
+1 -38
View File
@@ -277,7 +277,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
draggingType: "image" | "@mention" | null
mode: "normal" | "shell"
applyingHistory: boolean
variantOpen: boolean
}>({
popover: null,
historyIndex: -1,
@@ -286,7 +285,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
draggingType: null,
mode: "normal",
applyingHistory: false,
variantOpen: false,
})
const [picker, setPicker] = createStore({
projectOpen: false,
@@ -1103,8 +1101,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
)
const variants = createMemo(() => ["default", ...local.model.variant.list()])
// Check provider variants directly: `variants` also includes the UI-only default option.
const showVariantControl = createMemo(() => local.model.variant.list().length > 0)
const accepting = createMemo(() => {
const id = params.id
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
@@ -1575,39 +1571,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<ComposerPickerTrigger state={newProjectTriggerState()} />
</Show>
<ComposerModelControl state={modelControlState()} />
<Show when={store.mode !== "shell" && showVariantControl()}>
<div
data-component="prompt-variant-control"
classList={{
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
!local.model.variant.current() && !store.variantOpen,
}}
>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<Select
size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onOpenChange={(open) => setStore("variantOpen", open)}
onSelect={(value) => {
local.model.variant.set(value === "default" ? undefined : value)
restoreFocus()
}}
class="capitalize max-w-[160px] justify-start text-v2-text-text-faint"
valueClass="truncate text-[13px] font-[440] leading-5 text-v2-text-text-faint"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-model-variant" }}
variant="ghost"
/>
</TooltipKeybind>
</div>
</Show>
</div>
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<IconButton
@@ -1927,7 +1890,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</TooltipKeybind>
</Show>
</div>
<Show when={showVariantControl()}>
<Show when={variants().length > 2}>
<div
data-component="prompt-variant-control"
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
@@ -16,8 +16,14 @@ export const DialogSettings: Component = () => {
const platform = usePlatform()
return (
<Dialog size="x-large" variant="settings" class="settings-v2-dialog">
<TabsV2 orientation="vertical" variant="settings" defaultValue="general" class="settings-v2">
<Dialog size="x-large" class="settings-v2-dialog" data-component="settings-v2-dialog">
<TabsV2
orientation="vertical"
variant="settings"
defaultValue="general"
class="settings-v2"
data-component="settings-v2"
>
<TabsV2.List>
<div class="flex flex-col justify-between h-full w-full">
<div class="flex flex-col gap-3 w-full">
@@ -1,15 +1,11 @@
@import "@opencode-ai/ui/v2/text-input-v2.css";
@import "@opencode-ai/ui/v2/button-v2.css";
[data-component="tabs-v2"][data-variant="settings"] {
[data-component="settings-v2"] {
height: 100%;
}
[data-component="dialog-v2"][data-variant="settings"] [data-slot="dialog-container"] {
background: var(--v2-background-bg-base);
}
[data-component="dialog-v2"][data-variant="settings"] [data-slot="dialog-body"] {
[data-component="settings-v2-dialog"] [data-slot="dialog-body"] {
padding: 0;
overflow: hidden;
}
@@ -85,6 +81,10 @@
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
}
[data-slot="dialog-container"]:has(.settings-v2-dialog) {
box-shadow: var(--v2-elevation-overlay);
}
[data-component="settings-v2-row"] {
display: flex;
flex-wrap: wrap;
@@ -153,12 +153,12 @@
width: 100%;
}
[data-component="dialog-v2"][data-variant="settings"] [data-component="select-v2-root"] {
[data-component="settings-v2-dialog"] [data-component="select-v2-root"] {
width: fit-content;
max-width: 100%;
}
[data-component="dialog-v2"][data-variant="settings"] [data-component="button-v2"] {
[data-component="settings-v2-dialog"] [data-component="button-v2"] {
width: fit-content;
max-width: 100%;
}
+1 -3
View File
@@ -707,9 +707,7 @@ type TitlebarV2RightState = {
function TitlebarV2Right(props: { state: TitlebarV2RightState }) {
return (
<div class="relative z-20 flex shrink-0 items-center justify-end gap-0 overflow-visible">
<Show when={props.state.update.visible}>
<TitlebarUpdateIconButton state={props.state.update} />
</Show>
<TitlebarUpdateIconButton state={props.state.update} />
<div id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" />
</div>
)
-1
View File
@@ -467,7 +467,6 @@ export const dict = {
"error.page.title": "Something went wrong",
"error.page.description": "An error occurred while loading the application.",
"error.page.description.localServerStartup": "An error occurred while starting the local server.",
"error.page.details.label": "Error Details",
"error.page.action.restart": "Restart",
"error.page.action.report": "Report Error",
@@ -1,17 +0,0 @@
import { describe, expect, test } from "bun:test"
import { errorDescriptionKey } from "./error-description"
describe("error description", () => {
test("describes local server startup errors", () => {
expect(errorDescriptionKey(Object.assign(new Error("migration failed"), { localServerStartup: true }))).toBe(
"error.page.description.localServerStartup",
)
})
test("uses the generic description for other errors", () => {
expect(errorDescriptionKey(new Error("unknown"))).toBe("error.page.description")
expect(errorDescriptionKey(Object.assign(new Error("unknown"), { localServerStartup: false }))).toBe(
"error.page.description",
)
})
})
@@ -1,11 +0,0 @@
export function errorDescriptionKey(error: unknown) {
if (
typeof error === "object" &&
error !== null &&
"localServerStartup" in error &&
error.localServerStartup === true
) {
return "error.page.description.localServerStartup" as const
}
return "error.page.description" as const
}
+1 -2
View File
@@ -7,7 +7,6 @@ import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { Icon } from "@opencode-ai/ui/icon"
import { errorDescriptionKey } from "./error-description"
export type InitError = {
name: string
@@ -290,7 +289,7 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
<Logo class="w-58.5 opacity-12 shrink-0" />
<div class="flex flex-col items-center gap-2 text-center">
<h1 class="text-lg font-medium text-text-strong">{language.t("error.page.title")}</h1>
<p class="text-sm text-text-weak">{language.t(errorDescriptionKey(props.error))}</p>
<p class="text-sm text-text-weak">{language.t("error.page.description")}</p>
</div>
<TextField
value={formattedError()}
+1 -1
View File
@@ -270,7 +270,7 @@ function HomeDesign() {
}
return (
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 bg-v2-background-bg-base self-stretch flex-1">
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 bg-background-base self-stretch flex-1">
<div class="mx-auto grid w-full h-full max-w-[1080px] gap-8 px-6 pb-16 lg:grid-cols-[280px_minmax(0,720px)]">
<HomeProjectColumn
projects={projects()}
+5 -2
View File
@@ -89,7 +89,6 @@ import {
} from "./layout/sidebar-workspace"
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
import { SidebarContent } from "./layout/sidebar-shell"
import { runUpdateAndRestart } from "./layout/update"
export default function Layout(props: ParentProps) {
const [store, setStore, , ready] = persisted(
@@ -184,7 +183,11 @@ export default function Layout(props: ParentProps) {
return updateQuery.data.version ?? ""
}
const installUpdate = () => {
runUpdateAndRestart(platform.updateAndRestart, (installing) => setUpdate("installing", installing))
if (!platform.updateAndRestart) return
setUpdate("installing", true)
void platform.updateAndRestart().catch(() => {
setUpdate("installing", false)
})
}
const titlebarUpdate: TitlebarUpdate = {
version: updateVersion,
@@ -1,19 +0,0 @@
import { describe, expect, test } from "bun:test"
import { runUpdateAndRestart } from "./update"
describe("runUpdateAndRestart", () => {
test("clears the installing state when restart resolves without exiting", async () => {
const states: boolean[] = []
await new Promise<void>((resolve) => {
runUpdateAndRestart(
async () => {},
(installing) => {
states.push(installing)
if (states.length === 2) resolve()
},
)
})
expect(states).toEqual([true, false])
})
})
-10
View File
@@ -1,10 +0,0 @@
export function runUpdateAndRestart(
updateAndRestart: (() => Promise<void>) | undefined,
setInstalling: (installing: boolean) => void,
) {
if (!updateAndRestart) return
setInstalling(true)
void updateAndRestart()
.catch(() => undefined)
.finally(() => setInstalling(false))
}
+2
View File
@@ -467,6 +467,8 @@ export default function Page() {
return {
queryKey: [...vcsKey(), mode] as const,
enabled,
staleTime: Number.POSITIVE_INFINITY,
gcTime: 60 * 1000,
queryFn: mode
? () =>
sdk.client.vcs
-130
View File
@@ -1,130 +0,0 @@
#!/usr/bin/env node
const childProcess = require("child_process")
const fs = require("fs")
const path = require("path")
const os = require("os")
const forwardedSignals = ["SIGINT", "SIGTERM", "SIGHUP"]
function run(target) {
const child = childProcess.spawn(target, process.argv.slice(2), { stdio: "inherit" })
child.on("error", (error) => {
console.error(error.message)
process.exit(1)
})
const forwarders = {}
for (const signal of forwardedSignals) {
forwarders[signal] = () => {
try {
child.kill(signal)
} catch {}
}
process.on(signal, forwarders[signal])
}
child.on("exit", (code, signal) => {
for (const forwardedSignal of forwardedSignals) process.removeListener(forwardedSignal, forwarders[forwardedSignal])
if (signal) return process.kill(process.pid, signal)
process.exit(typeof code === "number" ? code : 0)
})
}
const envPath = process.env.OPENCODE_BIN_PATH
const scriptDir = path.dirname(fs.realpathSync(__filename))
const cached = path.join(scriptDir, ".lildax")
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] || os.platform()
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch()
const base = "@opencode-ai/cli-" + platform + "-" + arch
const binary = platform === "windows" ? "lildax.exe" : "lildax"
function supportsAvx2() {
if (arch !== "x64") return false
if (platform === "linux") {
try {
return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8"))
} catch {
return false
}
}
if (platform === "darwin") {
try {
const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], { encoding: "utf8", timeout: 1500 })
return result.status === 0 && (result.stdout || "").trim() === "1"
} catch {
return false
}
}
if (platform === "windows") {
const command =
'(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)'
for (const executable of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) {
try {
const result = childProcess.spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", command], {
encoding: "utf8",
timeout: 3000,
windowsHide: true,
})
if (result.status !== 0) continue
const output = (result.stdout || "").trim().toLowerCase()
if (output === "true" || output === "1") return true
if (output === "false" || output === "0") return false
} catch {
continue
}
}
}
return false
}
const names = (() => {
const baseline = arch === "x64" && !supportsAvx2()
if (platform === "linux") {
const musl = (() => {
try {
if (fs.existsSync("/etc/alpine-release")) return true
const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" })
return ((result.stdout || "") + (result.stderr || "")).toLowerCase().includes("musl")
} catch {
return false
}
})()
if (musl)
return arch === "x64"
? baseline
? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base]
: [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`]
: [`${base}-musl`, base]
return arch === "x64"
? baseline
? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`]
: [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]
: [base, `${base}-musl`]
}
return arch === "x64" ? (baseline ? [`${base}-baseline`, base] : [base, `${base}-baseline`]) : [base]
})()
function findBinary(startDir) {
let current = startDir
for (;;) {
const modules = path.join(current, "node_modules")
if (fs.existsSync(modules))
for (const name of names) {
const candidate = path.join(modules, name, "bin", binary)
if (fs.existsSync(candidate)) return candidate
}
const parent = path.dirname(current)
if (parent === current) return
current = parent
}
}
const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir))
if (!resolved) {
console.error(
"It seems that your package manager failed to install the right lildax CLI package. Try manually installing " +
names.map((name) => `"${name}"`).join(" or ") +
" package",
)
process.exit(1)
}
run(resolved)
+3 -9
View File
@@ -1,15 +1,13 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.16.0",
"version": "1.15.13",
"private": true,
"type": "module",
"license": "MIT",
"bin": {
"lildax": "./bin/lildax.cjs"
"opencode": "./src/index.ts"
},
"files": [
"bin"
],
"scripts": {
"build": "bun run script/build.ts",
"dev": "bun run src/index.ts",
@@ -18,13 +16,9 @@
"dependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@parcel/watcher": "2.5.1",
"effect": "catalog:"
},
"devDependencies": {
"@opencode-ai/script": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
-105
View File
@@ -1,105 +0,0 @@
#!/usr/bin/env bun
import { rm } from "fs/promises"
import path from "path"
import { Script } from "@opencode-ai/script"
import { modelsData } from "./generate"
const dir = path.resolve(import.meta.dirname, "..")
const binary = "lildax"
process.chdir(dir)
await rm("dist", { recursive: true, force: true })
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const sourcemapsFlag = process.argv.includes("--sourcemaps")
const allTargets: {
os: string
arch: "arm64" | "x64"
abi?: "musl"
avx2?: false
}[] = [
{ os: "linux", arch: "arm64" },
{ os: "linux", arch: "x64" },
{ os: "linux", arch: "x64", avx2: false },
{ os: "linux", arch: "arm64", abi: "musl" },
{ os: "linux", arch: "x64", abi: "musl" },
{ os: "linux", arch: "x64", abi: "musl", avx2: false },
{ os: "darwin", arch: "arm64" },
{ os: "darwin", arch: "x64" },
{ os: "darwin", arch: "x64", avx2: false },
{ os: "win32", arch: "arm64" },
{ os: "win32", arch: "x64" },
{ os: "win32", arch: "x64", avx2: false },
]
const targets = singleFlag
? allTargets.filter((item) => {
if (item.os !== process.platform || item.arch !== process.arch) return false
if (item.avx2 === false) return baselineFlag
return item.abi === undefined
})
: allTargets
for (const item of targets) {
const target = [
binary,
item.os === "win32" ? "windows" : item.os,
item.arch,
item.avx2 === false ? "baseline" : undefined,
item.abi,
]
.filter(Boolean)
.join("-")
const name = target.replace(binary, "cli")
console.log(`building ${name}`)
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
external: ["node-gyp"],
format: "esm",
minify: true,
sourcemap: sourcemapsFlag ? "linked" : "none",
splitting: true,
compile: {
autoloadBunfig: false,
autoloadDotenv: false,
autoloadTsconfig: true,
autoloadPackageJson: true,
target: target.replace(binary, "bun") as Bun.Build.CompileTarget,
outfile: `./dist/${name}/bin/${binary}`,
execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--"],
windows: {},
},
define: {
OPENCODE_VERSION: `'${Script.version}'`,
OPENCODE_CLI_NAME: `'${binary}'`,
OPENCODE_MODELS_DEV: modelsData,
OPENCODE_CHANNEL: `'${Script.channel}'`,
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
},
})
if (!result.success) {
for (const log of result.logs) console.error(log)
process.exit(1)
}
await Bun.write(
`./dist/${name}/package.json`,
JSON.stringify(
{
name: `@opencode-ai/${name}`,
version: Script.version,
license: "MIT",
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
os: [item.os],
cpu: [item.arch],
},
null,
2,
),
)
}
-7
View File
@@ -1,7 +0,0 @@
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
export const modelsData = process.env.MODELS_DEV_API_JSON
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
: await fetch(`${modelsUrl}/api.json`).then((response) => response.text())
console.log("Loaded models.dev snapshot")
-53
View File
@@ -1,53 +0,0 @@
#!/usr/bin/env bun
import { $ } from "bun"
import pkg from "../package.json"
import { Script } from "@opencode-ai/script"
import { fileURLToPath } from "url"
const dir = fileURLToPath(new URL("..", import.meta.url))
process.chdir(dir)
async function published(name: string, version: string) {
return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0
}
async function publish(dir: string, name: string, version: string) {
if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir)
if (await published(name, version)) return console.log(`already published ${name}@${version}`)
await $`bun pm pack`.cwd(dir)
await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir)
}
const binaries: Record<string, string> = {}
for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" })) {
const item = await Bun.file(`./dist/${filepath}`).json()
binaries[item.name] = item.version
}
console.log("binaries", binaries)
const version = Object.values(binaries)[0]
await $`mkdir -p ./dist/${pkg.name}/bin`
await $`cp ./bin/lildax.cjs ./dist/${pkg.name}/bin/lildax`
await Bun.file(`./dist/${pkg.name}/package.json`).write(
JSON.stringify(
{
name: pkg.name,
bin: { lildax: "./bin/lildax" },
version,
license: pkg.license,
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
os: ["darwin", "linux", "win32"],
cpu: ["arm64", "x64"],
optionalDependencies: binaries,
},
null,
2,
),
)
await Promise.all(
Object.entries(binaries).map(([name, version]) =>
publish(`./dist/${name.replace("@opencode-ai/", "")}`, name, version),
),
)
await publish(`./dist/${pkg.name}`, pkg.name, version)
+12
View File
@@ -0,0 +1,12 @@
import { CliApi } from "./cli-api"
export const Api = CliApi.make("opencode", {
description: "OpenCode command line interface",
commands: [
CliApi.make("debug", {
description: "Debugging and troubleshooting tools",
commands: [CliApi.make("agents", { description: "List all agents" })],
}),
CliApi.make("migrate", { description: "Migrate v1 data to v2" }),
],
})
@@ -39,4 +39,4 @@ type ChildrenOf<Commands extends ReadonlyArray<Any>> = {
readonly [Node in Commands[number] as Node["name"]]: Node
}
export * as Spec from "./spec"
export * as CliApi from "./cli-api"
@@ -1,22 +1,19 @@
import * as Effect from "effect/Effect"
import * as Command from "effect/unstable/cli/Command"
import { Spec } from "./spec"
import { Daemon } from "../services/daemon"
import { CliApi } from "./cli-api"
export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
? Input<Command>
Value extends CliApi.Node<infer _Name, infer Spec, infer _Commands>
? Input<Spec>
: Value extends Command.Command<infer _Name, infer Input, infer _Context, infer _Error, infer _Requirements>
? Input
: never
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service>
type Loader<Node extends Spec.Any> = () => Promise<{
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service>
}>
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service>
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown>
type Loader<Node extends CliApi.Any> = () => Promise<{ default: (input: Input<Node>) => Effect.Effect<void, any> }>
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, never>
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
export type Handlers<Node extends CliApi.Any> = keyof Node["commands"] extends never
? Loader<Node>
: { readonly $?: Loader<Node> } & { readonly [Key in keyof Node["commands"]]: Handlers<Node["commands"][Key]> }
@@ -32,17 +29,17 @@ type RuntimeHandlers =
readonly [key: string]: RuntimeHandlers | (() => Promise<{ default: RuntimeHandler }>) | undefined
}
export function handler<const Node extends Spec.Any, Error, Requirements>(
export function handler<const Node extends CliApi.Any, Error>(
_node: Node,
run: (input: Input<Node>) => Effect.Effect<void, Error, Requirements>,
run: (input: Input<Node>) => Effect.Effect<void, Error>,
) {
return run
}
export function handlers<const Root extends Spec.Any>(root: Root, handlers: Handlers<Root>) {
export function handlers<const Root extends CliApi.Any>(root: Root, handlers: Handlers<Root>) {
const result: LazyHandler[] = []
function add(node: Spec.Any, value: RuntimeHandlers) {
function add(node: CliApi.Any, value: RuntimeHandlers) {
if (typeof value === "function") {
result.push({ spec: node.spec, load: value as () => Promise<{ default: RuntimeHandler }> })
return
@@ -55,11 +52,11 @@ export function handlers<const Root extends Spec.Any>(root: Root, handlers: Hand
return result
}
export function run(commands: Spec.Any, handlers: ReadonlyArray<LazyHandler>, options: { readonly version: string }) {
return Command.run(provide(commands, handlers), options) as Effect.Effect<void, unknown, Command.Environment>
export function run(api: CliApi.Any, handlers: ReadonlyArray<LazyHandler>, options: { readonly version: string }) {
return Command.run(provide(api, handlers), options) as Effect.Effect<void, unknown, Command.Environment>
}
function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): ProvidedCommand {
function provide(node: CliApi.Any, handlers: ReadonlyArray<LazyHandler>): ProvidedCommand {
const spec: Command.Command.Any = Object.keys(node.commands).length
? (node.spec as Command.Command<string, unknown>).pipe(
Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
@@ -68,12 +65,8 @@ function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): Provided
const handler = handlers.find((handler) => handler.spec === node.spec)
if (!handler) return spec as ProvidedCommand
return spec.pipe(
Command.withHandler((input) =>
Effect.gen(function* () {
yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))
}),
),
Command.withHandler((input) => Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))),
) as ProvidedCommand
}
export * as Runtime from "./runtime"
export * as CliBuilder from "./cli-builder"
-36
View File
@@ -1,36 +0,0 @@
import { Argument, Flag } from "effect/unstable/cli"
import { Spec } from "../framework/spec"
declare const OPENCODE_CLI_NAME: string | undefined
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
description: "OpenCode 2.0 preview command line interface",
commands: [
Spec.make("debug", {
description: "Debugging and troubleshooting tools",
commands: [Spec.make("agents", { description: "List all agents" })],
}),
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
Spec.make("service", {
description: "Manage the background server",
commands: [
Spec.make("start", { description: "Start the background server" }),
Spec.make("restart", { description: "Restart the background server" }),
Spec.make("status", { description: "Show background server status" }),
Spec.make("stop", { description: "Stop the background server" }),
Spec.make("password", {
description: "Get or set the server password",
params: { value: Argument.string("value").pipe(Argument.optional) },
}),
],
}),
Spec.make("serve", {
description: "Start the v2 API server",
params: {
hostname: Flag.string("hostname").pipe(Flag.withDefault("127.0.0.1")),
port: Flag.integer("port").pipe(Flag.optional),
register: Flag.boolean("register").pipe(Flag.withDefault(false)),
},
}),
],
})
@@ -1,21 +0,0 @@
import { EOL } from "os"
import * as Effect from "effect/Effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Daemon } from "../../../services/daemon"
export default Runtime.handler(
Commands.commands.debug.commands.agents,
Effect.fn("cli.debug.agents")(function* () {
const daemon = yield* Daemon.Service
const client = yield* daemon.client()
const response = yield* Effect.promise(() => client.v2.agent.list({ location: { directory: process.cwd() } }))
process.stdout.write(
JSON.stringify(
response.data?.data.toSorted((a, b) => a.id.localeCompare(b.id)),
null,
2,
) + EOL,
)
}),
)
@@ -1,5 +0,0 @@
import * as Effect from "effect/Effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
export default Runtime.handler(Commands.commands.migrate, (_input) => Effect.log("No migrations to run."))
@@ -1,39 +0,0 @@
import { NodeHttpServer } from "@effect/platform-node"
import { Context, Layer, Option } from "effect"
import * as Effect from "effect/Effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { createServer } from "node:http"
import { createRoutes } from "@opencode-ai/server/routes"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Daemon } from "../../services/daemon"
export default Runtime.handler(
Commands.commands.serve,
Effect.fn("cli.serve")(function* (input) {
return yield* Effect.scoped(
Effect.gen(function* () {
const daemon = yield* Daemon.Service
const address = yield* listen(input.hostname, input.port, yield* daemon.password())
if (input.register) yield* daemon.register(address)
console.log(`server listening on ${HttpServer.formatAddress(address)}`)
return yield* Effect.never
}),
)
}),
)
function listen(hostname: string, port: Option.Option<number>, password: string) {
if (Option.isSome(port)) return bind(hostname, port.value, password)
// Preserve the familiar default when available, but let the OS choose a free
// port when another local server already owns 4096.
return bind(hostname, 4096, password).pipe(Effect.catch(() => bind(hostname, 0, password)))
}
function bind(hostname: string, port: number, password: string) {
return Layer.build(
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })),
),
).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address))
}
@@ -1,16 +0,0 @@
import { EOL } from "os"
import { Option } from "effect"
import * as Effect from "effect/Effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Daemon } from "../../../services/daemon"
export default Runtime.handler(
Commands.commands.service.commands.password,
Effect.fn("cli.service.password")(function* (input) {
const daemon = yield* Daemon.Service
const value = Option.getOrUndefined(input.value)
if (value !== undefined) yield* daemon.stop()
process.stdout.write((yield* daemon.password(value)) + EOL)
}),
)
@@ -1,14 +0,0 @@
import { EOL } from "os"
import * as Effect from "effect/Effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Daemon } from "../../../services/daemon"
export default Runtime.handler(
Commands.commands.service.commands.restart,
Effect.fn("cli.service.restart")(function* () {
const daemon = yield* Daemon.Service
yield* daemon.stop()
process.stdout.write((yield* daemon.start()) + EOL)
}),
)
@@ -1,12 +0,0 @@
import { EOL } from "os"
import * as Effect from "effect/Effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Daemon } from "../../../services/daemon"
export default Runtime.handler(
Commands.commands.service.commands.start,
Effect.fn("cli.service.start")(function* () {
process.stdout.write((yield* (yield* Daemon.Service).start()) + EOL)
}),
)
@@ -1,13 +0,0 @@
import { EOL } from "os"
import * as Effect from "effect/Effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Daemon } from "../../../services/daemon"
export default Runtime.handler(
Commands.commands.service.commands.status,
Effect.fn("cli.service.status")(function* () {
const url = yield* (yield* Daemon.Service).status()
process.stdout.write((url ? `running ${url}` : "stopped") + EOL)
}),
)
@@ -1,11 +0,0 @@
import * as Effect from "effect/Effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Daemon } from "../../../services/daemon"
export default Runtime.handler(
Commands.commands.service.commands.stop,
Effect.fn("cli.service.stop")(function* () {
yield* (yield* Daemon.Service).stop()
}),
)
+30
View File
@@ -0,0 +1,30 @@
import { EOL } from "os"
import { AgentV2 } from "@opencode-ai/core/agent"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { AbsolutePath } from "@opencode-ai/core/schema"
import * as Effect from "effect/Effect"
import { Api } from "../../api"
import { CliBuilder } from "../../cli-builder"
export default CliBuilder.handler(
Api.commands.debug.commands.agents,
Effect.fn("cli.debug.agents")(
function* () {
const svc = {
plugin: yield* PluginBoot.Service,
agent: yield* AgentV2.Service,
}
yield* svc.plugin.wait()
process.stdout.write(
JSON.stringify(
(yield* svc.agent.all()).sort((a, b) => a.id.localeCompare(b.id)),
null,
2,
) + EOL,
)
},
Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(process.cwd()) })),
Effect.provide(LocationServiceMap.layer),
),
)
+5
View File
@@ -0,0 +1,5 @@
import * as Effect from "effect/Effect"
import { Api } from "../api"
import { CliBuilder } from "../cli-builder"
export default CliBuilder.handler(Api.commands.migrate, (_input) => Effect.log("No migrations to run."))
+6 -16
View File
@@ -3,27 +3,17 @@
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
import * as NodeServices from "@effect/platform-node/NodeServices"
import * as Effect from "effect/Effect"
import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime"
import { Daemon } from "./services/daemon"
import { Api } from "./api"
import { CliBuilder } from "./cli-builder"
const Handlers = Runtime.handlers(Commands, {
const Handlers = CliBuilder.handlers(Api, {
debug: {
agents: () => import("./commands/handlers/debug/agents"),
agents: () => import("./handlers/debug/agents"),
},
migrate: () => import("./commands/handlers/migrate"),
service: {
start: () => import("./commands/handlers/service/start"),
restart: () => import("./commands/handlers/service/restart"),
status: () => import("./commands/handlers/service/status"),
stop: () => import("./commands/handlers/service/stop"),
password: () => import("./commands/handlers/service/password"),
},
serve: () => import("./commands/handlers/serve"),
migrate: () => import("./handlers/migrate"),
})
Runtime.run(Commands, Handlers, { version: "local" }).pipe(
Effect.provide(Daemon.defaultLayer),
CliBuilder.run(Api, Handlers, { version: "local" }).pipe(
Effect.provide(NodeServices.layer),
Effect.scoped,
NodeRuntime.runMain,
-143
View File
@@ -1,143 +0,0 @@
import { Global } from "@opencode-ai/core/global"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { ServerAuth } from "@opencode-ai/server/auth"
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
import { HttpServer } from "effect/unstable/http"
import { randomBytes } from "crypto"
import path from "path"
export interface Interface {
readonly client: () => Effect.Effect<ReturnType<typeof createOpencodeClient>, unknown>
readonly start: () => Effect.Effect<string, Error>
readonly status: () => Effect.Effect<string | undefined>
readonly stop: () => Effect.Effect<void, unknown>
readonly password: (value?: string) => Effect.Effect<string, unknown>
readonly register: (address: HttpServer.Address) => Effect.Effect<void, unknown, Scope.Scope>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Daemon") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const directory = Global.Path.state
const file = path.join(directory, "server.json")
const passwordFile = path.join(directory, "password")
const decodeRegistration = Schema.decodeUnknownEffect(
Schema.fromJsonString(Schema.Struct({ url: Schema.String, pid: Schema.Number })),
)
const password = Effect.fn("cli.daemon.password")(function* (value?: string) {
const existing = yield* fs.readFileString(passwordFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (value === undefined && existing) return existing
// Keep one private credential across server restarts so discovered clients
// can reconnect without exposing a password flag or environment variable.
const generated = value ?? randomBytes(32).toString("base64url")
const temp = passwordFile + ".tmp"
yield* fs.makeDirectory(directory, { recursive: true })
yield* fs.writeFileString(temp, generated, { mode: 0o600 })
yield* fs.rename(temp, passwordFile)
return generated
})
const registration = Effect.fnUntraced(function* () {
return yield* fs.readFileString(file).pipe(Effect.flatMap(decodeRegistration))
})
const createClient = Effect.fnUntraced(function* (url: string) {
return createOpencodeClient({ baseUrl: url, headers: ServerAuth.headers({ password: yield* password() }) })
})
const healthy = Effect.fnUntraced(function* () {
const info = yield* registration()
const client = yield* createClient(info.url)
const response = yield* Effect.tryPromise(() => client.v2.health.get())
if (response.data?.healthy === true) return info
return yield* Effect.fail(new Error("Registered server is not healthy"))
})
const start = Effect.fn("cli.daemon.start")(function* () {
const existing = yield* healthy().pipe(Effect.option)
const found = Option.getOrUndefined(existing)
if (found) return found.url
yield* Effect.sync(() => {
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
Bun.spawn([process.execPath, ...(compiled ? [] : [Bun.main]), "serve", "--register"], {
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
}).unref()
})
return yield* healthy().pipe(
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
Effect.map((info) => info.url),
Effect.mapError(() => new Error("Failed to start server")),
)
})
const client = Effect.fn("cli.daemon.client")(function* () {
return yield* createClient(yield* start())
})
const status = Effect.fn("cli.daemon.status")(function* () {
const existing = yield* healthy().pipe(Effect.option)
const found = Option.getOrUndefined(existing)
if (found) return found.url
yield* fs.remove(file).pipe(Effect.ignore)
return undefined
})
const signal = (pid: number, signal: NodeJS.Signals) =>
Effect.try({ try: () => process.kill(pid, signal), catch: (cause) => cause }).pipe(Effect.ignore)
const awaitStopped = Effect.fnUntraced(function* (pid: number) {
const running = yield* Effect.try({ try: () => process.kill(pid, 0), catch: () => false }).pipe(
Effect.orElseSucceed(() => false),
)
if (!running) return true
return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
})
const stop = Effect.fn("cli.daemon.stop")(function* () {
const existing = yield* healthy().pipe(Effect.option)
// A stale registration may point at a PID that has since been reused by
// another process. Only signal the PID after authenticating the server.
if (Option.isNone(existing)) return yield* fs.remove(file).pipe(Effect.ignore)
const pid = existing.value.pid
yield* signal(pid, "SIGTERM")
const stopped = yield* awaitStopped(pid).pipe(
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
Effect.option,
)
if (Option.isNone(stopped)) {
yield* signal(pid, "SIGKILL")
yield* awaitStopped(pid).pipe(
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
)
}
yield* fs.remove(file).pipe(Effect.ignore)
})
const register = Effect.fn("cli.daemon.register")(function* (address: HttpServer.Address) {
const temp = file + ".tmp"
yield* fs.makeDirectory(directory, { recursive: true })
yield* fs.writeFileString(temp, JSON.stringify({ url: HttpServer.formatAddress(address), pid: process.pid }), {
mode: 0o600,
})
yield* fs.rename(temp, file)
// The metadata file represents this live listener, not persistent config.
// Scope shutdown removes it when the server exits normally.
yield* Effect.addFinalizer(() => fs.remove(file).pipe(Effect.ignore))
})
return Service.of({ client, start, status, stop, password, register })
}),
)
export const defaultLayer = layer
export * as Daemon from "./daemon"
-1
View File
@@ -2,7 +2,6 @@
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.16.0",
"version": "1.15.13",
"type": "module",
"license": "MIT",
"scripts": {
@@ -112,7 +112,6 @@ export async function handler(
client: ocClient,
user_agent: userAgent,
"model.variant": variant,
"model.tier": opts.modelList === "full" ? "zen" : "go",
})
const zenData = ZenData.list(opts.modelList)
const modelInfo = validateModel(zenData, model)
@@ -679,10 +678,12 @@ export async function handler(
...(() => {
if (data.billing.subscription)
return {
isSubscription: true,
subscription: data.billing.subscription.plan,
}
if (data.billing.lite)
return {
isSubscription: true,
subscription: "lite",
}
return {}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.16.0",
"version": "1.15.13",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.16.0",
"version": "1.15.13",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
@@ -132,7 +132,7 @@ function toLakeEvent(time: string, data: Record<string, unknown>) {
error_cause2: string(data, "error.cause2"),
api_key: string(data, "api_key"),
workspace: string(data, "workspace"),
is_subscription: boolean(data, "isSubscription"), // removed
is_subscription: boolean(data, "isSubscription"),
subscription: string(data, "subscription"),
response_length: integer(data, "response_length"),
time_to_first_byte: integer(data, "time_to_first_byte"),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.16.0",
"version": "1.15.13",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
"version": "1.16.0",
"version": "1.15.13",
"type": "module",
"license": "MIT",
"scripts": {
@@ -1,5 +0,0 @@
DROP INDEX IF EXISTS `session_message_session_idx`;--> statement-breakpoint
DROP INDEX IF EXISTS `session_message_session_type_idx`;--> statement-breakpoint
CREATE INDEX `event_aggregate_seq_idx` ON `event` (`aggregate_id`,`seq`);--> statement-breakpoint
CREATE INDEX `session_message_session_time_created_id_idx` ON `session_message` (`session_id`,`time_created`,`id`);--> statement-breakpoint
CREATE INDEX `session_message_session_type_time_created_id_idx` ON `session_message` (`session_id`,`type`,`time_created`,`id`);
File diff suppressed because it is too large Load Diff
@@ -1,6 +0,0 @@
DELETE FROM `session_message`;--> statement-breakpoint
ALTER TABLE `session_message` ADD `seq` integer NOT NULL;--> statement-breakpoint
DROP INDEX IF EXISTS `session_message_session_time_created_id_idx`;--> statement-breakpoint
DROP INDEX IF EXISTS `session_message_session_type_time_created_id_idx`;--> statement-breakpoint
CREATE INDEX `session_message_session_seq_idx` ON `session_message` (`session_id`,`seq`);--> statement-breakpoint
CREATE INDEX `session_message_session_type_seq_idx` ON `session_message` (`session_id`,`type`,`seq`);
File diff suppressed because it is too large Load Diff
@@ -1,12 +0,0 @@
CREATE TABLE `session_input` (
`seq` integer PRIMARY KEY AUTOINCREMENT,
`id` text NOT NULL UNIQUE,
`session_id` text NOT NULL,
`prompt` text NOT NULL,
`delivery` text NOT NULL,
`promoted_seq` integer,
`time_created` integer NOT NULL,
CONSTRAINT `fk_session_input_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE INDEX `session_input_session_pending_seq_idx` ON `session_input` (`session_id`,`promoted_seq`,`seq`);
File diff suppressed because it is too large Load Diff
@@ -1,4 +0,0 @@
DROP INDEX IF EXISTS `session_input_session_pending_seq_idx`;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `event_aggregate_type_seq_idx` ON `event` (`aggregate_id`,`type`,`seq`);--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `session_input_session_pending_delivery_seq_idx` ON `session_input` (`session_id`,`promoted_seq`,`delivery`,`seq`);--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `session_message_session_time_created_id_idx` ON `session_message` (`session_id`,`time_created`,`id`);
File diff suppressed because it is too large Load Diff
@@ -1,28 +0,0 @@
DELETE FROM `session_input`;--> statement-breakpoint
DELETE FROM `session_message`;--> statement-breakpoint
DELETE FROM `event`;--> statement-breakpoint
DELETE FROM `event_sequence`;--> statement-breakpoint
UPDATE `session` SET `workspace_id` = NULL;--> statement-breakpoint
DELETE FROM `workspace`;--> statement-breakpoint
DROP INDEX IF EXISTS `event_aggregate_seq_idx`;--> statement-breakpoint
CREATE UNIQUE INDEX `event_aggregate_seq_idx` ON `event` (`aggregate_id`,`seq`);--> statement-breakpoint
DROP INDEX IF EXISTS `session_message_session_seq_idx`;--> statement-breakpoint
CREATE UNIQUE INDEX `session_message_session_seq_idx` ON `session_message` (`session_id`,`seq`);--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_session_input` (
`id` text PRIMARY KEY,
`session_id` text NOT NULL,
`prompt` text NOT NULL,
`delivery` text NOT NULL,
`admitted_seq` integer NOT NULL,
`promoted_seq` integer,
`time_created` integer NOT NULL,
CONSTRAINT `fk_session_input_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
DROP TABLE `session_input`;--> statement-breakpoint
ALTER TABLE `__new_session_input` RENAME TO `session_input`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE INDEX `session_input_session_pending_delivery_seq_idx` ON `session_input` (`session_id`,`promoted_seq`,`delivery`,`admitted_seq`);--> statement-breakpoint
CREATE UNIQUE INDEX `session_input_session_admitted_seq_idx` ON `session_input` (`session_id`,`admitted_seq`);--> statement-breakpoint
CREATE UNIQUE INDEX `session_input_session_promoted_seq_idx` ON `session_input` (`session_id`,`promoted_seq`);
File diff suppressed because it is too large Load Diff
+4 -11
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.16.0",
"version": "1.15.13",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
@@ -17,8 +17,6 @@
"opencode": "./bin/opencode"
},
"exports": {
"./public": "./src/public/index.ts",
"./session/runner": "./src/session/runner/index.ts",
"./*": "./src/*.ts"
},
"imports": {
@@ -41,7 +39,6 @@
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "catalog:",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
@@ -51,12 +48,11 @@
"@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1",
"@opencode-ai/http-recorder": "workspace:*",
"drizzle-kit": "catalog:"
},
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/amazon-bedrock": "4.0.107",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
@@ -75,7 +71,7 @@
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.82",
"@aws-sdk/credential-providers": "3.1057.0",
"@aws-sdk/credential-providers": "3.993.0",
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
@@ -84,13 +80,12 @@
"@npmcli/config": "10.8.1",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@parcel/watcher": "2.5.1",
"@openrouter/ai-sdk-provider": "2.9.0",
"@openrouter/ai-sdk-provider": "2.8.1",
"ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8",
"cross-spawn": "catalog:",
@@ -101,7 +96,6 @@
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
"htmlparser2": "8.0.2",
"immer": "11.1.4",
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
@@ -109,7 +103,6 @@
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"semver": "^7.6.3",
"turndown": "7.2.0",
"venice-ai-sdk-provider": "2.0.2",
"which": "6.0.1",
"xdg-basedir": "5.1.0",
+1 -5
View File
@@ -82,11 +82,7 @@ function prepareOptions(model: ModelV2.Info, pkg: string) {
if (abortSignals.length === 1) opts.signal = abortSignals[0]
if (abortSignals.length > 1) opts.signal = AbortSignal.any(abortSignals)
if (
(pkg === "@ai-sdk/openai" || pkg === "@ai-sdk/azure" || pkg === "@ai-sdk/amazon-bedrock/mantle") &&
opts.body &&
opts.method === "POST"
) {
if ((pkg === "@ai-sdk/openai" || pkg === "@ai-sdk/azure") && opts.body && opts.method === "POST") {
const body = JSON.parse(opts.body as string)
if (body.store !== true && Array.isArray(body.input)) {
for (const item of body.input) {
-284
View File
@@ -1,284 +0,0 @@
export * as BackgroundJob from "./background-job"
import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
import { Identifier } from "./id/id"
export type Status = "running" | "completed" | "error" | "cancelled"
export type Info = {
id: string
type: string
title?: string
status: Status
started_at: number
completed_at?: number
output?: string
error?: string
metadata?: Record<string, unknown>
}
type Active = {
info: Info
done: Deferred.Deferred<Info>
scope: Scope.Closeable
token: object
pending: number
next: number
output?: { sequence: number; text: string }
}
type State = {
jobs: SynchronizedRef.SynchronizedRef<Map<string, Active>>
scope: Scope.Scope
}
type FinishResult = {
info?: Info
done?: Deferred.Deferred<Info>
scope?: Scope.Closeable
}
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable; token: object }
type ExtendResult = { extended: false } | { extended: true; scope: Scope.Closeable; token: object; sequence: number }
export type StartInput = {
id?: string
type: string
title?: string
metadata?: Record<string, unknown>
run: Effect.Effect<string, unknown>
}
export type ExtendInput = {
id: string
run: Effect.Effect<string, unknown>
}
export type WaitInput = {
id: string
timeout?: number
}
export type WaitResult = {
info?: Info
timedOut: boolean
}
export interface Interface {
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: string) => Effect.Effect<Info | undefined>
readonly start: (input: StartInput) => Effect.Effect<Info>
readonly extend: (input: ExtendInput) => Effect.Effect<boolean>
readonly wait: (input: WaitInput) => Effect.Effect<WaitResult>
readonly cancel: (id: string) => Effect.Effect<Info | undefined>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/BackgroundJob") {}
function snapshot(job: Active): Info {
return {
...job.info,
...(job.info.metadata ? { metadata: { ...job.info.metadata } } : {}),
}
}
function errorText(error: unknown) {
if (error instanceof Error) return error.message
return String(error)
}
/**
* Makes one scoped, process-local registry. Entries are intentionally not
* durable: process restart or owner-scope closure loses status and interrupts
* live work. Persisted observation, restart recovery, and remote workers need a
* separate durable ownership slice rather than pretending this registry has
* those semantics.
*/
export const make = Effect.gen(function* () {
const state: State = {
jobs: yield* SynchronizedRef.make(new Map()),
scope: yield* Scope.Scope,
}
const settle = Effect.fn("BackgroundJob.settle")(function* (
id: string,
token: object,
sequence: number,
exit: Exit.Exit<string, unknown>,
) {
const completed_at = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
const job = jobs.get(id)
if (!job) return [{}, jobs]
if (job.token !== token) return [{}, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
const pending = job.pending - 1
const output =
Exit.isSuccess(exit) && (!job.output || sequence > job.output.sequence)
? { sequence, text: exit.value }
: job.output
if (Exit.isSuccess(exit) && pending > 0) {
return [{}, new Map(jobs).set(id, { ...job, pending, output })]
}
const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
? "completed"
: Cause.hasInterruptsOnly(exit.cause)
? "cancelled"
: "error"
const next = {
...job,
pending: 0,
output,
info: {
...job.info,
status,
completed_at,
...(output ? { output: output.text } : {}),
...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}),
},
}
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
})
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
if (result.scope) {
yield* Scope.close(result.scope, Exit.void).pipe(Effect.forkIn(state.scope, { startImmediately: true }))
}
return result.info
})
const fork = Effect.fn("BackgroundJob.fork")(function* (
scope: Scope.Scope,
id: string,
token: object,
sequence: number,
run: Effect.Effect<string, unknown>,
) {
return yield* run.pipe(
Effect.matchCauseEffect({
onSuccess: (output) => settle(id, token, sequence, Exit.succeed(output)),
onFailure: (cause) => settle(id, token, sequence, Exit.failCause(cause)),
}),
Effect.asVoid,
Effect.forkIn(scope, { startImmediately: true }),
)
})
const list: Interface["list"] = Effect.fn("BackgroundJob.list")(function* () {
return Array.from((yield* SynchronizedRef.get(state.jobs)).values())
.map(snapshot)
.toSorted((a, b) => a.started_at - b.started_at)
})
const get: Interface["get"] = Effect.fn("BackgroundJob.get")(function* (id) {
const job = (yield* SynchronizedRef.get(state.jobs)).get(id)
if (!job) return
return snapshot(job)
})
const start: Interface["start"] = Effect.fn("BackgroundJob.start")(function* (input) {
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const id = input.id ?? Identifier.ascending("job")
const started_at = yield* Clock.currentTimeMillis
const done = yield* Deferred.make<Info>()
const result = yield* SynchronizedRef.modifyEffect(
state.jobs,
Effect.fnUntraced(function* (jobs) {
const existing = jobs.get(id)
if (existing?.info.status === "running") {
return [{ info: snapshot(existing) }, jobs] as readonly [StartResult, Map<string, Active>]
}
const scope = yield* Scope.fork(state.scope, "parallel")
const token = {}
const job = {
info: {
id,
type: input.type,
title: input.title,
status: "running" as const,
started_at,
metadata: input.metadata,
},
done,
scope,
token,
pending: 1,
next: 1,
}
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)] as readonly [
StartResult,
Map<string, Active>,
]
}),
)
if ("scope" in result) yield* fork(result.scope, id, result.token, 0, restore(input.run))
return result.info
}),
)
})
const extend: Interface["extend"] = Effect.fn("BackgroundJob.extend")(function* (input) {
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const result = yield* SynchronizedRef.modify(
state.jobs,
(jobs): readonly [ExtendResult, Map<string, Active>] => {
const job = jobs.get(input.id)
if (!job || job.info.status !== "running") return [{ extended: false }, jobs]
return [
{ extended: true, scope: job.scope, token: job.token, sequence: job.next },
new Map(jobs).set(input.id, {
...job,
pending: job.pending + 1,
next: job.next + 1,
}),
]
},
)
if (!result.extended) return false
yield* fork(result.scope, input.id, result.token, result.sequence, restore(input.run))
return true
}),
)
})
const wait: Interface["wait"] = Effect.fn("BackgroundJob.wait")(function* (input) {
const job = (yield* SynchronizedRef.get(state.jobs)).get(input.id)
if (!job) return { timedOut: false }
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
if (input.timeout === undefined) return { info: yield* Deferred.await(job.done), timedOut: false }
if (input.timeout <= 0) return { info: snapshot(job), timedOut: true }
const info = yield* Deferred.await(job.done).pipe(Effect.timeoutOption(input.timeout))
if (info._tag === "Some") return { info: info.value, timedOut: false }
return { info: snapshot(job), timedOut: true }
})
const cancel: Interface["cancel"] = Effect.fn("BackgroundJob.cancel")(function* (id) {
const completed_at = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
const job = jobs.get(id)
if (!job) return [{}, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
const next = {
...job,
pending: 0,
info: {
...job.info,
status: "cancelled" as const,
completed_at,
},
}
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
})
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
if (result.scope) yield* Scope.close(result.scope, Exit.void)
return result.info
})
return Service.of({ list, get, start, extend, wait, cancel })
})
export const layer = Layer.effect(Service, make)
export const defaultLayer = layer
-68
View File
@@ -1,68 +0,0 @@
export * as CommandV2 from "./command"
import { Context, Effect, Layer, Schema } from "effect"
import { castDraft, type Draft } from "immer"
import { ModelV2 } from "./model"
import { State } from "./state"
export class Info extends Schema.Class<Info>("CommandV2.Info")({
name: Schema.String,
template: Schema.String,
description: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
model: ModelV2.Ref.pipe(Schema.optional),
subtask: Schema.Boolean.pipe(Schema.optional),
}) {}
export type Data = {
commands: Map<string, Info>
}
export type Editor = {
list: () => readonly Info[]
get: (name: string) => Info | undefined
update: (name: string, update: (command: Draft<Info>) => void) => void
remove: (name: string) => void
}
export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Command") {}
export const layer = Layer.effect(
Service,
Effect.sync(() => {
const state = State.create<Data, Editor>({
initial: () => ({ commands: new Map() }),
editor: (draft) => ({
list: () => Array.from(draft.commands.values()) as Info[],
get: (name) => draft.commands.get(name),
update: (name, update) => {
const current = draft.commands.get(name) ?? castDraft(new Info({ name, template: "" }))
if (!draft.commands.has(name)) draft.commands.set(name, current)
update(current)
current.name = name
},
remove: (name) => {
draft.commands.delete(name)
},
}),
})
return Service.of({
transform: state.transform,
get: Effect.fn("CommandV2.get")(function* (name) {
return state.get().commands.get(name)
}),
list: Effect.fn("CommandV2.list")(function* () {
return Array.from(state.get().commands.values())
}),
})
}),
)
export const locationLayer = layer
+2 -6
View File
@@ -6,13 +6,12 @@ import { Context, Effect, Layer, Option, Schema } from "effect"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { Location } from "./location"
import { PermissionSchema } from "./permission/schema"
import { PermissionV2 } from "./permission"
import { Policy } from "./policy"
import { AbsolutePath } from "./schema"
import { ConfigAgent } from "./config/agent"
import { ConfigAttachments } from "./config/attachments"
import { ConfigCompaction } from "./config/compaction"
import { ConfigCommand } from "./config/command"
import { ConfigExperimental } from "./config/experimental"
import { ConfigFormatter } from "./config/formatter"
import { ConfigLSP } from "./config/lsp"
@@ -53,7 +52,7 @@ export class Info extends Schema.Class<Info>("Config.Info")({
username: Schema.String.pipe(Schema.optional).annotate({
description: "Username displayed in conversations and used for telemetry identity",
}),
permissions: PermissionSchema.Ruleset.pipe(Schema.optional).annotate({
permissions: PermissionV2.Ruleset.pipe(Schema.optional).annotate({
description: "Ordered tool permission rules applied to agent tool use",
}),
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
@@ -86,9 +85,6 @@ export class Info extends Schema.Class<Info>("Config.Info")({
skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
description: "Additional paths or URLs to discover skills from",
}),
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(Schema.optional).annotate({
description: "Named slash command definitions",
}),
instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
description: "Additional paths or URLs supplying ambient instructions",
}),
+2 -2
View File
@@ -1,7 +1,7 @@
export * as ConfigAgent from "./agent"
import { Schema } from "effect"
import { PermissionSchema } from "../permission/schema"
import { PermissionV2 } from "../permission"
import { ConfigProvider } from "./provider"
import { PositiveInt } from "../schema"
@@ -21,5 +21,5 @@ export class Info extends Schema.Class<Info>("ConfigV2.Agent")({
color: Color.pipe(Schema.optional),
steps: PositiveInt.pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
permissions: PermissionSchema.Ruleset.pipe(Schema.optional),
permissions: PermissionV2.Ruleset.pipe(Schema.optional),
}) {}
-12
View File
@@ -1,12 +0,0 @@
export * as ConfigCommand from "./command"
import { Schema } from "effect"
export class Info extends Schema.Class<Info>("ConfigV2.Command")({
template: Schema.String,
description: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
model: Schema.String.pipe(Schema.optional),
variant: Schema.String.pipe(Schema.optional),
subtask: Schema.Boolean.pipe(Schema.optional),
}) {}
@@ -1,84 +0,0 @@
export * as ConfigCommandPlugin from "./command"
import path from "path"
import { Effect, Option, Schema } from "effect"
import { CommandV2 } from "../../command"
import { Config } from "../../config"
import { FSUtil } from "../../fs-util"
import { ModelV2 } from "../../model"
import { PluginV2 } from "../../plugin"
import { ConfigCommand } from "../command"
import { ConfigMarkdown } from "../markdown"
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
export const Plugin = PluginV2.define({
id: PluginV2.ID.make("config-command"),
effect: Effect.gen(function* () {
const command = yield* CommandV2.Service
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const transform = yield* command.transform()
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
return loadDirectory(fs, entry.path).pipe(
Effect.map((commands) => [
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
]),
)
}).pipe(Effect.map((documents) => documents.flat()))
yield* transform((editor) => {
for (const document of documents) {
for (const [name, command] of Object.entries(document.commands ?? {})) {
editor.update(name, (item) => {
item.template = command.template
if (command.description !== undefined) item.description = command.description
if (command.agent !== undefined) item.agent = command.agent
if (command.model !== undefined) {
const model = ModelV2.parse(command.model)
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
}
if (command.variant !== undefined && item.model !== undefined) {
item.model.variant = ModelV2.VariantID.make(command.variant)
}
if (command.subtask !== undefined) item.subtask = command.subtask
})
}
}
})
}),
})
function loadDirectory(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const files = yield* fs
.glob("{command,commands}/**/*.md", { cwd: directory, absolute: true, dot: true, symlink: true })
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
return yield* Effect.forEach(files.toSorted(), (filepath) =>
fs.readFileStringSafe(filepath).pipe(
Effect.map((content) => (content === undefined ? undefined : decode(directory, filepath, content))),
Effect.catch(() => Effect.succeed(undefined)),
),
).pipe(
Effect.map((commands) =>
commands.filter((command): command is { name: string; info: ConfigCommand.Info } => command !== undefined),
),
)
})
}
function decode(directory: string, filepath: string, content: string) {
const markdown = ConfigMarkdown.parseOption(content)
if (!markdown) return
const info = Option.getOrUndefined(decodeCommand({ ...markdown.data, template: markdown.content.trim() }))
if (!info) return
return {
name: path
.relative(directory, filepath)
.replaceAll("\\", "/")
.replace(/^(command|commands)\//, "")
.replace(/\.md$/, ""),
info,
}
}
-9
View File
@@ -18,18 +18,9 @@ export const Plugin = PluginV2.define({
const skill = yield* SkillV2.Service
const transform = yield* skill.transform()
const entries = yield* config.entries()
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
yield* transform((editor) => {
for (const directory of directories) {
editor.source(
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
)
editor.source(
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
)
}
for (const item of items) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
editor.source(new SkillV2.UrlSource({ type: "url", url: item }))
@@ -1,128 +0,0 @@
export * as MoveSession from "./move-session"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { EventV2 } from "../event"
import { Git } from "../git"
import { Location } from "../location"
import { ProjectV2 } from "../project"
import { SessionV2 } from "../session"
import { SessionEvent } from "../session/event"
import { SessionSchema } from "../session/schema"
import { AbsolutePath, RelativePath } from "../schema"
import path from "path"
export const Destination = Schema.Struct({
directory: AbsolutePath,
}).annotate({ identifier: "MoveSession.Destination" })
export type Destination = typeof Destination.Type
export const Input = Schema.Struct({
sessionID: SessionSchema.ID,
destination: Destination,
moveChanges: Schema.optional(Schema.Boolean),
}).annotate({ identifier: "MoveSession.Input" })
export type Input = typeof Input.Type
export class DestinationProjectMismatchError extends Schema.TaggedErrorClass<DestinationProjectMismatchError>()(
"MoveSession.DestinationProjectMismatchError",
{
expected: ProjectV2.ID,
actual: ProjectV2.ID,
},
) {}
export class ApplyChangesError extends Schema.TaggedErrorClass<ApplyChangesError>()("MoveSession.ApplyChangesError", {
message: Schema.String,
}) {}
export class CaptureChangesError extends Schema.TaggedErrorClass<CaptureChangesError>()(
"MoveSession.CaptureChangesError",
{
message: Schema.String,
},
) {}
export class ResetSourceChangesError extends Schema.TaggedErrorClass<ResetSourceChangesError>()(
"MoveSession.ResetSourceChangesError",
{
directory: AbsolutePath,
message: Schema.String,
cause: Schema.optional(Schema.Defect),
},
) {}
export type Error =
| SessionV2.NotFoundError
| DestinationProjectMismatchError
| CaptureChangesError
| ApplyChangesError
| ResetSourceChangesError
export interface Interface {
readonly moveSession: (input: Input) => Effect.Effect<void, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ControlPlaneMoveSession") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const git = yield* Git.Service
const events = yield* EventV2.Service
const project = yield* ProjectV2.Service
const session = yield* SessionV2.Service
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
const current = yield* session.get(input.sessionID)
const directory = AbsolutePath.make(input.destination.directory)
if (current.location.directory === directory) return
const source = yield* project.resolve(current.location.directory)
const destination = yield* project.resolve(directory)
if (current.projectID !== destination.id) {
return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id })
}
const patch =
input.moveChanges && source.directory !== destination.directory
? yield* git
.patch(current.location.directory)
.pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message })))
: ""
if (patch) {
yield* git
.applyPatch({ directory, patch })
.pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message })))
}
yield* events.publish(SessionEvent.Moved, {
sessionID: input.sessionID,
location: Location.Ref.make({ directory }),
subdirectory: RelativePath.make(path.relative(destination.directory, directory).replaceAll("\\", "/")),
timestamp: yield* DateTime.now,
})
if (patch) {
yield* git.softResetChanges(current.location.directory).pipe(
Effect.mapError(
(error) =>
new ResetSourceChangesError({
directory: current.location.directory,
message: error.message,
cause: error.cause,
}),
),
)
}
})
return Service.of({ moveSession })
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(SessionV2.defaultLayer),
)
-5
View File
@@ -27,10 +27,5 @@ export const migrations = (
import("./migration/20260601202201_amazing_prowler"),
import("./migration/20260602002951_lowly_union_jack"),
import("./migration/20260602182828_add_project_directories"),
import("./migration/20260603001617_session_message_projection_indexes"),
import("./migration/20260603040000_session_message_projection_order"),
import("./migration/20260603141458_session_input_inbox"),
import("./migration/20260603160727_jittery_ezekiel_stane"),
import("./migration/20260604172448_event_sourced_session_input"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
+2 -3
View File
@@ -1,13 +1,12 @@
export * as DatabaseMigration from "./migration"
import { sql } from "drizzle-orm"
import { Effect, Semaphore } from "effect"
import { Effect } from "effect"
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { migrations } from "./migration.gen"
type Database = EffectDrizzleSqlite.EffectSQLiteDatabase
type Transaction = Parameters<Parameters<Database["transaction"]>[0]>[0]
const lock = Semaphore.makeUnsafe(1)
export type Migration = {
id: string
@@ -15,7 +14,7 @@ export type Migration = {
}
export function apply(db: Database) {
return lock.withPermit(applyOnly(db, migrations))
return applyOnly(db, migrations)
}
export function applyOnly(db: Database, input: Migration[]) {
@@ -1,19 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260603001617_session_message_projection_indexes",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DROP INDEX IF EXISTS \`session_message_session_idx\`;`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_message_session_type_idx\`;`)
yield* tx.run(`CREATE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`)
yield* tx.run(
`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`,
)
yield* tx.run(
`CREATE INDEX \`session_message_session_type_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`time_created\`,\`id\`);`,
)
})
},
} satisfies DatabaseMigration.Migration
@@ -1,19 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260603040000_session_message_projection_order",
up(tx) {
return Effect.gen(function* () {
// Pre-launch Session projections were written before durable event persistence
// became unconditional, so they cannot be assigned truthful aggregate order.
yield* tx.run(`DELETE FROM \`session_message\`;`)
yield* tx.run(`ALTER TABLE \`session_message\` ADD COLUMN \`seq\` integer NOT NULL;`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_message_session_type_time_created_id_idx\`;`)
yield* tx.run(`CREATE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`)
yield* tx.run(
`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`,
)
})
},
} satisfies DatabaseMigration.Migration
@@ -1,25 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260603141458_session_input_inbox",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`session_input\` (
\`seq\` integer PRIMARY KEY AUTOINCREMENT,
\`id\` text NOT NULL UNIQUE,
\`session_id\` text NOT NULL,
\`prompt\` text NOT NULL,
\`delivery\` text NOT NULL,
\`promoted_seq\` integer,
\`time_created\` integer NOT NULL,
CONSTRAINT \`fk_session_input_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(
`CREATE INDEX \`session_input_session_pending_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`seq\`);`,
)
})
},
} satisfies DatabaseMigration.Migration
@@ -1,20 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260603160727_jittery_ezekiel_stane",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DROP INDEX IF EXISTS \`session_input_session_pending_seq_idx\`;`)
yield* tx.run(
`CREATE INDEX IF NOT EXISTS \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`,
)
yield* tx.run(
`CREATE INDEX IF NOT EXISTS \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`seq\`);`,
)
yield* tx.run(
`CREATE INDEX IF NOT EXISTS \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`,
)
})
},
} satisfies DatabaseMigration.Migration
@@ -1,47 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260604172448_event_sourced_session_input",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DELETE FROM \`session_input\`;`)
yield* tx.run(`DELETE FROM \`session_message\`;`)
yield* tx.run(`DELETE FROM \`event\`;`)
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL;`)
yield* tx.run(`DELETE FROM \`workspace\`;`)
yield* tx.run(`DROP INDEX IF EXISTS \`event_aggregate_seq_idx\`;`)
yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_message_session_seq_idx\`;`)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`,
)
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
yield* tx.run(`
CREATE TABLE \`__new_session_input\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`prompt\` text NOT NULL,
\`delivery\` text NOT NULL,
\`admitted_seq\` integer NOT NULL,
\`promoted_seq\` integer,
\`time_created\` integer NOT NULL,
CONSTRAINT \`fk_session_input_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`DROP TABLE \`session_input\`;`)
yield* tx.run(`ALTER TABLE \`__new_session_input\` RENAME TO \`session_input\`;`)
yield* tx.run(`PRAGMA foreign_keys=ON;`)
yield* tx.run(
`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`,
)
})
},
} satisfies DatabaseMigration.Migration
+5 -6
View File
@@ -175,9 +175,8 @@ const drizzleLayer = Layer.effect(
}),
)
export const layer = (config: Config) => {
const native = nativeLayer(config)
return Layer.merge(native, Layer.merge(sqliteLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
Layer.provide(Reactivity.layer),
)
}
export const layer = (config: Config) =>
Layer.merge(
nativeLayer(config),
Layer.merge(sqliteLayer(config), drizzleLayer).pipe(Layer.provide(nativeLayer(config))),
).pipe(Layer.provide(Reactivity.layer))
+5 -6
View File
@@ -170,9 +170,8 @@ const drizzleLayer = Layer.effect(
}),
)
export const layer = (config: Config) => {
const native = nativeLayer(config)
return Layer.merge(native, Layer.merge(sqliteLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
Layer.provide(Reactivity.layer),
)
}
export const layer = (config: Config) =>
Layer.merge(
nativeLayer(config),
Layer.merge(sqliteLayer(config), drizzleLayer).pipe(Layer.provide(nativeLayer(config))),
).pipe(Layer.provide(Reactivity.layer))
-45
View File
@@ -1,45 +0,0 @@
export * as KeyedMutex from "./keyed-mutex"
import { Effect, Semaphore } from "effect"
export interface KeyedMutex<in Key> {
readonly size: Effect.Effect<number>
readonly withLock: (key: Key) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
}
/**
* Creates an in-memory mutex with one lock per key. Entries are removed when no
* holder or waiter remains.
*
* same key -> queue
* different key -> run independently
*
* `users` counts holders and waiters so an entry is not removed while a waiter
* will reuse it.
*/
export const makeUnsafe = <Key>(): KeyedMutex<Key> => {
const locks = new Map<Key, { readonly semaphore: Semaphore.Semaphore; users: number }>()
const withLock =
(key: Key) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.suspend(() => {
const current = locks.get(key)
const entry = current ?? { semaphore: Semaphore.makeUnsafe(1), users: 0 }
if (!current) locks.set(key, entry)
entry.users++
return entry.semaphore.withPermit(effect).pipe(
Effect.ensuring(
Effect.sync(() => {
entry.users--
if (entry.users === 0) locks.delete(key)
}),
),
)
})
return { size: Effect.sync(() => locks.size), withLock }
}
/** Creates an in-memory keyed mutex inside an Effect workflow. */
export const make = <Key>(): Effect.Effect<KeyedMutex<Key>> => Effect.sync(makeUnsafe<Key>)
+235 -504
View File
@@ -1,30 +1,19 @@
export * as EventV2 from "./event"
import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { and, asc, eq, gt } from "drizzle-orm"
import { Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { eq } from "drizzle-orm"
import { Database } from "./database/database"
import { EventSequenceTable, EventTable } from "./event/sql"
import { Location } from "./location"
import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema"
import { withStatics } from "./schema"
import { Identifier } from "./util/identifier"
import { isDeepStrictEqual } from "node:util"
export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
export const ID = Schema.String.pipe(
Schema.brand("Event.ID"),
withStatics((schema) => ({
create: () => schema.make("evt_" + Identifier.ascending()),
fromExternal: (input: ExternalID) => schema.make(externalID("evt", input)),
})),
withStatics((schema) => ({ create: () => schema.make("evt_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
/**
* Durable aggregate continuation position for embedded replay streams.
* TODO: Decide whether a future HTTP / SDK surface should expose an opaque cursor instead.
*/
export const Cursor = NonNegativeInt.pipe(Schema.brand("EventV2.Cursor"))
export type Cursor = typeof Cursor.Type
export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = {
readonly type: Type
readonly sync?: {
@@ -40,8 +29,6 @@ export type Payload<D extends Definition = Definition> = {
readonly id: ID
readonly type: D["type"]
readonly data: Data<D>
/** Durable aggregate order, populated while synchronized events are projected. */
readonly seq?: number
readonly version?: number
readonly location?: Location.Ref
readonly metadata?: Record<string, unknown>
@@ -49,7 +36,6 @@ export type Payload<D extends Definition = Definition> = {
export type Projector<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
type AnyProjector = (event: Payload) => Effect.Effect<void>
export type CommitGuard = (event: Payload) => Effect.Effect<void>
export type Listener = (event: Payload) => Effect.Effect<void>
export type Sync = (event: Payload) => Effect.Effect<void>
export type Unsubscribe = Effect.Effect<void>
@@ -62,11 +48,6 @@ export type SerializedEvent = {
readonly data: Record<string, unknown>
}
export type CursorEvent<E extends Payload = Payload> = {
readonly cursor: Cursor
readonly event: E
}
export class InvalidSyncEventError extends Schema.TaggedErrorClass<InvalidSyncEventError>()(
"EventV2.InvalidSyncEvent",
{
@@ -80,15 +61,7 @@ export function versionedType(type: string, version: number) {
}
export const registry = new Map<string, Definition>()
type SyncDefinition = Definition & {
readonly sync: NonNullable<Definition["sync"]>
readonly encode: (data: unknown) => unknown
readonly decode: (data: unknown) => unknown
}
const syncRegistry = new Map<string, SyncDefinition>()
// Synchronized events cross a JSON boundary, so their data schemas must encode and decode without services.
const syncCodec = (definition: Definition) => definition.data as Schema.Codec<unknown, unknown, never, never>
const syncRegistry = new Map<string, Definition & { readonly sync: NonNullable<Definition["sync"]> }>()
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
readonly type: Type
@@ -120,10 +93,7 @@ export function define<const Type extends string, Fields extends Schema.Struct.F
if (input.sync)
syncRegistry.set(
versionedType(input.type, input.sync.version),
Object.assign(definition, {
encode: Schema.encodeUnknownSync(syncCodec(definition)),
decode: Schema.decodeUnknownSync(syncCodec(definition)),
}) as SyncDefinition,
definition as Definition & { readonly sync: NonNullable<Definition["sync"]> },
)
return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> &
Definition<Type, Schema.Struct<Fields>>
@@ -147,21 +117,16 @@ export interface Interface {
) => Effect.Effect<Payload<D>>
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
readonly all: () => Stream.Stream<Payload>
readonly aggregateEvents: (input: {
readonly aggregateID: string
readonly after?: Cursor
}) => Stream.Stream<CursorEvent>
readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
readonly listen: (listener: Listener) => Effect.Effect<Unsubscribe>
readonly beforeCommit: (guard: CommitGuard) => Effect.Effect<void>
readonly project: <D extends Definition>(definition: D, projector: Projector<D>) => Effect.Effect<void>
readonly replay: (
event: SerializedEvent,
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
options?: { readonly publish?: boolean; readonly ownerID?: string },
) => Effect.Effect<void>
readonly replayAll: (
events: SerializedEvent[],
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
options?: { readonly publish?: boolean; readonly ownerID?: string },
) => Effect.Effect<string | undefined>
readonly remove: (aggregateID: string) => Effect.Effect<void>
readonly claim: (aggregateID: string, ownerID: string) => Effect.Effect<void>
@@ -169,495 +134,261 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
export interface LayerOptions {
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const all = yield* PubSub.unbounded<Payload>()
const typed = new Map<string, PubSub.PubSub<Payload>>()
const projectors = new Map<string, AnyProjector[]>()
const listeners = new Array<Listener>()
const syncHandlers = new Array<Sync>()
const { db } = yield* Database.Service
export const layerWith = (options?: LayerOptions) =>
Layer.effect(
Service,
Effect.gen(function* () {
const all = yield* PubSub.unbounded<Payload>()
const synchronized = new Map<string, Set<PubSub.PubSub<void>>>()
const typed = new Map<string, PubSub.PubSub<Payload>>()
const projectors = new Map<string, AnyProjector[]>()
const commitGuards = new Array<CommitGuard>()
const listeners = new Array<Listener>()
const syncHandlers = new Array<Sync>()
const { db } = yield* Database.Service
const getOrCreate = (definition: Definition) =>
Effect.gen(function* () {
const existing = typed.get(definition.type)
if (existing) return existing
const pubsub = yield* PubSub.unbounded<Payload>()
typed.set(definition.type, pubsub)
return pubsub
})
const getOrCreate = (definition: Definition) =>
Effect.gen(function* () {
const existing = typed.get(definition.type)
if (existing) return existing
const pubsub = yield* PubSub.unbounded<Payload>()
typed.set(definition.type, pubsub)
return pubsub
})
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
yield* PubSub.shutdown(all)
yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true })
}),
)
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
yield* PubSub.shutdown(all)
yield* Effect.forEach(
synchronized.values(),
(pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }),
{ discard: true },
)
yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true })
}),
)
function commitSyncEvent(
event: Payload,
input?: {
readonly seq: number
readonly aggregateID: string
readonly ownerID?: string
readonly strictOwner?: boolean
},
) {
return Effect.gen(function* () {
const definition = registry.get(event.type)
const sync = definition?.sync
if (sync) {
if (event.version !== sync.version) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Expected event version ${sync.version}, got ${event.version}`,
}),
)
}
const aggregateID = (event.data as Record<string, unknown>)[sync.aggregate]
if (typeof aggregateID !== "string") {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Expected string aggregate field ${sync.aggregate}`,
}),
)
} else {
if (input && input.aggregateID !== aggregateID) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
}),
)
}
const list = projectors.get(event.type) ?? []
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const committed = yield* db
.transaction(
() =>
Effect.gen(function* () {
const row = yield* db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
const latest = row?.seq ?? -1
const encoded = syncRegistry
.get(versionedType(definition.type, sync.version))!
.encode(event.data) as Record<string, unknown>
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
}),
)
}
if (input && input.seq <= latest) {
const stored = yield* db
.select()
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq)))
.get()
.pipe(Effect.orDie)
if (
stored?.id === event.id &&
stored.type === versionedType(definition.type, sync.version) &&
isDeepStrictEqual(stored.data, encoded)
) {
if (input.ownerID && row?.ownerID == null) {
yield* db
.update(EventSequenceTable)
.set({ owner_id: input.ownerID })
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.run()
.pipe(Effect.orDie)
}
return
}
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
}),
)
}
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
return
}
const seq = input?.seq ?? latest + 1
if (input && seq !== latest + 1) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
}),
)
}
const stored = yield* db
.select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
.from(EventTable)
.where(eq(EventTable.id, event.id))
.get()
.pipe(Effect.orDie)
if (stored)
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
}),
)
for (const guard of commitGuards) {
yield* guard(event)
}
for (const projector of list) {
yield* projector({ ...event, seq } as Payload)
}
yield* db
.insert(EventSequenceTable)
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: {
seq,
...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
},
})
.run()
.pipe(Effect.orDie)
yield* db
.insert(EventTable)
.values([
{
id: event.id,
aggregate_id: aggregateID,
seq,
type: versionedType(definition.type, sync.version),
data: encoded,
},
])
.run()
.pipe(Effect.orDie)
return { aggregateID, seq }
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
if (committed) {
yield* Effect.forEach(
synchronized.get(committed.aggregateID) ?? [],
(pubsub) => PubSub.publish(pubsub, undefined),
{ discard: true },
)
}
return committed
}),
)
}
}
})
}
function publishEvent<D extends Definition>(event: Payload<D>) {
return Effect.gen(function* () {
const durable = registry.get(event.type)?.sync !== undefined
if (durable) {
const committed = yield* commitSyncEvent(event as Payload)
if (committed) {
event = { ...event, seq: committed.seq }
yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true })
yield* notify(event as Payload, true)
return event
}
}
yield* notify(event as Payload, false)
return event
})
}
const observe = (event: Payload, kind: "sync" | "listener", observer: (event: Payload) => Effect.Effect<void>) =>
Effect.suspend(() => observer(event)).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
(cause) =>
Effect.logError("Event observer failed").pipe(
Effect.annotateLogs({ eventID: event.id, eventType: event.type, kind, cause }),
),
),
)
function notify(event: Payload, isolateListeners: boolean) {
return Effect.gen(function* () {
yield* Effect.forEach(
listeners,
(listener) => (isolateListeners ? observe(event, "listener", listener) : listener(event)),
{ discard: true },
)
const pubsub = typed.get(event.type)
if (pubsub) yield* PubSub.publish(pubsub, event)
yield* PubSub.publish(all, event)
})
}
function publish<D extends Definition>(definition: D, data: Data<D>, options?: PublishOptions) {
return Effect.gen(function* () {
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
const location =
options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined)
return yield* publishEvent({
id: options?.id ?? ID.create(),
...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type,
...(definition.sync === undefined ? {} : { version: definition.sync.version }),
...(location ? { location } : {}),
data,
} as Payload<D>)
})
}
function replay(
event: SerializedEvent,
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
) {
return Effect.gen(function* () {
const definition = syncRegistry.get(event.type)
if (!definition) {
yield* Effect.die(
new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` }),
)
} else {
const payload = {
id: event.id,
type: definition.type,
version: definition.sync.version,
data: definition.decode(event.data),
} as Payload
const committed = yield* commitSyncEvent(payload, {
seq: event.seq,
aggregateID: event.aggregateID,
ownerID: options?.ownerID,
strictOwner: options?.strictOwner,
})
if (committed && options?.publish) {
yield* notify({ ...payload, seq: committed.seq }, true)
}
}
})
}
function replayAll(
events: SerializedEvent[],
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
) {
return Effect.gen(function* () {
const source = events[0]?.aggregateID
if (!source) return undefined
if (events.some((event) => event.aggregateID !== source)) {
function commitSyncEvent(
event: Payload,
input?: { readonly seq: number; readonly aggregateID: string; readonly ownerID?: string },
) {
return Effect.gen(function* () {
const definition = registry.get(event.type)
const sync = definition?.sync
if (sync) {
if (event.version !== sync.version) {
yield* Effect.die(
new InvalidSyncEventError({
type: events[0]?.type ?? "unknown",
message: "Replay events must belong to the same aggregate",
type: event.type,
message: `Expected event version ${sync.version}, got ${event.version}`,
}),
)
}
const start = events[0]?.seq ?? 0
for (const [index, event] of events.entries()) {
const seq = start + index
if (event.seq !== seq) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`,
}),
const aggregateID = (event.data as Record<string, unknown>)[sync.aggregate]
if (typeof aggregateID !== "string") {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Expected string aggregate field ${sync.aggregate}`,
}),
)
} else {
const list = projectors.get(event.type) ?? []
yield* db
.transaction(
() =>
Effect.gen(function* () {
const row = yield* db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
const latest = row?.seq ?? -1
if (input && input.seq <= latest) return
if (input && row?.ownerID && row.ownerID !== input.ownerID) return
const seq = input?.seq ?? latest + 1
if (input && seq !== latest + 1) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
}),
)
}
for (const projector of list) {
yield* projector(event as Payload)
}
yield* db
.insert(EventSequenceTable)
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq },
})
.run()
.pipe(Effect.orDie)
yield* db
.insert(EventTable)
.values([
{
id: event.id,
aggregate_id: aggregateID,
seq,
type: versionedType(definition.type, sync.version),
data: event.data as Record<string, unknown>,
},
])
.run()
.pipe(Effect.orDie)
}),
{ behavior: "immediate" },
)
}
.pipe(Effect.orDie)
}
for (const event of events) {
yield* replay(event, options)
}
return source
})
}
}
})
}
function remove(aggregateID: string) {
return db
.transaction(() =>
Effect.gen(function* () {
yield* db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run()
yield* db.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
}),
)
.pipe(Effect.orDie)
}
function publishEvent<D extends Definition>(event: Payload<D>) {
return Effect.gen(function* () {
for (const sync of syncHandlers) {
yield* sync(event as Payload)
}
yield* commitSyncEvent(event as Payload)
for (const listener of listeners) {
yield* listener(event as Payload)
}
const pubsub = typed.get(event.type)
if (pubsub) yield* PubSub.publish(pubsub, event as Payload)
yield* PubSub.publish(all, event as Payload)
return event
})
}
function claim(aggregateID: string, ownerID: string) {
return db
.update(EventSequenceTable)
.set({ owner_id: ownerID })
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.run()
.pipe(Effect.orDie)
}
function publish<D extends Definition>(definition: D, data: Data<D>, options?: PublishOptions) {
return Effect.gen(function* () {
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
const location =
options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined)
return yield* publishEvent({
id: options?.id ?? ID.create(),
...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type,
...(definition.sync === undefined ? {} : { version: definition.sync.version }),
...(location ? { location } : {}),
data,
} as Payload<D>)
})
}
const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe(
Stream.map((event) => event as Payload<D>),
)
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all)
const decodeSerializedEvent = (event: SerializedEvent): CursorEvent => {
function replay(event: SerializedEvent, options?: { readonly publish?: boolean; readonly ownerID?: string }) {
return Effect.gen(function* () {
const definition = syncRegistry.get(event.type)
if (!definition) {
throw new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` })
}
return {
cursor: Cursor.make(event.seq),
event: {
yield* Effect.die(
new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` }),
)
} else {
const payload = {
id: event.id,
type: definition.type,
version: definition.sync.version,
seq: event.seq,
data: definition.decode(event.data),
},
data: event.data,
} as Payload
yield* commitSyncEvent(payload, { seq: event.seq, aggregateID: event.aggregateID, ownerID: options?.ownerID })
if (options?.publish) {
for (const listener of listeners) {
yield* listener(payload)
}
const pubsub = typed.get(payload.type)
if (pubsub) yield* PubSub.publish(pubsub, payload)
yield* PubSub.publish(all, payload)
}
}
}
})
}
const readAfter = (aggregateID: string, after: number) =>
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
Effect.andThen(
db
.select()
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, aggregateID), gt(EventTable.seq, after)))
.orderBy(asc(EventTable.seq))
.all(),
),
Effect.orDie,
Effect.map((rows) =>
rows.map((event) =>
decodeSerializedEvent({
id: event.id,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
}),
),
),
)
const subscribeSynchronized = (aggregateID: string) =>
Effect.gen(function* () {
const pubsub = yield* PubSub.sliding<void>(1)
const subscription = yield* PubSub.subscribe(pubsub)
yield* Effect.acquireRelease(
Effect.sync(() => {
const pubsubs = synchronized.get(aggregateID) ?? new Set()
pubsubs.add(pubsub)
synchronized.set(aggregateID, pubsubs)
function replayAll(events: SerializedEvent[], options?: { readonly publish?: boolean; readonly ownerID?: string }) {
return Effect.gen(function* () {
const source = events[0]?.aggregateID
if (!source) return undefined
if (events.some((event) => event.aggregateID !== source)) {
yield* Effect.die(
new InvalidSyncEventError({
type: events[0]?.type ?? "unknown",
message: "Replay events must belong to the same aggregate",
}),
() =>
Effect.sync(() => {
const pubsubs = synchronized.get(aggregateID)
pubsubs?.delete(pubsub)
if (pubsubs?.size === 0) synchronized.delete(aggregateID)
}).pipe(Effect.andThen(PubSub.shutdown(pubsub))),
)
return subscription
})
}
const start = events[0]?.seq ?? 0
for (const [index, event] of events.entries()) {
const seq = start + index
if (event.seq !== seq) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`,
}),
)
}
}
for (const event of events) {
yield* replay(event, options)
}
return source
})
}
const streamEvents = (input: {
readonly aggregateID: string
readonly after?: Cursor
}): Stream.Stream<CursorEvent> =>
Stream.unwrap(
function remove(aggregateID: string) {
return db
.transaction(() =>
Effect.gen(function* () {
const synchronized = yield* subscribeSynchronized(input.aggregateID)
let cursor = input.after ?? -1
const read = Effect.suspend(() => readAfter(input.aggregateID, cursor)).pipe(
Effect.tap((events) =>
Effect.sync(() => {
cursor = events.at(-1)?.cursor ?? cursor
}),
),
)
const historical = yield* read
const live = Stream.fromSubscription(synchronized).pipe(
Stream.mapEffect(() => read),
Stream.flattenIterable,
)
return Stream.concat(Stream.fromIterable(historical), live)
yield* db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run()
yield* db.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
}),
)
.pipe(Effect.orDie)
}
const listen = (listener: Listener): Effect.Effect<Unsubscribe> =>
Effect.sync(() => {
listeners.push(listener)
return Effect.sync(() => {
const index = listeners.indexOf(listener)
if (index >= 0) listeners.splice(index, 1)
})
function claim(aggregateID: string, ownerID: string) {
return db
.update(EventSequenceTable)
.set({ owner_id: ownerID })
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.run()
.pipe(Effect.orDie)
}
const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe(
Stream.map((event) => event as Payload<D>),
)
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all)
const listen = (listener: Listener): Effect.Effect<Unsubscribe> =>
Effect.sync(() => {
listeners.push(listener)
return Effect.sync(() => {
const index = listeners.indexOf(listener)
if (index >= 0) listeners.splice(index, 1)
})
const sync = (handler: Sync): Effect.Effect<Unsubscribe> =>
Effect.sync(() => {
syncHandlers.push(handler)
return Effect.sync(() => {
const index = syncHandlers.indexOf(handler)
if (index >= 0) syncHandlers.splice(index, 1)
})
})
const beforeCommit = (guard: CommitGuard): Effect.Effect<void> =>
Effect.sync(() => {
commitGuards.push(guard)
})
const project = <D extends Definition>(definition: D, projector: Projector<D>): Effect.Effect<void> =>
Effect.sync(() => {
const list = projectors.get(definition.type) ?? []
list.push((event) => projector(event as Payload<D>))
projectors.set(definition.type, list)
})
return Service.of({
publish,
subscribe,
all: streamAll,
aggregateEvents: streamEvents,
sync,
listen,
beforeCommit,
project,
replay,
replayAll,
remove,
claim,
})
}),
)
export const layer = layerWith()
const sync = (handler: Sync): Effect.Effect<Unsubscribe> =>
Effect.sync(() => {
syncHandlers.push(handler)
return Effect.sync(() => {
const index = syncHandlers.indexOf(handler)
if (index >= 0) syncHandlers.splice(index, 1)
})
})
const project = <D extends Definition>(definition: D, projector: Projector<D>): Effect.Effect<void> =>
Effect.sync(() => {
const list = projectors.get(definition.type) ?? []
list.push((event) => projector(event as Payload<D>))
projectors.set(definition.type, list)
})
return Service.of({ publish, subscribe, all: streamAll, sync, listen, project, replay, replayAll, remove, claim })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
+10 -17
View File
@@ -1,4 +1,4 @@
import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core"
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import type { EventV2 } from "../event"
export const EventSequenceTable = sqliteTable("event_sequence", {
@@ -7,19 +7,12 @@ export const EventSequenceTable = sqliteTable("event_sequence", {
owner_id: text(),
})
export const EventTable = sqliteTable(
"event",
{
id: text().$type<EventV2.ID>().primaryKey(),
aggregate_id: text()
.notNull()
.references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }),
seq: integer().notNull(),
type: text().notNull(),
data: text({ mode: "json" }).$type<Record<string, unknown>>().notNull(),
},
(table) => [
uniqueIndex("event_aggregate_seq_idx").on(table.aggregate_id, table.seq),
index("event_aggregate_type_seq_idx").on(table.aggregate_id, table.type, table.seq),
],
)
export const EventTable = sqliteTable("event", {
id: text().$type<EventV2.ID>().primaryKey(),
aggregate_id: text()
.notNull()
.references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }),
seq: integer().notNull(),
type: text().notNull(),
data: text({ mode: "json" }).$type<Record<string, unknown>>().notNull(),
})
-212
View File
@@ -1,212 +0,0 @@
export * as FileMutation from "./file-mutation"
import { Context, Effect, Layer, Schema } from "effect"
import { dirname } from "path"
import { KeyedMutex } from "./effect/keyed-mutex"
import { FSUtil } from "./fs-util"
import { LocationMutation } from "./location-mutation"
export interface WriteInput {
readonly plan: LocationMutation.Plan
readonly content: string | Uint8Array
}
export interface TextWriteInput {
readonly plan: LocationMutation.Plan
readonly content: string
}
export interface ConditionalWriteInput extends WriteInput {
readonly expected: Uint8Array
}
export interface RemoveInput {
readonly plan: LocationMutation.Plan
}
export class StaleContentError extends Schema.TaggedErrorClass<StaleContentError>()("FileMutation.StaleContentError", {
path: Schema.String,
}) {}
export class TargetExistsError extends Schema.TaggedErrorClass<TargetExistsError>()("FileMutation.TargetExistsError", {
path: Schema.String,
}) {}
export interface WriteResult {
readonly operation: "write"
/** Canonical target actually passed to the filesystem mutation. */
readonly target: string
/** Permission resource captured during planning. */
readonly resource: string
readonly existed: boolean
}
export interface RemoveResult {
readonly operation: "remove"
/** Canonical target actually passed to the filesystem mutation. */
readonly target: string
/** Permission resource captured during planning. */
readonly resource: string
readonly existed: boolean
}
export interface Interface {
/** Create only while the planned target remains absent. */
readonly create: (
input: WriteInput,
) => Effect.Effect<WriteResult, TargetExistsError | LocationMutation.RevalidationError | FSUtil.Error>
/** Write after immediately revalidating the planned target. */
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, LocationMutation.RevalidationError | FSUtil.Error>
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
readonly writeTextPreservingBom: (
input: TextWriteInput,
) => Effect.Effect<WriteResult, LocationMutation.RevalidationError | FSUtil.Error>
/** Commit only if an existing target still has the expected bytes. */
readonly writeIfUnchanged: (
input: ConditionalWriteInput,
) => Effect.Effect<WriteResult, StaleContentError | LocationMutation.RevalidationError | FSUtil.Error>
/** Remove after immediately revalidating the planned target. */
readonly remove: (
input: RemoveInput,
) => Effect.Effect<RemoveResult, LocationMutation.RevalidationError | FSUtil.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileMutation") {}
/**
* Commit planned file changes.
*
* resolve(path) -> approve -> lock target -> revalidate(plan) -> mutate
*
* The caller approves the plan first. This service locks the canonical target,
* revalidates the plan immediately before the filesystem operation, then mutates.
*
* `writeIfUnchanged` compares and writes while holding the same in-memory lock,
* so cooperating calls in this process cannot overwrite from the same stale
* content. Locks apply only within this service layer and only to identical
* canonical targets.
*
* Revalidation reduces the race window but is not atomic with the next
* path-based filesystem operation. A hostile local process can still race it.
*
* TODO: Use descriptor-relative no-follow operations where supported to close
* the final race.
*/
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const mutation = yield* LocationMutation.Service
const locks = KeyedMutex.makeUnsafe<string>()
const withTargetLock =
(target: string) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
locks.withLock(target)(Effect.uninterruptible(effect))
const withValidatedTarget =
(plan: LocationMutation.Plan) =>
<A, E, R>(commit: (target: LocationMutation.Target) => Effect.Effect<A, E, R>) =>
withTargetLock(plan.target.canonical)(mutation.revalidate(plan).pipe(Effect.flatMap(commit)))
const writeResult = (target: LocationMutation.Target, existed = target.exists): WriteResult => ({
operation: "write",
target: target.canonical,
resource: target.resource,
existed,
})
const removeResult = (target: LocationMutation.Target): RemoveResult => ({
operation: "remove",
target: target.canonical,
resource: target.resource,
existed: target.exists,
})
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
withValidatedTarget(input.plan)((target) =>
Effect.gen(function* () {
yield* fs.writeWithDirs(target.canonical, input.content)
return writeResult(target)
}),
),
)
const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) =>
withValidatedTarget(input.plan)((target) =>
Effect.gen(function* () {
const next = splitBom(input.content)
const preserveBom = target.exists && hasUtf8Bom(yield* fs.readFile(target.canonical))
yield* fs.writeWithDirs(target.canonical, joinBom(next.text, preserveBom || next.bom))
return writeResult(target)
}),
),
)
const create = Effect.fn("FileMutation.create")((input: WriteInput) =>
withValidatedTarget(input.plan)((target) =>
Effect.gen(function* () {
if (target.exists) return yield* new TargetExistsError({ path: target.canonical })
yield* fs.ensureDir(dirname(target.canonical))
if (typeof input.content === "string")
yield* fs.writeFileString(target.canonical, input.content, { flag: "wx" })
else yield* fs.writeFile(target.canonical, input.content, { flag: "wx" })
return writeResult(target, false)
}),
),
)
const writeIfUnchanged = Effect.fn("FileMutation.writeIfUnchanged")((input: ConditionalWriteInput) =>
withValidatedTarget(input.plan)((target) =>
Effect.gen(function* () {
const current = yield* fs.readFile(target.canonical)
if (!sameBytes(current, input.expected)) return yield* new StaleContentError({ path: target.canonical })
yield* fs.writeWithDirs(target.canonical, input.content)
return writeResult(target)
}),
),
)
const remove = Effect.fn("FileMutation.remove")((input: RemoveInput) =>
withValidatedTarget(input.plan)((target) =>
Effect.gen(function* () {
yield* fs.remove(target.canonical)
return removeResult(target)
}),
),
)
return Service.of({ create, write, writeTextPreservingBom, writeIfUnchanged, remove })
}),
)
function splitBom(text: string) {
const stripped = text.replace(/^\uFEFF+/, "")
return { bom: stripped.length !== text.length, text: stripped }
}
function joinBom(text: string, bom: boolean) {
const stripped = splitBom(text).text
return bom ? `\uFEFF${stripped}` : stripped
}
function hasUtf8Bom(content: Uint8Array) {
return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
}
function sameBytes(left: Uint8Array, right: Uint8Array) {
if (left.length !== right.length) return false
return left.every((byte, index) => byte === right[index])
}
export const locationLayer = layer
/**
* Deferred until the corresponding V2 integrations exist.
*/
// TODO: Add formatter integration after V2 formatter runtime exists.
// TODO: Publish watcher/file-edit events after V2 watcher integration exists.
// TODO: Add snapshots / undo after V2 snapshot design exists.
// TODO: Notify LSP and collect diagnostics after V2 LSP runtime exists.
// TODO: Design multi-file transactions / rollback if apply_patch needs atomic edits.
// Until then, edits are sequential and report partial application.
// TODO: Define crash recovery and idempotency for side effects between Tool.Called and durable settlement.
+31 -335
View File
@@ -4,7 +4,7 @@ import path from "path"
import { pathToFileURL } from "url"
import fuzzysort from "fuzzysort"
import ignore from "ignore"
import { Context, Effect, Layer, Option, Schema, Stream } from "effect"
import { Context, Effect, Layer, Schema, Stream } from "effect"
import { EventV2 } from "./event"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
@@ -16,22 +16,17 @@ import { Ripgrep } from "./filesystem/ripgrep"
export const ReadInput = Schema.Struct({
path: RelativePath,
reference: Schema.NonEmptyString.pipe(Schema.optional),
reference: Schema.String.pipe(Schema.optional),
})
export type ReadInput = typeof ReadInput.Type
export const MAX_READ_LINES = 2_000
export const MAX_READ_BYTES = 50 * 1024
const MAX_LINE_LENGTH = 2_000
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
export class TextContent extends Schema.Class<TextContent>("FileSystem.TextContent")({
export class TextContent extends Schema.Class<TextContent>("LocationFileSystem.TextContent")({
type: Schema.Literal("text"),
content: Schema.String,
mime: Schema.String,
}) {}
export class BinaryContent extends Schema.Class<BinaryContent>("FileSystem.BinaryContent")({
export class BinaryContent extends Schema.Class<BinaryContent>("LocationFileSystem.BinaryContent")({
type: Schema.Literal("binary"),
content: Schema.String,
encoding: Schema.Literal("base64"),
@@ -41,80 +36,19 @@ export class BinaryContent extends Schema.Class<BinaryContent>("FileSystem.Binar
export const Content = Schema.Union([TextContent, BinaryContent]).pipe(Schema.toTaggedUnion("type"))
export type Content = typeof Content.Type
export const TextPageInput = Schema.Struct({
offset: PositiveInt.pipe(Schema.optional),
limit: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES)).pipe(Schema.optional),
})
export type TextPageInput = typeof TextPageInput.Type
export class TextPage extends Schema.Class<TextPage>("FileSystem.TextPage")({
type: Schema.Literal("text-page"),
content: Schema.String,
mime: Schema.String,
offset: PositiveInt,
truncated: Schema.Boolean,
next: PositiveInt.pipe(Schema.optional),
}) {}
export class ReadTarget extends Schema.Class<ReadTarget>("FileSystem.ReadTarget")({
real: Schema.String,
resource: Schema.String,
size: NonNegativeInt,
dev: Schema.Number,
ino: Schema.Number.pipe(Schema.optional),
}) {}
export const ListInput = Schema.Struct({
path: RelativePath.pipe(Schema.optional),
reference: Schema.NonEmptyString.pipe(Schema.optional),
reference: Schema.String.pipe(Schema.optional),
})
export type ListInput = typeof ListInput.Type
export const ListPageInput = Schema.Struct({
...ListInput.fields,
offset: PositiveInt.pipe(Schema.optional),
limit: PositiveInt.check(Schema.isLessThanOrEqualTo(2_000)).pipe(Schema.optional),
})
export type ListPageInput = typeof ListPageInput.Type
export class ListTarget extends Schema.Class<ListTarget>("FileSystem.ListTarget")({
absolute: Schema.String,
real: Schema.String,
directory: Schema.String,
root: Schema.String,
resource: Schema.String,
}) {}
/** Canonical read authority for Location-scoped search and metadata leaves. */
export class RootTarget extends Schema.Class<RootTarget>("FileSystem.RootTarget")({
absolute: Schema.String,
real: Schema.String,
directory: Schema.String,
root: Schema.String,
resource: Schema.String,
reference: Schema.NonEmptyString.pipe(Schema.optional),
type: Schema.Literals(["file", "directory"]),
dev: Schema.Number,
ino: Schema.Number.pipe(Schema.optional),
}) {}
export type ReadPathTarget =
| { readonly type: "file"; readonly target: ReadTarget }
| { readonly type: "directory"; readonly target: ListTarget }
export class Entry extends Schema.Class<Entry>("FileSystem.Entry")({
export class Entry extends Schema.Class<Entry>("LocationFileSystem.Entry")({
path: RelativePath,
uri: Schema.String,
type: Schema.Literals(["file", "directory"]),
mime: Schema.String,
}) {}
export class ListPage extends Schema.Class<ListPage>("FileSystem.ListPage")({
entries: Schema.Array(Entry),
truncated: Schema.Boolean,
next: PositiveInt.pipe(Schema.optional),
}) {}
export const FindInput = Schema.Struct({
query: Schema.String,
type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
@@ -129,7 +63,7 @@ export const GrepInput = Schema.Struct({
})
export type GrepInput = typeof GrepInput.Type
export class GrepMatch extends Schema.Class<GrepMatch>("FileSystem.GrepMatch")({
export class GrepMatch extends Schema.Class<GrepMatch>("LocationFileSystem.GrepMatch")({
path: RelativePath,
lines: Schema.String,
line: PositiveInt,
@@ -154,21 +88,7 @@ export const Event = {
export interface Interface {
readonly read: (input: ReadInput) => Effect.Effect<Content>
readonly resolveReadPath: (input: ReadInput) => Effect.Effect<ReadPathTarget>
readonly resolveRead: (input: ReadInput) => Effect.Effect<ReadTarget>
readonly readResolved: (target: ReadTarget, maximumBytes?: number) => Effect.Effect<Content>
readonly readTextPageResolved: (target: ReadTarget, page?: TextPageInput) => Effect.Effect<TextPage>
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
/** Select a contained canonical read root without asserting leaf policy. */
readonly resolveRoot: (input?: ListInput) => Effect.Effect<RootTarget>
readonly revalidateRoot: (target: RootTarget) => Effect.Effect<RootTarget>
readonly resolveList: (input?: ListInput) => Effect.Effect<ListTarget>
readonly listResolved: (target: ListTarget) => Effect.Effect<Entry[]>
readonly listPage: (input?: ListPageInput) => Effect.Effect<ListPage>
readonly listPageResolved: (
target: ListTarget,
page?: Pick<ListPageInput, "offset" | "limit">,
) => Effect.Effect<ListPage>
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
readonly grep: (input: GrepInput) => Effect.Effect<GrepMatch[]>
readonly isIgnored: (path: RelativePath, type: "file" | "directory") => boolean
@@ -263,36 +183,13 @@ export const layer = Layer.effect(
return [...files, ...dirs]
})
const resolveReadPath = Effect.fn("FileSystem.resolveReadPath")(function* (input: ReadInput) {
const file = yield* resolve(input.path, input.reference)
const info = yield* fs.stat(file.real).pipe(Effect.orDie)
const relative = path.relative(file.root, file.real).replaceAll("\\", "/")
const resource = input.reference === undefined ? relative || "." : `${input.reference}:${relative || "."}`
if (info.type === "File") {
return {
type: "file" as const,
target: new ReadTarget({
real: file.real,
resource,
size: Number(info.size),
dev: info.dev,
ino: Option.getOrUndefined(info.ino),
}),
}
}
if (info.type === "Directory") {
return { type: "directory" as const, target: new ListTarget({ ...file, resource }) }
}
return yield* Effect.die(new Error("Path is not a file or directory"))
})
const resolveRead = Effect.fn("FileSystem.resolveRead")(function* (input: ReadInput) {
const resolved = yield* resolveReadPath(input)
if (resolved.type !== "file") return yield* Effect.die(new Error("Path is not a file"))
return resolved.target
})
const content = (target: ReadTarget, bytes: Uint8Array) =>
Effect.gen(function* () {
const mime = FSUtil.mimeType(target.real)
return Service.of({
read: Effect.fn("FileSystem.read")(function* (input) {
const file = yield* resolve(input.path, input.reference)
const info = yield* fs.stat(file.real).pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
const bytes = yield* fs.readFile(file.real).pipe(Effect.orDie)
const mime = FSUtil.mimeType(file.real)
if (!bytes.includes(0)) {
const content = yield* Effect.sync(() => new TextDecoder("utf-8", { fatal: true }).decode(bytes)).pipe(
Effect.option,
@@ -305,226 +202,25 @@ export const layer = Layer.effect(
encoding: "base64",
mime,
})
})
const readResolved = Effect.fn("FileSystem.readResolved")(function* (target: ReadTarget, maximumBytes?: number) {
if (maximumBytes === undefined) return yield* content(target, yield* fs.readFile(target.real).pipe(Effect.orDie))
return yield* Effect.scoped(
Effect.gen(function* () {
const file = yield* fs.open(target.real, { flag: "r" }).pipe(Effect.orDie)
const info = yield* file.stat.pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
if (info.dev !== target.dev || Option.getOrUndefined(info.ino) !== target.ino)
return yield* Effect.die(new Error("File changed after permission approval"))
if (info.size > maximumBytes)
return yield* Effect.die(new Error(`File exceeds ${maximumBytes} byte read limit`))
const bytes = yield* file.readAlloc(maximumBytes + 1).pipe(Effect.orDie)
if (bytes._tag === "Some" && bytes.value.length > maximumBytes)
return yield* Effect.die(new Error(`File exceeds ${maximumBytes} byte read limit`))
return yield* content(target, bytes._tag === "Some" ? bytes.value : new Uint8Array())
}),
)
})
const readTextPageResolved = Effect.fn("FileSystem.readTextPageResolved")(function* (
target: ReadTarget,
page: TextPageInput = {},
) {
return yield* Effect.scoped(
Effect.gen(function* () {
const file = yield* fs.open(target.real, { flag: "r" }).pipe(Effect.orDie)
const info = yield* file.stat.pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
if (info.dev !== target.dev || Option.getOrUndefined(info.ino) !== target.ino)
return yield* Effect.die(new Error("File changed after permission approval"))
const offset = page.offset ?? 1
const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES)
const lines: string[] = []
const decoder = new TextDecoder("utf-8", { fatal: true })
let pending = ""
let discard = false
let line = 1
let bytes = 0
let found = false
let truncated = false
let next: number | undefined
const append = (input: string) => {
if (line < offset) {
line++
return true
}
if (lines.length >= limit) {
truncated = true
next = line
return false
}
found = true
const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input
const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0)
if (bytes + size > MAX_READ_BYTES) {
truncated = true
next = line
return false
}
lines.push(text)
bytes += size
line++
return true
}
let done = false
while (!done) {
const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie)
if (Option.isNone(chunk)) break
if (chunk.value.includes(0)) return yield* Effect.die(new Error("Cannot page binary file"))
let text = decoder.decode(chunk.value, { stream: true })
while (true) {
const index = text.indexOf("\n")
if (index === -1) {
if (!discard) {
pending += text
if (pending.length > MAX_LINE_LENGTH) {
pending = pending.slice(0, MAX_LINE_LENGTH + 1)
discard = true
}
}
break
}
const current = pending + (discard ? "" : text.slice(0, index))
pending = ""
discard = false
text = text.slice(index + 1)
if (!append(current.endsWith("\r") ? current.slice(0, -1) : current)) {
done = true
break
}
}
}
if (!done) {
const tail = decoder.decode()
if (!discard) pending += tail
if (pending && !append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)) done = true
}
if (!done && !found && offset !== 1) return yield* Effect.die(new Error(`Offset ${offset} is out of range`))
return new TextPage({
type: "text-page",
content: lines.join("\n"),
mime: FSUtil.mimeType(target.real),
offset,
truncated,
...(next === undefined ? {} : { next }),
})
}),
)
})
const resolveList = Effect.fn("FileSystem.resolveList")(function* (input: ListInput = {}) {
const directory = yield* resolve(input.path, input.reference)
const info = yield* fs.stat(directory.real).pipe(Effect.orDie)
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
const relative = path.relative(directory.root, directory.real).replaceAll("\\", "/") || "."
return new ListTarget({
...directory,
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
})
})
const resolveRoot = Effect.fn("FileSystem.resolveRoot")(function* (input: ListInput = {}) {
const target = yield* resolve(input.path, input.reference)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
if (!type) return yield* Effect.die(new Error("Path is not a file or directory"))
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
return new RootTarget({
...target,
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
reference: input.reference,
type,
dev: info.dev,
ino: Option.getOrUndefined(info.ino),
})
})
const revalidateRoot = Effect.fn("FileSystem.revalidateRoot")(function* (target: RootTarget) {
const canonical = yield* fs.realPath(target.absolute).pipe(Effect.orDie)
if (canonical !== target.real) return yield* Effect.die(new Error("Search root changed after approval"))
const info = yield* fs.stat(canonical).pipe(Effect.orDie)
if (
info.type !== (target.type === "file" ? "File" : "Directory") ||
info.dev !== target.dev ||
Option.getOrUndefined(info.ino) !== target.ino
)
return yield* Effect.die(new Error("Search root identity changed after approval"))
return target
})
const listResolved = Effect.fn("FileSystem.listResolved")(function* (directory: ListTarget) {
return yield* fs.readDirectoryEntries(directory.real).pipe(
Effect.orDie,
Effect.flatMap((items) =>
Effect.forEach(items, (item) => entry(path.join(directory.absolute, item.name), directory), {
concurrency: "unbounded",
}),
),
Effect.map((items) =>
items
.filter((item): item is Entry => item !== undefined)
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
),
)
})
const listPageResolved = Effect.fn("FileSystem.listPageResolved")(function* (
target: ListTarget,
page: Pick<ListPageInput, "offset" | "limit"> = {},
) {
type Candidate = Entry | { readonly name: string; readonly type: "file" | "directory" }
const offset = page.offset ?? 1
const limit = Math.min(page.limit ?? 2_000, 2_000)
const items = yield* fs.readDirectoryEntries(target.real).pipe(Effect.orDie)
const candidates = yield* Effect.forEach(
items,
(item): Effect.Effect<Candidate | undefined> => {
if (item.type === "other") return Effect.succeed(undefined)
if (item.type === "symlink") return entry(path.join(target.absolute, item.name), target)
return Effect.succeed({ name: item.name, type: item.type } as const)
},
{ concurrency: 16 },
).pipe(Effect.map((items) => items.filter((item): item is Candidate => item !== undefined)))
candidates.sort((a, b) => {
return a.type === b.type
? (a instanceof Entry ? a.path : a.name).localeCompare(b instanceof Entry ? b.path : b.name)
: a.type === "directory"
? -1
: 1
})
const selected = candidates.slice(offset - 1, offset - 1 + limit)
const entries = yield* Effect.forEach(
selected,
(item) => (item instanceof Entry ? Effect.succeed(item) : entry(path.join(target.absolute, item.name), target)),
{
concurrency: 16,
},
).pipe(Effect.map((items) => items.filter((item): item is Entry => item !== undefined)))
const truncated = offset - 1 + selected.length < candidates.length
return new ListPage({ entries, truncated, ...(truncated ? { next: offset + selected.length } : {}) })
})
return Service.of({
read: Effect.fn("FileSystem.read")(function* (input) {
return yield* readResolved(yield* resolveRead(input))
}),
resolveReadPath,
resolveRead,
readResolved,
readTextPageResolved,
list: Effect.fn("FileSystem.list")(function* (input) {
return yield* listResolved(yield* resolveList(input))
list: Effect.fn("FileSystem.list")(function* (input = {}) {
const directory = yield* resolve(input.path, input.reference)
const info = yield* fs.stat(directory.real).pipe(Effect.orDie)
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
return yield* fs.readDirectoryEntries(directory.real).pipe(
Effect.orDie,
Effect.flatMap((items) =>
Effect.forEach(items, (item) => entry(path.join(directory.absolute, item.name), directory), {
concurrency: "unbounded",
}),
),
Effect.map((items) =>
items
.filter((item): item is Entry => item !== undefined)
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
),
)
}),
resolveRoot,
revalidateRoot,
resolveList,
listResolved,
listPage: Effect.fn("FileSystem.listPage")(function* (input) {
return yield* listPageResolved(yield* resolveList(input), input)
}),
listPageResolved,
find: Effect.fn("FileSystem.find")(function* (input) {
const items = (yield* scan()).filter((item) => input.type !== "file" || !item.endsWith("/"))
const filtered = items.filter((item) => input.type !== "directory" || item.endsWith("/"))
+1 -2
View File
@@ -135,7 +135,6 @@ export interface TreeInput {
}
export interface Interface {
readonly filepath: Effect.Effect<string, Error>
readonly files: (input: FilesInput) => Stream.Stream<string, PlatformError | Error>
readonly tree: (input: TreeInput) => Effect.Effect<string, PlatformError | Error>
readonly search: (input: SearchInput) => Effect.Effect<SearchResult, PlatformError | Error>
@@ -472,7 +471,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | ChildProcessSpa
return lines.join("\n")
})
return Service.of({ filepath, files, tree, search })
return Service.of({ files, tree, search })
}),
)
+1 -1
View File
@@ -1,6 +1,6 @@
import { Config } from "effect"
export function truthy(key: string) {
function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
return value === "true" || value === "1"
}
+5 -4
View File
@@ -1,5 +1,5 @@
import { NodeFileSystem } from "@effect/platform-node"
import { dirname, isAbsolute, join, relative, resolve as pathResolve, sep } from "path"
import { dirname, join, relative, resolve as pathResolve } from "path"
import { realpathSync } from "fs"
import * as NFS from "fs/promises"
import { lookup } from "mime-types"
@@ -236,11 +236,12 @@ export namespace FSUtil {
}
export function overlaps(a: string, b: string) {
return contains(a, b) || contains(b, a)
const relA = relative(a, b)
const relB = relative(b, a)
return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
}
export function contains(parent: string, child: string) {
const result = relative(parent, child)
return result === "" || (!isAbsolute(result) && result !== ".." && !result.startsWith(`..${sep}`))
return !relative(parent, child).startsWith("..")
}
}
+1 -166
View File
@@ -1,7 +1,7 @@
export * as Git from "./git"
import path from "path"
import { Context, Effect, Layer, Schema, Stream } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { AbsolutePath } from "./schema"
import { FSUtil } from "./fs-util"
@@ -33,13 +33,6 @@ export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git
cause: Schema.optional(Schema.Defect),
}) {}
export class PatchError extends Schema.TaggedErrorClass<PatchError>()("Git.PatchError", {
operation: Schema.Literals(["capture", "apply", "reset"]),
directory: AbsolutePath,
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export interface Interface {
readonly find: (input: AbsolutePath) => Effect.Effect<Repo | undefined>
readonly remote: (repo: Repo, name?: string) => Effect.Effect<string | undefined>
@@ -59,10 +52,6 @@ export interface Interface {
readonly fetchBranch: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
readonly checkout: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
readonly reset: (directory: string, target: string) => Effect.Effect<Result, AppProcess.AppProcessError>
readonly patch: (directory: AbsolutePath) => Effect.Effect<string, PatchError>
readonly applyPatch: (input: { directory: AbsolutePath; patch: string }) => Effect.Effect<void, PatchError>
readonly resetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
readonly softResetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
readonly worktreeCreate: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
readonly worktreeRemove: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
readonly worktreeList: (repo: Repo) => Effect.Effect<AbsolutePath[], WorktreeError>
@@ -170,156 +159,6 @@ export const layer = Layer.effect(
execute(directory, proc)(["reset", "--hard", target]),
)
const patch = Effect.fn("Git.patch")(function* (directory: AbsolutePath) {
const root = yield* execute(
directory,
proc,
)(["rev-parse", "--show-toplevel"]).pipe(
Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })),
)
if (root.exitCode !== 0) {
return yield* new PatchError({
operation: "capture",
directory,
message: root.stderr.trim() || root.text.trim() || "Failed to locate repository root",
})
}
const repo = AbsolutePath.make(resolvePath(directory, root.text))
const scope = path.relative(repo, directory).replaceAll("\\", "/") || "."
const tracked = yield* execute(
repo,
proc,
)(["diff", "--binary", "HEAD", "--", scope]).pipe(
Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })),
)
if (tracked.exitCode !== 0) {
return yield* new PatchError({
operation: "capture",
directory,
message: tracked.stderr.trim() || tracked.text.trim() || "Failed to capture tracked changes",
})
}
const untracked = yield* execute(
repo,
proc,
)(["ls-files", "--others", "--exclude-standard", "-z", "--", scope]).pipe(
Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })),
)
if (untracked.exitCode !== 0) {
return yield* new PatchError({
operation: "capture",
directory,
message: untracked.stderr.trim() || untracked.text.trim() || "Failed to list untracked changes",
})
}
const created = yield* Effect.forEach(untracked.text.split("\0").filter(Boolean), (file) =>
execute(
repo,
proc,
)(["diff", "--binary", "--no-index", "--", "/dev/null", file]).pipe(
Effect.mapError(
(cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause }),
),
Effect.flatMap((result) =>
// git diff --no-index returns 1 when differences were found.
result.exitCode === 0 || result.exitCode === 1
? Effect.succeed(result.text)
: Effect.fail(
new PatchError({
operation: "capture",
directory,
message:
result.stderr.trim() || result.text.trim() || `Failed to capture untracked change: ${file}`,
}),
),
),
),
)
return [tracked.text, ...created].filter(Boolean).join("\n")
})
const applyPatch = Effect.fn("Git.applyPatch")(function* (input: { directory: AbsolutePath; patch: string }) {
const result = yield* proc
.run(
ChildProcess.make("git", ["apply", "-"], {
cwd: input.directory,
extendEnv: true,
stdin: Stream.make(new TextEncoder().encode(input.patch)),
}),
)
.pipe(
Effect.mapError(
(cause) =>
new PatchError({ operation: "apply", directory: input.directory, message: cause.message, cause }),
),
)
if (result.exitCode === 0) return
return yield* new PatchError({
operation: "apply",
directory: input.directory,
message:
result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Failed to apply changes",
})
})
const resetChanges = Effect.fn("Git.resetChanges")(function* (directory: AbsolutePath) {
const reset = yield* execute(
directory,
proc,
)(["reset", "--hard", "HEAD"]).pipe(
Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
)
if (reset.exitCode !== 0) {
return yield* new PatchError({
operation: "reset",
directory,
message: reset.stderr.trim() || reset.text.trim() || "Failed to reset tracked changes",
})
}
const clean = yield* execute(
directory,
proc,
)(["clean", "-fd"]).pipe(
Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
)
if (clean.exitCode === 0) return
return yield* new PatchError({
operation: "reset",
directory,
message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes",
})
})
const softResetChanges = Effect.fn("Git.softResetChanges")(function* (directory: AbsolutePath) {
const checkout = yield* execute(
directory,
proc,
)(["checkout", "--", "."]).pipe(
Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
)
if (checkout.exitCode !== 0) {
return yield* new PatchError({
operation: "reset",
directory,
message: checkout.stderr.trim() || checkout.text.trim() || "Failed to restore tracked changes",
})
}
const clean = yield* execute(
directory,
proc,
)(["clean", "-fd", "--", "."]).pipe(
Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
)
if (clean.exitCode === 0) return
return yield* new PatchError({
operation: "reset",
directory,
message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes",
})
})
const worktree = Effect.fnUntraced(function* (
operation: "create" | "remove" | "list",
repo: Repo,
@@ -377,10 +216,6 @@ export const layer = Layer.effect(
fetchBranch,
checkout,
reset,
patch,
applyPatch,
resetChanges,
softResetChanges,
worktreeCreate,
worktreeRemove,
worktreeList,
+5 -57
View File
@@ -4,7 +4,6 @@ import { Policy } from "./policy"
import { Config } from "./config"
import { PluginV2 } from "./plugin"
import { Catalog } from "./catalog"
import { CommandV2 } from "./command"
import { AgentV2 } from "./agent"
import { PluginBoot } from "./plugin/boot"
import { Project } from "./project"
@@ -17,79 +16,32 @@ import { Global } from "./global"
import { Database } from "./database/database"
import { PermissionV2 } from "./permission"
import { PermissionSaved } from "./permission/saved"
import { SessionV2 } from "./session"
import { FileSystem } from "./filesystem"
import { Watcher } from "./filesystem/watcher"
import { LocationMutation } from "./location-mutation"
import { LocationSearch } from "./location-search"
import { FileMutation } from "./file-mutation"
import { ProjectReference } from "./project-reference"
import { RepositoryCache } from "./repository-cache"
import { Pty } from "./pty"
import { SkillV2 } from "./skill"
import { BuiltInTools } from "./tool/builtins"
import { ToolRegistry } from "./tool-registry"
import { ToolOutputStore } from "./tool-output-store"
import { AppProcess } from "./process"
import { Ripgrep } from "./ripgrep"
import { SessionStore } from "./session/store"
import { SessionTodo } from "./session/todo"
import { QuestionV2 } from "./question"
import { LLMClient } from "@opencode-ai/llm"
import { RequestExecutor } from "@opencode-ai/llm/route"
import * as SessionRunnerLLM from "./session/runner/llm"
import { SessionRunnerModel } from "./session/runner/model"
import { SessionRunCoordinator } from "./session/run-coordinator"
import { FetchHttpClient } from "effect/unstable/http"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => {
const location = Location.layer(ref)
const permissionsAndTools = ToolRegistry.layer.pipe(Layer.provideMerge(PermissionV2.locationLayer))
const services = Layer.mergeAll(
return Layer.mergeAll(
location,
Policy.locationLayer,
Config.locationLayer,
ProjectReference.locationLayer,
PluginV2.locationLayer,
Catalog.locationLayer,
CommandV2.locationLayer,
AgentV2.locationLayer,
PluginBoot.locationLayer,
PermissionV2.locationLayer,
FileSystem.locationLayer,
Watcher.locationLayer,
Pty.locationLayer,
SkillV2.locationLayer,
permissionsAndTools,
LocationMutation.locationLayer.pipe(Layer.orDie),
).pipe(Layer.provideMerge(location))
const commits = FileMutation.locationLayer.pipe(Layer.provide(services))
const searches = LocationSearch.layer.pipe(Layer.provide(Ripgrep.layer), Layer.provide(services))
const resources = ToolOutputStore.layer.pipe(Layer.provide(services))
const todos = SessionTodo.layer.pipe(Layer.provide(services))
const questions = QuestionV2.locationLayer.pipe(Layer.provide(services))
const builtInTools = BuiltInTools.locationLayer.pipe(
Layer.provide(services),
Layer.provide(commits),
Layer.provide(searches),
Layer.provide(resources),
Layer.provide(todos),
Layer.provide(questions),
)
const model = SessionRunnerModel.locationLayer.pipe(Layer.provide(services))
const runner = SessionRunnerLLM.defaultLayer.pipe(Layer.provide(services), Layer.provide(model))
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
return Layer.mergeAll(
services,
commits,
searches,
resources,
todos,
questions,
model,
runner,
coordinator,
builtInTools,
).pipe(Layer.fresh)
).pipe(Layer.provideMerge(location), Layer.fresh)
},
idleTimeToLive: "60 minutes",
dependencies: [
@@ -99,14 +51,10 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Npm.defaultLayer,
ModelsDev.defaultLayer,
FSUtil.defaultLayer,
AppProcess.defaultLayer,
Global.defaultLayer,
Database.defaultLayer,
SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
SessionV2.defaultLayer,
PermissionSaved.defaultLayer,
RepositoryCache.defaultLayer,
LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)),
FetchHttpClient.layer,
ToolOutputStore.defaultCleanupLayer,
],
}) {}
-311
View File
@@ -1,311 +0,0 @@
export * as LocationMutation from "./location-mutation"
import path from "path"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { FSUtil } from "./fs-util"
import { Location } from "./location"
export const Kind = Schema.Literals(["file", "directory"])
export type Kind = typeof Kind.Type
/**
* Mutation paths do not accept project references. Relative paths must stay
* inside the active Location. Absolute paths outside it require separate
* `external_directory` approval.
*/
export const ResolveInput = Schema.Struct({
path: Schema.String,
/** Selects the external approval boundary; it does not validate the target type. */
kind: Kind.pipe(Schema.optional),
})
export type ResolveInput = typeof ResolveInput.Type
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
path: Schema.String,
reason: Schema.Literals([
"relative_escape",
"location_escape",
"non_directory_ancestor",
"unresolved_symlink",
"location_identity_changed",
]),
}) {}
export class RevalidationError extends Schema.TaggedErrorClass<RevalidationError>()(
"LocationMutation.RevalidationError",
{
path: Schema.String,
reason: Schema.String,
},
) {}
export interface Identity {
/** Canonical path for this saved filesystem identity. */
readonly canonical: string
readonly dev: number
readonly ino?: number
}
export interface ExternalDirectoryAuthorization {
readonly action: "external_directory"
/** Canonical existing directory used as the external approval boundary. */
readonly directory: string
/** `external_directory` permission resource. */
readonly resource: string
readonly save: string
/** Saved identity checked again after approval to detect swaps. */
readonly authority: Identity
}
/** Build the `external_directory` permission request. */
export const externalDirectoryPermission = (input: ExternalDirectoryAuthorization) => ({
action: input.action,
resources: [input.resource],
save: [input.save],
})
export interface Target {
/** Canonical existing path, or missing path below a canonical directory. */
readonly canonical: string
readonly exists: boolean
readonly type?:
| "File"
| "Directory"
| "SymbolicLink"
| "BlockDevice"
| "CharacterDevice"
| "FIFO"
| "Socket"
| "Unknown"
/** Permission resource: Location-relative for internal paths, canonical for external paths. */
readonly resource: string
readonly externalDirectory?: ExternalDirectoryAuthorization
}
/**
* A path checked before permission approval.
*
* resolve(path) -> Plan -> approve -> revalidate(plan) -> mutate immediately
*
* Tools must approve `target.externalDirectory`, when present, and their normal
* mutation action before calling `revalidate`. Revalidation rejects escapes,
* symlinks in missing suffixes, and changes made while approval is pending. It
* cannot be atomic with the next filesystem call, so mutate immediately afterward.
*/
export interface Plan {
readonly input: ResolveInput
readonly target: Target
/** Saved identity of the existing target or nearest existing ancestor. */
readonly authority: Identity
}
export interface Interface {
/**
* Check a path before approval and derive its permission resources. Relative
* paths must stay inside the Location. Absolute paths outside it require
* separate `external_directory` approval. This does not approve the tool's
* mutation action.
*/
readonly resolve: (input: ResolveInput) => Effect.Effect<Plan, PathError | FSUtil.Error>
/**
* Check the plan again immediately before mutation. Reject changes to the
* target, its saved identity, or approval resources. Mutate the returned
* target immediately.
*/
readonly revalidate: (plan: Plan) => Effect.Effect<Target, RevalidationError | FSUtil.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationMutation") {}
interface ResolvedPath {
readonly canonical: string
readonly exists: boolean
readonly type?: Target["type"]
readonly authority: Identity
}
const slash = (value: string) => value.replaceAll("\\", "/")
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const locationRoot = yield* fs.realPath(location.directory)
const locationAuthority = yield* identity(locationRoot)
function identityFrom(canonical: string, info: Effect.Success<ReturnType<typeof fs.stat>>): Identity {
return {
canonical,
dev: info.dev,
ino: Option.getOrUndefined(info.ino),
}
}
function identity(canonical: string) {
return fs.stat(canonical).pipe(Effect.map((info) => identityFrom(canonical, info)))
}
function notFound<A>(effect: Effect.Effect<A, FSUtil.Error>) {
return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
}
function sameIdentity(left: Identity, right: Identity) {
return left.canonical === right.canonical && left.dev === right.dev && left.ino === right.ino
}
/** Check whether a saved path still points to the same filesystem object. */
const assertIdentity = Effect.fnUntraced(function* (expected: Identity) {
const canonical = yield* notFound(fs.realPath(expected.canonical))
if (canonical === undefined) return false
const actual = yield* notFound(identity(canonical))
if (actual === undefined) return false
return canonical === expected.canonical && sameIdentity(expected, actual)
})
const assertLocationIdentity = Effect.fnUntraced(function* (requested: string) {
if (yield* assertIdentity(locationAuthority)) return
return yield* new PathError({ path: requested, reason: "location_identity_changed" })
})
const hasUnresolvedSymlink = Effect.fnUntraced(function* (anchor: string, suffix: string) {
let current = anchor
for (const part of suffix.split(path.sep)) {
if (!part) continue
current = path.join(current, part)
if (
yield* fs.readLink(current).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
)
return true
}
return false
})
/**
* Resolve a path to a canonical target and save an existing filesystem
* identity for later revalidation.
*
* existing path -> save target identity
* missing path -> save nearest existing directory identity
*
* Missing suffixes must not contain symlinks.
*/
const resolvePath = Effect.fnUntraced(function* (absolute: string) {
const existing = yield* notFound(fs.realPath(absolute))
if (existing !== undefined) {
const info = yield* fs.stat(existing)
return {
canonical: existing,
exists: true,
type: info.type,
authority: identityFrom(existing, info),
} satisfies ResolvedPath
}
let anchor = path.dirname(absolute)
while (true) {
const canonical = yield* notFound(fs.realPath(anchor))
if (canonical !== undefined) {
const info = yield* fs.stat(canonical)
if (info.type !== "Directory")
return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
const suffix = path.relative(anchor, absolute)
if (yield* hasUnresolvedSymlink(anchor, suffix)) {
return yield* new PathError({ path: absolute, reason: "unresolved_symlink" })
}
return {
canonical: path.resolve(canonical, suffix),
exists: false,
authority: identityFrom(canonical, info),
} satisfies ResolvedPath
}
const parent = path.dirname(anchor)
if (parent === anchor) return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
anchor = parent
}
})
/**
* Choose the existing directory used for separate external approval.
*
* existing directory target -> "<target>/*"
* file or missing target -> "<nearest existing parent>/*"
*/
const externalDirectory = Effect.fnUntraced(function* (resolved: ResolvedPath, kind: Kind) {
const candidate =
kind === "directory" && resolved.type === "Directory" ? resolved.canonical : path.dirname(resolved.canonical)
const boundary = yield* resolvePath(candidate)
const directory =
boundary.exists && boundary.type === "Directory" ? boundary.canonical : boundary.authority.canonical
const resource = slash(path.join(directory, "*"))
return {
action: "external_directory" as const,
directory,
resource,
save: resource,
authority: boundary.authority,
}
})
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
yield* assertLocationIdentity(input.path)
const relative = !path.isAbsolute(input.path)
const absolute = path.resolve(location.directory, input.path)
const lexicallyInternal = FSUtil.contains(location.directory, absolute)
if (relative && !lexicallyInternal) return yield* new PathError({ path: input.path, reason: "relative_escape" })
const resolved = yield* resolvePath(absolute)
if (lexicallyInternal && !FSUtil.contains(locationRoot, resolved.canonical)) {
return yield* new PathError({ path: input.path, reason: "location_escape" })
}
const external = !lexicallyInternal
const resource = external
? slash(resolved.canonical)
: slash(path.relative(locationRoot, resolved.canonical) || ".")
const target: Target = {
canonical: resolved.canonical,
exists: resolved.exists,
type: resolved.type,
resource,
externalDirectory: external ? yield* externalDirectory(resolved, input.kind ?? "file") : undefined,
}
return { input, target, authority: resolved.authority } satisfies Plan
})
/**
* Re-resolve a plan immediately before mutation and reject any changed
* identity, target, or approval resource. This reduces the race window but
* cannot make the next filesystem call atomic.
*/
const revalidate = Effect.fn("LocationMutation.revalidate")(function* (plan: Plan) {
const invalid = (reason: string) => new RevalidationError({ path: plan.input.path, reason })
const fresh = yield* resolve(plan.input).pipe(
Effect.mapError((error) => (error instanceof PathError ? invalid(error.reason) : error)),
)
if (!sameIdentity(fresh.authority, plan.authority)) return yield* invalid("mutation authority changed")
if (fresh.target.canonical !== plan.target.canonical) return yield* invalid("canonical mutation target changed")
if (fresh.target.resource !== plan.target.resource) return yield* invalid("mutation resource changed")
if (Boolean(fresh.target.externalDirectory) !== Boolean(plan.target.externalDirectory)) {
return yield* invalid("external directory authority changed")
}
if (
fresh.target.externalDirectory &&
plan.target.externalDirectory &&
(fresh.target.externalDirectory.directory !== plan.target.externalDirectory.directory ||
fresh.target.externalDirectory.resource !== plan.target.externalDirectory.resource ||
!sameIdentity(fresh.target.externalDirectory.authority, plan.target.externalDirectory.authority))
) {
return yield* invalid("external directory authority changed")
}
return fresh.target
})
return Service.of({ resolve, revalidate })
}),
)
export const locationLayer = layer
-198
View File
@@ -1,198 +0,0 @@
export * as LocationSearch from "./location-search"
import path from "path"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { FileSystem } from "./filesystem"
import { FSUtil } from "./fs-util"
import { Ripgrep } from "./ripgrep"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
/**
* Location-scoped raw search substrate. Search authority is selected only by
* FileSystem, preserving Location-relative paths and named read
* references. Model formatting, leaf-tool permissions, and HTTP transport stay
* outside this service so future GlobTool, GrepTool, and HTTP consumers can
* share the same bounded filesystem behavior.
*
* TODO: Expose this substrate through HTTP fs.search/fs.grep endpoints.
* TODO: Reuse this substrate for instruction and skill discovery where suitable.
*/
export const DEFAULT_RESULT_LIMIT = 100
export const MAX_RESULT_LIMIT = 100
export const MAX_LINE_PREVIEW_LENGTH = 2_000
export const ResultLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_RESULT_LIMIT))
const RootInput = {
path: RelativePath.pipe(Schema.optional),
reference: Schema.NonEmptyString.pipe(Schema.optional),
}
export const FilesInput = Schema.Struct({
pattern: Schema.String,
...RootInput,
limit: ResultLimit.pipe(Schema.optional),
})
export type FilesInput = typeof FilesInput.Type & { readonly signal?: AbortSignal }
export const GrepInput = Schema.Struct({
pattern: Schema.String,
include: Schema.String.pipe(Schema.optional),
...RootInput,
limit: ResultLimit.pipe(Schema.optional),
})
export type GrepInput = typeof GrepInput.Type & { readonly signal?: AbortSignal }
export class File extends Schema.Class<File>("LocationSearch.File")({
path: RelativePath,
canonical: Schema.String,
resource: Schema.String,
mtime: Schema.Number,
}) {}
export class Submatch extends Schema.Class<Submatch>("LocationSearch.Submatch")({
text: Schema.String,
start: NonNegativeInt,
end: NonNegativeInt,
}) {}
export class Match extends Schema.Class<Match>("LocationSearch.Match")({
path: RelativePath,
canonical: Schema.String,
resource: Schema.String,
lines: Schema.String,
linePreviewTruncated: Schema.Boolean,
line: PositiveInt,
offset: NonNegativeInt,
submatches: Schema.Array(Submatch),
mtime: Schema.Number,
}) {}
export class FilesResult extends Schema.Class<FilesResult>("LocationSearch.FilesResult")({
items: Schema.Array(File),
truncated: Schema.Boolean,
partial: Schema.Boolean,
}) {}
export class GrepResult extends Schema.Class<GrepResult>("LocationSearch.GrepResult")({
items: Schema.Array(Match),
truncated: Schema.Boolean,
partial: Schema.Boolean,
}) {}
export interface Interface {
readonly files: (input: FilesInput, root?: FileSystem.RootTarget) => Effect.Effect<FilesResult, Ripgrep.Error>
readonly grep: (
input: GrepInput,
root?: FileSystem.RootTarget,
) => Effect.Effect<GrepResult, Ripgrep.Error | Ripgrep.InvalidPatternError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationSearch") {}
const slash = (value: string) => value.replaceAll("\\", "/")
const cap = (limit?: number) => Math.min(limit ?? DEFAULT_RESULT_LIMIT, MAX_RESULT_LIMIT)
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const filesystem = yield* FileSystem.Service
const ripgrep = yield* Ripgrep.Service
const candidate = Effect.fnUntraced(function* (root: FileSystem.RootTarget, cwd: string, value: string) {
const absolute = path.resolve(cwd, value)
const lexicallyContained =
root.type === "directory" ? FSUtil.contains(root.real, absolute) : absolute === root.real
if (!lexicallyContained) return
const canonical = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
if (!canonical || !FSUtil.contains(root.root, canonical)) return
const info = yield* fs.stat(canonical).pipe(Effect.catch(() => Effect.void))
if (!info || info.type !== "File") return
const relative = slash(path.relative(root.root, canonical))
return {
path: RelativePath.make(relative),
canonical,
resource: root.reference === undefined ? relative : `${root.reference}:${relative}`,
mtime: info.mtime.pipe(
Option.map((date) => date.getTime()),
Option.getOrElse(() => 0),
),
}
})
return Service.of({
files: Effect.fn("LocationSearch.files")(function* (input, approvedRoot) {
const root = yield* filesystem.revalidateRoot(approvedRoot ?? (yield* filesystem.resolveRoot(input)))
if (root.type !== "directory")
return yield* Effect.die(new globalThis.Error("Files search path must be a directory"))
const result = yield* ripgrep.files({
cwd: root.real,
pattern: input.pattern,
limit: cap(input.limit),
signal: input.signal,
})
const mapped = yield* Effect.forEach(result.items, (item) => candidate(root, root.real, item), {
concurrency: 16,
})
const items = mapped.filter((item): item is File => item !== undefined).map((item) => new File(item))
// TODO: Decide result ordering policy: V1 mtime sorting versus stable path ordering.
// TODO: Report inaccessible paths discovered after bounded ripgrep termination when practical.
return new FilesResult({
items,
truncated: result.truncated,
partial: result.partial || items.length !== result.items.length,
})
}),
grep: Effect.fn("LocationSearch.grep")(function* (input, approvedRoot) {
const root = yield* filesystem.revalidateRoot(approvedRoot ?? (yield* filesystem.resolveRoot(input)))
const cwd = root.type === "directory" ? root.real : path.dirname(root.real)
const result = yield* ripgrep.grep({
cwd,
pattern: input.pattern,
include: input.include,
file: root.type === "file" ? path.basename(root.real) : undefined,
limit: cap(input.limit),
signal: input.signal,
})
const candidates = new Map<string, ReturnType<typeof candidate>>()
for (const item of result.items) {
if (!candidates.has(item.path.text)) {
candidates.set(item.path.text, yield* Effect.cached(candidate(root, cwd, item.path.text)))
}
}
const mapped = yield* Effect.forEach(
result.items,
(item) =>
candidates.get(item.path.text)!.pipe(
Effect.map(
(file) =>
file &&
new Match({
...file,
lines: item.lines.text.slice(0, MAX_LINE_PREVIEW_LENGTH),
linePreviewTruncated: item.lines.text.length > MAX_LINE_PREVIEW_LENGTH,
line: item.line_number,
offset: item.absolute_offset,
submatches: item.submatches.map(
(submatch) =>
new Submatch({ text: submatch.match.text, start: submatch.start, end: submatch.end }),
),
}),
),
),
{ concurrency: 16 },
)
const items = mapped.filter((item): item is Match => item !== undefined)
// TODO: Decide result ordering policy: V1 mtime sorting versus stable path ordering.
// TODO: Report inaccessible paths discovered after bounded ripgrep termination when practical.
return new GrepResult({
items,
truncated: result.truncated,
partial: result.partial || items.length !== result.items.length,
})
}),
})
}),
)
+8 -16
View File
@@ -1,33 +1,25 @@
import { Context, Effect, Layer, Schema } from "effect"
import { Project } from "./project"
import { AbsolutePath } from "./schema"
import { WorkspaceV2 } from "./workspace"
export * as Location from "./location"
export const Ref = Schema.Struct({
directory: AbsolutePath,
workspaceID: Schema.optional(WorkspaceV2.ID),
workspaceID: Schema.optional(Schema.String),
}).annotate({ identifier: "Location.Ref" })
export type Ref = typeof Ref.Type
export class Info extends Schema.Class<Info>("Location.Info")({
directory: AbsolutePath,
workspaceID: WorkspaceV2.ID.pipe(Schema.optional),
project: Schema.Struct({
id: Project.ID,
directory: AbsolutePath,
}),
}) {}
export interface Interface extends Info {
export interface Interface {
readonly directory: AbsolutePath
readonly workspaceID?: string
readonly project: {
readonly id: Project.ID
readonly directory: AbsolutePath
}
readonly vcs?: Project.Vcs
}
export function response<S extends Schema.Top>(data: S) {
return Schema.Struct({ location: Info, data })
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
export const layer = (ref: Ref) =>
-4
View File
@@ -1,4 +0,0 @@
declare module "*.md" {
const content: string
export default content
}
-197
View File
@@ -1,197 +0,0 @@
export * as Patch from "./patch"
export type Hunk =
| { readonly type: "add"; readonly path: string; readonly contents: string }
| { readonly type: "delete"; readonly path: string }
| {
readonly type: "update"
readonly path: string
readonly movePath?: string
readonly chunks: ReadonlyArray<UpdateFileChunk>
}
export interface UpdateFileChunk {
readonly oldLines: ReadonlyArray<string>
readonly newLines: ReadonlyArray<string>
readonly changeContext?: string
readonly endOfFile?: boolean
}
export interface FileUpdate {
readonly content: string
readonly bom: boolean
}
export function parse(patchText: string): ReadonlyArray<Hunk> {
const lines = stripHeredoc(patchText.trim()).split("\n")
const begin = lines.findIndex((line) => line.trim() === "*** Begin Patch")
const end = lines.findIndex((line) => line.trim() === "*** End Patch")
if (begin === -1 || end === -1 || begin >= end) throw new Error("Invalid patch format: missing Begin/End markers")
const hunks: Hunk[] = []
let index = begin + 1
while (index < end) {
const line = lines[index]!
if (line.startsWith("*** Add File:")) {
const path = line.slice("*** Add File:".length).trim()
if (!path) throw new Error("Invalid add file path")
const parsed = parseAdd(lines, index + 1)
hunks.push({ type: "add", path, contents: parsed.content })
index = parsed.next
continue
}
if (line.startsWith("*** Delete File:")) {
const path = line.slice("*** Delete File:".length).trim()
if (!path) throw new Error("Invalid delete file path")
hunks.push({ type: "delete", path })
index++
continue
}
if (line.startsWith("*** Update File:")) {
const path = line.slice("*** Update File:".length).trim()
if (!path) throw new Error("Invalid update file path")
let next = index + 1
let movePath: string | undefined
if (lines[next]?.startsWith("*** Move to:")) {
movePath = lines[next]!.slice("*** Move to:".length).trim()
if (!movePath) throw new Error("Invalid move file path")
next++
}
const parsed = parseUpdate(lines, next)
if (parsed.chunks.length === 0) throw new Error(`Invalid update hunk for ${path}: expected at least one @@ chunk`)
hunks.push({ type: "update", path, movePath, chunks: parsed.chunks })
index = parsed.next
continue
}
throw new Error(`Invalid patch line: ${line}`)
}
return hunks
}
export function derive(path: string, chunks: ReadonlyArray<UpdateFileChunk>, original: string): FileUpdate {
const source = splitBom(original)
const lines = source.text.split("\n")
if (lines.at(-1) === "") lines.pop()
const replacements = computeReplacements(lines, path, chunks)
const updated = [...lines]
for (const [start, remove, insert] of replacements.toReversed()) updated.splice(start, remove, ...insert)
if (updated.at(-1) !== "") updated.push("")
const next = splitBom(updated.join("\n"))
return { content: next.text, bom: source.bom || next.bom }
}
export function joinBom(text: string, bom: boolean) {
const stripped = splitBom(text).text
return bom ? `\uFEFF${stripped}` : stripped
}
function parseAdd(lines: ReadonlyArray<string>, start: number) {
const content: string[] = []
let index = start
while (index < lines.length && !lines[index]!.startsWith("***")) {
if (!lines[index]!.startsWith("+")) throw new Error(`Invalid add file line: ${lines[index]}`)
content.push(lines[index]!.slice(1))
index++
}
return { content: content.join("\n"), next: index }
}
function parseUpdate(lines: ReadonlyArray<string>, start: number) {
const chunks: UpdateFileChunk[] = []
let index = start
while (index < lines.length && !lines[index]!.startsWith("***")) {
if (!lines[index]!.startsWith("@@")) {
throw new Error(`Invalid update file line: ${lines[index]}`)
}
const changeContext = lines[index]!.slice(2).trim() || undefined
const oldLines: string[] = []
const newLines: string[] = []
let endOfFile = false
index++
while (index < lines.length && !lines[index]!.startsWith("@@")) {
const line = lines[index]!
if (line === "*** End of File") {
endOfFile = true
index++
break
}
if (line.startsWith("***")) break
if (line.startsWith(" ")) {
oldLines.push(line.slice(1))
newLines.push(line.slice(1))
} else if (line.startsWith("-")) oldLines.push(line.slice(1))
else if (line.startsWith("+")) newLines.push(line.slice(1))
else throw new Error(`Invalid update chunk line: ${line}`)
index++
}
chunks.push({ oldLines, newLines, changeContext, endOfFile: endOfFile || undefined })
}
return { chunks, next: index }
}
function computeReplacements(lines: ReadonlyArray<string>, path: string, chunks: ReadonlyArray<UpdateFileChunk>) {
const replacements: Array<readonly [start: number, remove: number, insert: ReadonlyArray<string>]> = []
let lineIndex = 0
for (const chunk of chunks) {
if (chunk.changeContext) {
const context = seek(lines, [chunk.changeContext], lineIndex)
if (context === -1) throw new Error(`Failed to find context '${chunk.changeContext}' in ${path}`)
lineIndex = context + 1
}
if (chunk.oldLines.length === 0) {
replacements.push([lines.length, 0, chunk.newLines])
continue
}
let oldLines = chunk.oldLines
let newLines = chunk.newLines
let found = seek(lines, oldLines, lineIndex, chunk.endOfFile)
if (found === -1 && oldLines.at(-1) === "") {
oldLines = oldLines.slice(0, -1)
if (newLines.at(-1) === "") newLines = newLines.slice(0, -1)
found = seek(lines, oldLines, lineIndex, chunk.endOfFile)
}
if (found === -1) throw new Error(`Failed to find expected lines in ${path}:\n${chunk.oldLines.join("\n")}`)
replacements.push([found, oldLines.length, newLines])
lineIndex = found + oldLines.length
}
return replacements.toSorted((left, right) => left[0] - right[0])
}
function seek(lines: ReadonlyArray<string>, pattern: ReadonlyArray<string>, start: number, eof = false) {
if (pattern.length === 0) return -1
for (const compare of [exact, rstrip, trim, normalized]) {
if (eof) {
const offset = lines.length - pattern.length
if (offset >= start && matches(lines, pattern, offset, compare)) return offset
}
for (let offset = start; offset <= lines.length - pattern.length; offset++) {
if (matches(lines, pattern, offset, compare)) return offset
}
}
return -1
}
function matches(
lines: ReadonlyArray<string>,
pattern: ReadonlyArray<string>,
offset: number,
compare: (left: string, right: string) => boolean,
) {
return pattern.every((line, index) => compare(lines[offset + index]!, line))
}
const exact = (left: string, right: string) => left === right
const rstrip = (left: string, right: string) => left.trimEnd() === right.trimEnd()
const trim = (left: string, right: string) => left.trim() === right.trim()
const normalized = (left: string, right: string) => normalize(left.trim()) === normalize(right.trim())
const normalize = (value: string) =>
value
.replace(/[‘’‚‛]/g, "'")
.replace(/[“”„‟]/g, '"')
.replace(/[‐‑‒–—―]/g, "-")
.replace(/…/g, "...")
.replace(/ /g, " ")
const splitBom = (text: string) =>
text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text }
const stripHeredoc = (input: string) =>
input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)?.[2] ?? input
+85 -101
View File
@@ -5,7 +5,6 @@ import { EventV2 } from "./event"
import { Location } from "./location"
import { AgentV2 } from "./agent"
import { SessionV2 } from "./session"
import { SessionStore } from "./session/store"
import { withStatics } from "./schema"
import { Identifier } from "./util/identifier"
import { Wildcard } from "./util/wildcard"
@@ -136,7 +135,7 @@ export const layer = Layer.effect(
const events = yield* EventV2.Service
const location = yield* Location.Service
const agents = yield* AgentV2.Service
const sessions = yield* SessionStore.Service
const sessions = yield* SessionV2.Service
const saved = yield* PermissionSaved.Service
const pending = new Map<ID, Pending>()
@@ -160,8 +159,8 @@ export const layer = Layer.effect(
const configured = EffectRuntime.fn("PermissionV2.configured")(function* (sessionID: SessionV2.ID) {
const session = yield* sessions.get(sessionID)
if (!session) return yield* new SessionV2.NotFoundError({ sessionID })
return (yield* agents.get(AgentV2.ID.make(session.agent ?? "build")))?.permissions ?? []
if (!session.agent) return []
return (yield* agents.get(AgentV2.ID.make(session.agent)))?.permissions ?? []
})
function denied(input: AssertInput, rules: Ruleset) {
@@ -193,19 +192,13 @@ export const layer = Layer.effect(
}
}
const create = (request: Request) =>
EffectRuntime.uninterruptible(
EffectRuntime.gen(function* () {
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
const item = { request, deferred }
if (pending.has(request.id)) return yield* EffectRuntime.die(`Duplicate pending permission ID: ${request.id}`)
pending.set(request.id, item)
yield* events
.publish(Event.Asked, request)
.pipe(EffectRuntime.onError(() => EffectRuntime.sync(() => pending.delete(request.id))))
return item
}),
)
const create = EffectRuntime.fnUntraced(function* (request: Request) {
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
const item = { request, deferred }
pending.set(request.id, item)
yield* events.publish(Event.Asked, request)
return item
})
const ask = EffectRuntime.fn("PermissionV2.ask")(function* (input: AssertInput) {
const result = yield* evaluateInput(input)
@@ -214,95 +207,86 @@ export const layer = Layer.effect(
return { id: value.id, effect: result.effect }
})
const assert = EffectRuntime.fn("PermissionV2.assert")((input: AssertInput) =>
EffectRuntime.uninterruptibleMask((restore) =>
EffectRuntime.gen(function* () {
const result = yield* evaluateInput(input)
if (result.effect === "deny") {
return yield* new DeniedError({
rules: relevant(input, result.rules),
})
}
if (result.effect === "allow") return
const item = yield* create(request(input))
return yield* restore(Deferred.await(item.deferred)).pipe(
EffectRuntime.ensuring(
EffectRuntime.sync(() => {
pending.delete(item.request.id)
}),
),
)
}),
),
)
const assert = EffectRuntime.fn("PermissionV2.assert")(function* (input: AssertInput) {
const result = yield* evaluateInput(input)
if (result.effect === "deny") {
return yield* new DeniedError({
rules: relevant(input, result.rules),
})
}
if (result.effect === "allow") return
const item = yield* create(request(input))
return yield* Deferred.await(item.deferred).pipe(
EffectRuntime.ensuring(
EffectRuntime.sync(() => {
pending.delete(item.request.id)
}),
),
)
})
const reply = EffectRuntime.fn("PermissionV2.reply")((input: ReplyInput) =>
EffectRuntime.uninterruptible(
EffectRuntime.gen(function* () {
const existing = pending.get(input.requestID)
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
const reply = EffectRuntime.fn("PermissionV2.reply")(function* (input: ReplyInput) {
const existing = pending.get(input.requestID)
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
pending.delete(input.requestID)
yield* events.publish(Event.Replied, {
sessionID: existing.request.sessionID,
requestID: existing.request.id,
reply: input.reply,
})
if (input.reply === "reject") {
yield* Deferred.fail(
existing.deferred,
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
)
for (const [id, item] of pending) {
if (item.request.sessionID !== existing.request.sessionID) continue
pending.delete(id)
yield* events.publish(Event.Replied, {
sessionID: existing.request.sessionID,
requestID: existing.request.id,
reply: input.reply,
sessionID: item.request.sessionID,
requestID: item.request.id,
reply: "reject",
})
yield* Deferred.fail(item.deferred, new RejectedError())
}
return
}
if (input.reply === "reject") {
yield* Deferred.fail(
existing.deferred,
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
)
pending.delete(input.requestID)
for (const [id, item] of pending) {
if (item.request.sessionID !== existing.request.sessionID) continue
yield* events.publish(Event.Replied, {
sessionID: item.request.sessionID,
requestID: item.request.id,
reply: "reject",
})
yield* Deferred.fail(item.deferred, new RejectedError())
pending.delete(id)
}
return
}
if (input.reply === "always" && existing.request.save?.length) {
yield* saved.add({
projectID: location.project.id,
action: existing.request.action,
resources: existing.request.save,
})
}
yield* Deferred.succeed(existing.deferred, undefined)
if (input.reply !== "always" || !existing.request.save?.length) return
if (input.reply === "always" && existing.request.save?.length) {
yield* saved.add({
projectID: location.project.id,
action: existing.request.action,
resources: existing.request.save,
})
}
yield* Deferred.succeed(existing.deferred, undefined)
pending.delete(input.requestID)
if (input.reply !== "always" || !existing.request.save?.length) return
const rememberedRules = yield* savedRules()
for (const [id, item] of pending) {
const input = { ...item.request }
const rules = yield* configured(item.request.sessionID).pipe(
EffectRuntime.catchTag("Session.NotFoundError", () => EffectRuntime.succeed(undefined)),
)
if (!rules) continue
if (denied(input, rules)) continue
const effective = [...rules, ...rememberedRules]
if (
!item.request.resources.every(
(resource) => evaluate(item.request.action, resource, effective).effect === "allow",
)
)
continue
yield* events.publish(Event.Replied, {
sessionID: item.request.sessionID,
requestID: item.request.id,
reply: "always",
})
yield* Deferred.succeed(item.deferred, undefined)
pending.delete(id)
}
}),
),
)
const rememberedRules = yield* savedRules()
for (const [id, item] of pending) {
const input = { ...item.request }
const rules = yield* configured(item.request.sessionID).pipe(
EffectRuntime.catchTag("Session.NotFoundError", () => EffectRuntime.succeed(undefined)),
)
if (!rules) continue
if (denied(input, rules)) continue
const effective = [...rules, ...rememberedRules]
if (
!item.request.resources.every(
(resource) => evaluate(item.request.action, resource, effective).effect === "allow",
)
)
continue
pending.delete(id)
yield* events.publish(Event.Replied, {
sessionID: item.request.sessionID,
requestID: item.request.id,
reply: "always",
})
yield* Deferred.succeed(item.deferred, undefined)
}
})
const list = EffectRuntime.fn("PermissionV2.list")(function* () {
return Array.from(pending.values(), (item) => item.request)

Some files were not shown because too many files have changed in this diff Show More