Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba44d37ac0 | ||
|
|
e2d4e60eab | ||
|
|
e21d892cff | ||
|
|
527956e3ec |
@@ -21,4 +21,3 @@ r44vc0rp
|
|||||||
rekram1-node
|
rekram1-node
|
||||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||||
thdxr
|
thdxr
|
||||||
-OpenCode2026
|
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-VF3rXpIz9XbTTfM8YB98DJJOs4Sotaq5cSwIBUfbNDA=",
|
"x86_64-linux": "sha256-WJgo6UclmtQOEubnKMZybdIEhZ1uRTucF61yojjd+l0=",
|
||||||
"aarch64-linux": "sha256-cIE10+0xhb5u0TQedaDbEu6e40ypHnSBmh8unnhCDZE=",
|
"aarch64-linux": "sha256-QfZ/g7EZFpe6ndR3dG8WvVfMj5Kyd/R/4kkTJfGJxL4=",
|
||||||
"aarch64-darwin": "sha256-d/l7g/4angRw/oxoSGpcYL0i9pNphgRChJwhva5Kypo=",
|
"aarch64-darwin": "sha256-ezr/R70XJr9eN5l3mgb7HzLF6QsofNEKUOtuxbfli80=",
|
||||||
"x86_64-darwin": "sha256-WQyuUKMfHpO1rpWsjhCXuG99iX2jEdSe3AVltxvt+1Y="
|
"x86_64-darwin": "sha256-MbsBGS415uEU/n1RQ/5H5pqh+udLY3+oimJ+eS5uJVI="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,8 +174,6 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
|||||||
- In terminal tests, type through the browser. Do not write to the PTY through the SDK.
|
- In terminal tests, type through the browser. Do not write to the PTY through the SDK.
|
||||||
- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`.
|
- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`.
|
||||||
- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
|
- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
|
||||||
- After opening the terminal, use `waitTerminalFocusIdle(...)` before the next keyboard action when prompt focus or keyboard routing matters.
|
|
||||||
- This avoids racing terminal mount, focus handoff, and prompt readiness when the next step types or sends shortcuts.
|
|
||||||
- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
|
- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
|
||||||
|
|
||||||
### Wait on state
|
### Wait on state
|
||||||
@@ -184,9 +182,6 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
|||||||
- Avoid race-prone flows that assume work is finished after an action
|
- Avoid race-prone flows that assume work is finished after an action
|
||||||
- Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers
|
- Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers
|
||||||
- Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state
|
- Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state
|
||||||
- Prefer semantic app state over transient DOM visibility when behavior depends on active selection, focus ownership, or async retry loops
|
|
||||||
- Do not treat a visible element as proof that the app will route the next action to it
|
|
||||||
- When fixing a flake, validate with `--repeat-each` and multiple workers when practical
|
|
||||||
|
|
||||||
### Add hooks
|
### Add hooks
|
||||||
|
|
||||||
@@ -194,16 +189,11 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
|||||||
- Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts`
|
- Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts`
|
||||||
- Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony
|
- Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony
|
||||||
- When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI
|
- When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI
|
||||||
- Add minimal test-only probes for semantic state like the active list item or selected command when DOM intermediates are unstable
|
|
||||||
- Prefer probing committed app state over asserting on transient highlight, visibility, or animation states
|
|
||||||
|
|
||||||
### Prefer helpers
|
### Prefer helpers
|
||||||
|
|
||||||
- Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise
|
- Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise
|
||||||
- Use direct locators when the interaction is simple and a helper would not add clarity
|
- Use direct locators when the interaction is simple and a helper would not add clarity
|
||||||
- Prefer helpers that both perform an action and verify the app consumed it
|
|
||||||
- Avoid composing helpers redundantly when one already includes the other or already waits for the resulting state
|
|
||||||
- If a helper already covers the required wait or verification, use it directly instead of layering extra clicks, keypresses, or assertions
|
|
||||||
|
|
||||||
## Writing New Tests
|
## Writing New Tests
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
listItemSelector,
|
listItemSelector,
|
||||||
listItemKeySelector,
|
listItemKeySelector,
|
||||||
listItemKeyStartsWithSelector,
|
listItemKeyStartsWithSelector,
|
||||||
promptSelector,
|
|
||||||
terminalSelector,
|
terminalSelector,
|
||||||
workspaceItemSelector,
|
workspaceItemSelector,
|
||||||
workspaceMenuTriggerSelector,
|
workspaceMenuTriggerSelector,
|
||||||
@@ -62,15 +61,6 @@ async function terminalReady(page: Page, term?: Locator) {
|
|||||||
}, id)
|
}, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function terminalFocusIdle(page: Page, term?: Locator) {
|
|
||||||
const next = term ?? page.locator(terminalSelector).first()
|
|
||||||
const id = await terminalID(next)
|
|
||||||
return page.evaluate((id) => {
|
|
||||||
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
|
|
||||||
return (state?.focusing ?? 0) === 0
|
|
||||||
}, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
|
async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
|
||||||
const next = input.term ?? page.locator(terminalSelector).first()
|
const next = input.term ?? page.locator(terminalSelector).first()
|
||||||
const id = await terminalID(next)
|
const id = await terminalID(next)
|
||||||
@@ -83,29 +73,6 @@ async function terminalHas(page: Page, input: { term?: Locator; token: string })
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function promptSlashActive(page: Page, id: string) {
|
|
||||||
return page.evaluate((id) => {
|
|
||||||
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
|
|
||||||
if (state?.popover !== "slash") return false
|
|
||||||
if (!state.slash.ids.includes(id)) return false
|
|
||||||
return state.slash.active === id
|
|
||||||
}, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function promptSlashSelects(page: Page) {
|
|
||||||
return page.evaluate(() => {
|
|
||||||
return (window as E2EWindow).__opencode_e2e?.prompt?.current?.selects ?? 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function promptSlashSelected(page: Page, input: { id: string; count: number }) {
|
|
||||||
return page.evaluate((input) => {
|
|
||||||
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
|
|
||||||
if (!state) return false
|
|
||||||
return state.selected === input.id && state.selects >= input.count
|
|
||||||
}, input)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
|
export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
|
||||||
const term = input?.term ?? page.locator(terminalSelector).first()
|
const term = input?.term ?? page.locator(terminalSelector).first()
|
||||||
const timeout = input?.timeout ?? 10_000
|
const timeout = input?.timeout ?? 10_000
|
||||||
@@ -114,43 +81,6 @@ export async function waitTerminalReady(page: Page, input?: { term?: Locator; ti
|
|||||||
await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
|
await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitTerminalFocusIdle(page: Page, input?: { term?: Locator; timeout?: number }) {
|
|
||||||
const term = input?.term ?? page.locator(terminalSelector).first()
|
|
||||||
const timeout = input?.timeout ?? 10_000
|
|
||||||
await waitTerminalReady(page, { term, timeout })
|
|
||||||
await expect.poll(() => terminalFocusIdle(page, term), { timeout }).toBe(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function showPromptSlash(
|
|
||||||
page: Page,
|
|
||||||
input: { id: string; text: string; prompt?: Locator; timeout?: number },
|
|
||||||
) {
|
|
||||||
const prompt = input.prompt ?? page.locator(promptSelector)
|
|
||||||
const timeout = input.timeout ?? 10_000
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
await prompt.click().catch(() => false)
|
|
||||||
await prompt.fill(input.text).catch(() => false)
|
|
||||||
return promptSlashActive(page, input.id).catch(() => false)
|
|
||||||
},
|
|
||||||
{ timeout },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runPromptSlash(
|
|
||||||
page: Page,
|
|
||||||
input: { id: string; text: string; prompt?: Locator; timeout?: number },
|
|
||||||
) {
|
|
||||||
const prompt = input.prompt ?? page.locator(promptSelector)
|
|
||||||
const timeout = input.timeout ?? 10_000
|
|
||||||
const count = await promptSlashSelects(page)
|
|
||||||
await showPromptSlash(page, input)
|
|
||||||
await prompt.press("Enter")
|
|
||||||
await expect.poll(() => promptSlashSelected(page, { id: input.id, count: count + 1 }), { timeout }).toBe(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
|
export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
|
||||||
const term = input.term ?? page.locator(terminalSelector).first()
|
const term = input.term ?? page.locator(terminalSelector).first()
|
||||||
const timeout = input.timeout ?? 10_000
|
const timeout = input.timeout ?? 10_000
|
||||||
|
|||||||
@@ -98,9 +98,6 @@ async function seedStorage(page: Page, input: { directory: string; extra?: strin
|
|||||||
model: {
|
model: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
prompt: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
terminal: {
|
terminal: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
terminals: {},
|
terminals: {},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { runPromptSlash, waitTerminalFocusIdle } from "../actions"
|
import { waitTerminalReady } from "../actions"
|
||||||
import { promptSelector, terminalSelector } from "../selectors"
|
import { promptSelector, terminalSelector } from "../selectors"
|
||||||
|
|
||||||
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
||||||
@@ -7,12 +7,29 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
|||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
const terminal = page.locator(terminalSelector)
|
const terminal = page.locator(terminalSelector)
|
||||||
|
const slash = page.locator('[data-slash-id="terminal.toggle"]').first()
|
||||||
|
|
||||||
await expect(terminal).not.toBeVisible()
|
await expect(terminal).not.toBeVisible()
|
||||||
|
|
||||||
await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
|
await prompt.fill("/terminal")
|
||||||
await waitTerminalFocusIdle(page, { term: terminal })
|
await expect(slash).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await waitTerminalReady(page, { term: terminal })
|
||||||
|
|
||||||
await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
|
// Terminal panel retries focus (immediate, RAF, 120ms, 240ms) after opening,
|
||||||
|
// which can steal focus from the prompt and prevent fill() from triggering
|
||||||
|
// the slash popover. Re-attempt click+fill until all retries are exhausted
|
||||||
|
// and the popover appears.
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
await prompt.click().catch(() => false)
|
||||||
|
await prompt.fill("/terminal").catch(() => false)
|
||||||
|
return slash.isVisible().catch(() => false)
|
||||||
|
},
|
||||||
|
{ timeout: 10_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
await expect(terminal).not.toBeVisible()
|
await expect(terminal).not.toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openSettings, closeDialog, waitTerminalFocusIdle, withSession } from "../actions"
|
import { openSettings, closeDialog, waitTerminalReady, withSession } from "../actions"
|
||||||
import { keybindButtonSelector, terminalSelector } from "../selectors"
|
import { keybindButtonSelector, terminalSelector } from "../selectors"
|
||||||
import { modKey } from "../utils"
|
import { modKey } from "../utils"
|
||||||
|
|
||||||
@@ -302,7 +302,7 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
|
|||||||
await expect(terminal).not.toBeVisible()
|
await expect(terminal).not.toBeVisible()
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Y`)
|
await page.keyboard.press(`${modKey}+Y`)
|
||||||
await waitTerminalFocusIdle(page, { term: terminal })
|
await waitTerminalReady(page, { term: terminal })
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Y`)
|
await page.keyboard.press(`${modKey}+Y`)
|
||||||
await expect(terminal).not.toBeVisible()
|
await expect(terminal).not.toBeVisible()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { waitTerminalFocusIdle, waitTerminalReady } from "../actions"
|
import { waitTerminalReady } from "../actions"
|
||||||
import { promptSelector, terminalSelector } from "../selectors"
|
import { promptSelector, terminalSelector } from "../selectors"
|
||||||
import { terminalToggleKey } from "../utils"
|
import { terminalToggleKey } from "../utils"
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
|
|||||||
await page.keyboard.press(terminalToggleKey)
|
await page.keyboard.press(terminalToggleKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
await waitTerminalFocusIdle(page, { term: terminals.first() })
|
await waitTerminalReady(page, { term: terminals.first() })
|
||||||
await expect(terminals).toHaveCount(1)
|
await expect(terminals).toHaveCount(1)
|
||||||
|
|
||||||
// Ghostty captures a lot of keybinds when focused; move focus back
|
// Ghostty captures a lot of keybinds when focused; move focus back
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||||
import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal } from "solid-js"
|
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
||||||
@@ -36,7 +36,6 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { promptEnabled, promptProbe } from "@/testing/prompt"
|
|
||||||
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
||||||
import { createPromptAttachments } from "./prompt-input/attachments"
|
import { createPromptAttachments } from "./prompt-input/attachments"
|
||||||
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
|
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
|
||||||
@@ -244,23 +243,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
const working = createMemo(() => status()?.type !== "idle")
|
const working = createMemo(() => status()?.type !== "idle")
|
||||||
const tip = () => {
|
|
||||||
if (working()) {
|
|
||||||
return (
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>{language.t("prompt.action.stop")}</span>
|
|
||||||
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>{language.t("prompt.action.send")}</span>
|
|
||||||
<Icon name="enter" size="small" class="text-icon-base" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const imageAttachments = createMemo(() =>
|
const imageAttachments = createMemo(() =>
|
||||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||||
)
|
)
|
||||||
@@ -622,7 +604,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
|
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
|
||||||
if (!cmd) return
|
if (!cmd) return
|
||||||
promptProbe.select(cmd.id)
|
|
||||||
closePopover()
|
closePopover()
|
||||||
|
|
||||||
if (cmd.type === "custom") {
|
if (cmd.type === "custom") {
|
||||||
@@ -711,20 +692,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
if (promptEnabled()) {
|
|
||||||
createEffect(() => {
|
|
||||||
promptProbe.set({
|
|
||||||
popover: store.popover,
|
|
||||||
slash: {
|
|
||||||
active: slashActive() ?? null,
|
|
||||||
ids: slashFlat().map((cmd) => cmd.id),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => promptProbe.clear())
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectPopoverActive = () => {
|
const selectPopoverActive = () => {
|
||||||
if (store.popover === "at") {
|
if (store.popover === "at") {
|
||||||
const items = atFlat()
|
const items = atFlat()
|
||||||
@@ -1379,7 +1346,26 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex items-center gap-1 pointer-events-auto">
|
<div class="flex items-center gap-1 pointer-events-auto">
|
||||||
<Tooltip placement="top" inactive={!prompt.dirty() && !working()} value={tip()}>
|
<Tooltip
|
||||||
|
placement="top"
|
||||||
|
inactive={!prompt.dirty() && !working()}
|
||||||
|
value={
|
||||||
|
<Switch>
|
||||||
|
<Match when={working()}>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{language.t("prompt.action.stop")}</span>
|
||||||
|
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{language.t("prompt.action.send")}</span>
|
||||||
|
<Icon name="enter" size="small" class="text-icon-base" />
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
}
|
||||||
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
data-action="prompt-submit"
|
data-action="prompt-submit"
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
|
||||||
import { checksum } from "@opencode-ai/util/encode"
|
|
||||||
import { useParams } from "@solidjs/router"
|
|
||||||
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
|
||||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||||
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
|
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
||||||
|
import { useParams } from "@solidjs/router"
|
||||||
import type { FileSelection } from "@/context/file"
|
import type { FileSelection } from "@/context/file"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
import { checksum } from "@opencode-ai/util/encode"
|
||||||
|
|
||||||
interface PartBase {
|
interface PartBase {
|
||||||
content: string
|
content: string
|
||||||
@@ -250,7 +250,6 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const owner = getOwner()
|
|
||||||
const load = (dir: string, id: string | undefined) => {
|
const load = (dir: string, id: string | undefined) => {
|
||||||
const key = `${dir}:${id ?? WORKSPACE_KEY}`
|
const key = `${dir}:${id ?? WORKSPACE_KEY}`
|
||||||
const existing = cache.get(key)
|
const existing = cache.get(key)
|
||||||
@@ -260,13 +259,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
return existing.value
|
return existing.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const entry = createRoot(
|
const entry = createRoot((dispose) => ({
|
||||||
(dispose) => ({
|
value: createPromptSession(dir, id),
|
||||||
value: createPromptSession(dir, id),
|
dispose,
|
||||||
dispose,
|
}))
|
||||||
}),
|
|
||||||
owner,
|
|
||||||
)
|
|
||||||
|
|
||||||
cache.set(key, entry)
|
cache.set(key, entry)
|
||||||
prune()
|
prune()
|
||||||
|
|||||||
@@ -18,10 +18,8 @@ import { terminalTabLabel } from "@/pages/session/terminal-label"
|
|||||||
import { createSizing, focusTerminalById } from "@/pages/session/helpers"
|
import { createSizing, focusTerminalById } from "@/pages/session/helpers"
|
||||||
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
|
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { terminalProbe } from "@/testing/terminal"
|
|
||||||
|
|
||||||
export function TerminalPanel() {
|
export function TerminalPanel() {
|
||||||
const delays = [120, 240]
|
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -81,20 +79,16 @@ export function TerminalPanel() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const focus = (id: string) => {
|
const focus = (id: string) => {
|
||||||
const probe = terminalProbe(id)
|
|
||||||
probe.focus(delays.length + 1)
|
|
||||||
focusTerminalById(id)
|
focusTerminalById(id)
|
||||||
|
|
||||||
const frame = requestAnimationFrame(() => {
|
const frame = requestAnimationFrame(() => {
|
||||||
probe.step()
|
|
||||||
if (!opened()) return
|
if (!opened()) return
|
||||||
if (terminal.active() !== id) return
|
if (terminal.active() !== id) return
|
||||||
focusTerminalById(id)
|
focusTerminalById(id)
|
||||||
})
|
})
|
||||||
|
|
||||||
const timers = delays.map((ms) =>
|
const timers = [120, 240].map((ms) =>
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
probe.step()
|
|
||||||
if (!opened()) return
|
if (!opened()) return
|
||||||
if (terminal.active() !== id) return
|
if (terminal.active() !== id) return
|
||||||
focusTerminalById(id)
|
focusTerminalById(id)
|
||||||
@@ -102,7 +96,6 @@ export function TerminalPanel() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
probe.focus(0)
|
|
||||||
cancelAnimationFrame(frame)
|
cancelAnimationFrame(frame)
|
||||||
for (const timer of timers) clearTimeout(timer)
|
for (const timer of timers) clearTimeout(timer)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import type { E2EWindow } from "./terminal"
|
|
||||||
|
|
||||||
export type PromptProbeState = {
|
|
||||||
popover: "at" | "slash" | null
|
|
||||||
slash: {
|
|
||||||
active: string | null
|
|
||||||
ids: string[]
|
|
||||||
}
|
|
||||||
selected: string | null
|
|
||||||
selects: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const promptEnabled = () => {
|
|
||||||
if (typeof window === "undefined") return false
|
|
||||||
return (window as E2EWindow).__opencode_e2e?.prompt?.enabled === true
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = () => {
|
|
||||||
if (!promptEnabled()) return
|
|
||||||
return (window as E2EWindow).__opencode_e2e?.prompt
|
|
||||||
}
|
|
||||||
|
|
||||||
export const promptProbe = {
|
|
||||||
set(input: Omit<PromptProbeState, "selected" | "selects">) {
|
|
||||||
const state = root()
|
|
||||||
if (!state) return
|
|
||||||
state.current = {
|
|
||||||
popover: input.popover,
|
|
||||||
slash: {
|
|
||||||
active: input.slash.active,
|
|
||||||
ids: [...input.slash.ids],
|
|
||||||
},
|
|
||||||
selected: state.current?.selected ?? null,
|
|
||||||
selects: state.current?.selects ?? 0,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
select(id: string) {
|
|
||||||
const state = root()
|
|
||||||
if (!state) return
|
|
||||||
const prev = state.current
|
|
||||||
state.current = {
|
|
||||||
popover: prev?.popover ?? null,
|
|
||||||
slash: {
|
|
||||||
active: prev?.slash.active ?? null,
|
|
||||||
ids: [...(prev?.slash.ids ?? [])],
|
|
||||||
},
|
|
||||||
selected: id,
|
|
||||||
selects: (prev?.selects ?? 0) + 1,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
clear() {
|
|
||||||
const state = root()
|
|
||||||
if (!state) return
|
|
||||||
state.current = undefined
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ export type TerminalProbeState = {
|
|||||||
connects: number
|
connects: number
|
||||||
rendered: string
|
rendered: string
|
||||||
settled: number
|
settled: number
|
||||||
focusing: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TerminalProbeControl = {
|
type TerminalProbeControl = {
|
||||||
@@ -20,10 +19,6 @@ export type E2EWindow = Window & {
|
|||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
current?: ModelProbeState
|
current?: ModelProbeState
|
||||||
}
|
}
|
||||||
prompt?: {
|
|
||||||
enabled?: boolean
|
|
||||||
current?: import("./prompt").PromptProbeState
|
|
||||||
}
|
|
||||||
terminal?: {
|
terminal?: {
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
terminals?: Record<string, TerminalProbeState>
|
terminals?: Record<string, TerminalProbeState>
|
||||||
@@ -37,7 +32,6 @@ const seed = (): TerminalProbeState => ({
|
|||||||
connects: 0,
|
connects: 0,
|
||||||
rendered: "",
|
rendered: "",
|
||||||
settled: 0,
|
settled: 0,
|
||||||
focusing: 0,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const root = () => {
|
const root = () => {
|
||||||
@@ -94,15 +88,6 @@ export const terminalProbe = (id: string) => {
|
|||||||
const prev = state[id] ?? seed()
|
const prev = state[id] ?? seed()
|
||||||
state[id] = { ...prev, settled: prev.settled + 1 }
|
state[id] = { ...prev, settled: prev.settled + 1 }
|
||||||
},
|
},
|
||||||
focus(count: number) {
|
|
||||||
set({ focusing: Math.max(0, count) })
|
|
||||||
},
|
|
||||||
step() {
|
|
||||||
const state = terms()
|
|
||||||
if (!state) return
|
|
||||||
const prev = state[id] ?? seed()
|
|
||||||
state[id] = { ...prev, focusing: Math.max(0, prev.focusing - 1) }
|
|
||||||
},
|
|
||||||
control(next: Partial<TerminalProbeControl>) {
|
control(next: Partial<TerminalProbeControl>) {
|
||||||
const state = controls()
|
const state = controls()
|
||||||
if (!state) return
|
if (!state) return
|
||||||
|
|||||||
@@ -11,52 +11,46 @@ const seed = async () => {
|
|||||||
const { Instance } = await import("../src/project/instance")
|
const { Instance } = await import("../src/project/instance")
|
||||||
const { InstanceBootstrap } = await import("../src/project/bootstrap")
|
const { InstanceBootstrap } = await import("../src/project/bootstrap")
|
||||||
const { Config } = await import("../src/config/config")
|
const { Config } = await import("../src/config/config")
|
||||||
const { disposeRuntime } = await import("../src/effect/runtime")
|
|
||||||
const { Session } = await import("../src/session")
|
const { Session } = await import("../src/session")
|
||||||
const { MessageID, PartID } = await import("../src/session/schema")
|
const { MessageID, PartID } = await import("../src/session/schema")
|
||||||
const { Project } = await import("../src/project/project")
|
const { Project } = await import("../src/project/project")
|
||||||
const { ModelID, ProviderID } = await import("../src/provider/schema")
|
const { ModelID, ProviderID } = await import("../src/provider/schema")
|
||||||
const { ToolRegistry } = await import("../src/tool/registry")
|
const { ToolRegistry } = await import("../src/tool/registry")
|
||||||
|
|
||||||
try {
|
await Instance.provide({
|
||||||
await Instance.provide({
|
directory: dir,
|
||||||
directory: dir,
|
init: InstanceBootstrap,
|
||||||
init: InstanceBootstrap,
|
fn: async () => {
|
||||||
fn: async () => {
|
await Config.waitForDependencies()
|
||||||
await Config.waitForDependencies()
|
await ToolRegistry.ids()
|
||||||
await ToolRegistry.ids()
|
|
||||||
|
|
||||||
const session = await Session.create({ title })
|
const session = await Session.create({ title })
|
||||||
const messageID = MessageID.ascending()
|
const messageID = MessageID.ascending()
|
||||||
const partID = PartID.ascending()
|
const partID = PartID.ascending()
|
||||||
const message = {
|
const message = {
|
||||||
id: messageID,
|
id: messageID,
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
role: "user" as const,
|
role: "user" as const,
|
||||||
time: { created: now },
|
time: { created: now },
|
||||||
agent: "build",
|
agent: "build",
|
||||||
model: {
|
model: {
|
||||||
providerID: ProviderID.make(providerID),
|
providerID: ProviderID.make(providerID),
|
||||||
modelID: ModelID.make(modelID),
|
modelID: ModelID.make(modelID),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const part = {
|
const part = {
|
||||||
id: partID,
|
id: partID,
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
messageID,
|
messageID,
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text,
|
text,
|
||||||
time: { start: now },
|
time: { start: now },
|
||||||
}
|
}
|
||||||
await Session.updateMessage(message)
|
await Session.updateMessage(message)
|
||||||
await Session.updatePart(part)
|
await Session.updatePart(part)
|
||||||
await Project.update({ projectID: Instance.project.id, name: "E2E Project" })
|
await Project.update({ projectID: Instance.project.id, name: "E2E Project" })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} finally {
|
|
||||||
await Instance.disposeAll().catch(() => {})
|
|
||||||
await disposeRuntime().catch(() => {})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await seed()
|
await seed()
|
||||||
|
|||||||
@@ -1,355 +0,0 @@
|
|||||||
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
|
||||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
|
||||||
|
|
||||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
|
||||||
import { AccountRepo, type AccountRow } from "./repo"
|
|
||||||
import {
|
|
||||||
type AccountError as SchemaError,
|
|
||||||
AccessToken as SchemaAccessToken,
|
|
||||||
Account as SchemaAccount,
|
|
||||||
AccountID as SchemaAccountID,
|
|
||||||
DeviceCode as SchemaDeviceCode,
|
|
||||||
RefreshToken as SchemaRefreshToken,
|
|
||||||
AccountServiceError as SchemaServiceError,
|
|
||||||
Login as SchemaLogin,
|
|
||||||
Org as SchemaOrg,
|
|
||||||
OrgID as SchemaOrgID,
|
|
||||||
PollDenied as SchemaPollDenied,
|
|
||||||
PollError as SchemaPollError,
|
|
||||||
PollExpired as SchemaPollExpired,
|
|
||||||
PollPending as SchemaPollPending,
|
|
||||||
type PollResult as SchemaPollResult,
|
|
||||||
PollSlow as SchemaPollSlow,
|
|
||||||
PollSuccess as SchemaPollSuccess,
|
|
||||||
UserCode as SchemaUserCode,
|
|
||||||
} from "./schema"
|
|
||||||
|
|
||||||
export namespace AccountEffect {
|
|
||||||
export type Error = SchemaError
|
|
||||||
|
|
||||||
const AccessToken = SchemaAccessToken
|
|
||||||
type AccessToken = SchemaAccessToken
|
|
||||||
const Account = SchemaAccount
|
|
||||||
type Account = SchemaAccount
|
|
||||||
const AccountID = SchemaAccountID
|
|
||||||
type AccountID = SchemaAccountID
|
|
||||||
const DeviceCode = SchemaDeviceCode
|
|
||||||
type DeviceCode = SchemaDeviceCode
|
|
||||||
const RefreshToken = SchemaRefreshToken
|
|
||||||
type RefreshToken = SchemaRefreshToken
|
|
||||||
const Login = SchemaLogin
|
|
||||||
type Login = SchemaLogin
|
|
||||||
const Org = SchemaOrg
|
|
||||||
type Org = SchemaOrg
|
|
||||||
const OrgID = SchemaOrgID
|
|
||||||
type OrgID = SchemaOrgID
|
|
||||||
const PollDenied = SchemaPollDenied
|
|
||||||
const PollError = SchemaPollError
|
|
||||||
const PollExpired = SchemaPollExpired
|
|
||||||
const PollPending = SchemaPollPending
|
|
||||||
const PollSlow = SchemaPollSlow
|
|
||||||
const PollSuccess = SchemaPollSuccess
|
|
||||||
const UserCode = SchemaUserCode
|
|
||||||
type PollResult = SchemaPollResult
|
|
||||||
|
|
||||||
export type AccountOrgs = {
|
|
||||||
account: Account
|
|
||||||
orgs: readonly Org[]
|
|
||||||
}
|
|
||||||
|
|
||||||
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
|
|
||||||
config: Schema.Record(Schema.String, Schema.Json),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
const DurationFromSeconds = Schema.Number.pipe(
|
|
||||||
Schema.decodeTo(Schema.Duration, {
|
|
||||||
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
|
|
||||||
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
|
|
||||||
access_token: AccessToken,
|
|
||||||
refresh_token: RefreshToken,
|
|
||||||
expires_in: DurationFromSeconds,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
|
||||||
device_code: DeviceCode,
|
|
||||||
user_code: UserCode,
|
|
||||||
verification_uri_complete: Schema.String,
|
|
||||||
expires_in: DurationFromSeconds,
|
|
||||||
interval: DurationFromSeconds,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
|
|
||||||
access_token: AccessToken,
|
|
||||||
refresh_token: RefreshToken,
|
|
||||||
token_type: Schema.Literal("Bearer"),
|
|
||||||
expires_in: DurationFromSeconds,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
|
|
||||||
error: Schema.String,
|
|
||||||
error_description: Schema.String,
|
|
||||||
}) {
|
|
||||||
toPollResult(): PollResult {
|
|
||||||
if (this.error === "authorization_pending") return new PollPending()
|
|
||||||
if (this.error === "slow_down") return new PollSlow()
|
|
||||||
if (this.error === "expired_token") return new PollExpired()
|
|
||||||
if (this.error === "access_denied") return new PollDenied()
|
|
||||||
return new PollError({ cause: this.error })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
|
|
||||||
|
|
||||||
class User extends Schema.Class<User>("User")({
|
|
||||||
id: AccountID,
|
|
||||||
email: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
|
|
||||||
|
|
||||||
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
|
|
||||||
grant_type: Schema.String,
|
|
||||||
device_code: DeviceCode,
|
|
||||||
client_id: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
|
|
||||||
grant_type: Schema.String,
|
|
||||||
refresh_token: RefreshToken,
|
|
||||||
client_id: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
const client_id = "opencode-cli"
|
|
||||||
|
|
||||||
const map =
|
|
||||||
(message = "Account service operation failed") =>
|
|
||||||
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, SchemaServiceError, R> =>
|
|
||||||
effect.pipe(
|
|
||||||
Effect.mapError((cause) =>
|
|
||||||
cause instanceof SchemaServiceError ? cause : new SchemaServiceError({ message, cause }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly active: () => Effect.Effect<Option.Option<Account>, Error>
|
|
||||||
readonly list: () => Effect.Effect<Account[], Error>
|
|
||||||
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], Error>
|
|
||||||
readonly remove: (accountID: AccountID) => Effect.Effect<void, Error>
|
|
||||||
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, Error>
|
|
||||||
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], Error>
|
|
||||||
readonly config: (accountID: AccountID, orgID: OrgID) => Effect.Effect<Option.Option<Record<string, unknown>>, Error>
|
|
||||||
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, Error>
|
|
||||||
readonly login: (url: string) => Effect.Effect<Login, Error>
|
|
||||||
readonly poll: (input: Login) => Effect.Effect<PollResult, Error>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
|
|
||||||
|
|
||||||
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const repo = yield* AccountRepo
|
|
||||||
const http = yield* HttpClient.HttpClient
|
|
||||||
const httpRead = withTransientReadRetry(http)
|
|
||||||
const httpOk = HttpClient.filterStatusOk(http)
|
|
||||||
const httpReadOk = HttpClient.filterStatusOk(httpRead)
|
|
||||||
|
|
||||||
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
|
|
||||||
httpRead.execute(request).pipe(map("HTTP request failed"))
|
|
||||||
|
|
||||||
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
|
|
||||||
httpReadOk.execute(request).pipe(map("HTTP request failed"))
|
|
||||||
|
|
||||||
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
|
||||||
request.pipe(
|
|
||||||
Effect.flatMap((req) => httpOk.execute(req)),
|
|
||||||
map("HTTP request failed"),
|
|
||||||
)
|
|
||||||
|
|
||||||
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
|
||||||
const now = yield* Clock.currentTimeMillis
|
|
||||||
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
|
||||||
|
|
||||||
const response = yield* executeEffectOk(
|
|
||||||
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
|
|
||||||
new TokenRefreshRequest({
|
|
||||||
grant_type: "refresh_token",
|
|
||||||
refresh_token: row.refresh_token,
|
|
||||||
client_id,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
|
|
||||||
map("Failed to decode response"),
|
|
||||||
)
|
|
||||||
|
|
||||||
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
|
|
||||||
|
|
||||||
yield* repo.persistToken({
|
|
||||||
accountID: row.id,
|
|
||||||
accessToken: parsed.access_token,
|
|
||||||
refreshToken: parsed.refresh_token,
|
|
||||||
expiry,
|
|
||||||
})
|
|
||||||
|
|
||||||
return parsed.access_token
|
|
||||||
})
|
|
||||||
|
|
||||||
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
|
|
||||||
const maybe = yield* repo.getRow(accountID)
|
|
||||||
if (Option.isNone(maybe)) return Option.none()
|
|
||||||
|
|
||||||
const account = maybe.value
|
|
||||||
const accessToken = yield* resolveToken(account)
|
|
||||||
return Option.some({ account, accessToken })
|
|
||||||
})
|
|
||||||
|
|
||||||
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
|
||||||
const response = yield* executeReadOk(
|
|
||||||
HttpClientRequest.get(`${url}/api/orgs`).pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
HttpClientRequest.bearerToken(accessToken),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
|
|
||||||
map("Failed to decode response"),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
|
||||||
const response = yield* executeReadOk(
|
|
||||||
HttpClientRequest.get(`${url}/api/user`).pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
HttpClientRequest.bearerToken(accessToken),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(map("Failed to decode response"))
|
|
||||||
})
|
|
||||||
|
|
||||||
const token = Effect.fn("Account.token")((accountID: AccountID) =>
|
|
||||||
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
|
|
||||||
)
|
|
||||||
|
|
||||||
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
|
|
||||||
const resolved = yield* resolveAccess(accountID)
|
|
||||||
if (Option.isNone(resolved)) return []
|
|
||||||
|
|
||||||
return yield* fetchOrgs(resolved.value.account.url, resolved.value.accessToken)
|
|
||||||
})
|
|
||||||
|
|
||||||
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
|
|
||||||
const accounts = yield* repo.list()
|
|
||||||
const [errors, results] = yield* Effect.partition(
|
|
||||||
accounts,
|
|
||||||
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
|
|
||||||
{ concurrency: 3 },
|
|
||||||
)
|
|
||||||
for (const err of errors) {
|
|
||||||
yield* Effect.logWarning("failed to fetch orgs for account").pipe(Effect.annotateLogs({ error: String(err) }))
|
|
||||||
}
|
|
||||||
return results
|
|
||||||
})
|
|
||||||
|
|
||||||
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
|
|
||||||
const resolved = yield* resolveAccess(accountID)
|
|
||||||
if (Option.isNone(resolved)) return Option.none()
|
|
||||||
|
|
||||||
const response = yield* executeRead(
|
|
||||||
HttpClientRequest.get(`${resolved.value.account.url}/api/config`).pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
HttpClientRequest.bearerToken(resolved.value.accessToken),
|
|
||||||
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (response.status === 404) return Option.none()
|
|
||||||
|
|
||||||
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(map())
|
|
||||||
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(map("Failed to decode response"))
|
|
||||||
return Option.some(parsed.config)
|
|
||||||
})
|
|
||||||
|
|
||||||
const login = Effect.fn("Account.login")(function* (server: string) {
|
|
||||||
const response = yield* executeEffectOk(
|
|
||||||
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id })),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(map("Failed to decode response"))
|
|
||||||
return new Login({
|
|
||||||
code: parsed.device_code,
|
|
||||||
user: parsed.user_code,
|
|
||||||
url: `${server}${parsed.verification_uri_complete}`,
|
|
||||||
server,
|
|
||||||
expiry: parsed.expires_in,
|
|
||||||
interval: parsed.interval,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const poll = Effect.fn("Account.poll")(function* (input: Login) {
|
|
||||||
const response = yield* executeEffectOk(
|
|
||||||
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
|
|
||||||
new DeviceTokenRequest({
|
|
||||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
||||||
device_code: input.code,
|
|
||||||
client_id,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(map("Failed to decode response"))
|
|
||||||
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
|
|
||||||
|
|
||||||
const [account, remoteOrgs] = yield* Effect.all(
|
|
||||||
[fetchUser(input.server, parsed.access_token), fetchOrgs(input.server, parsed.access_token)],
|
|
||||||
{ concurrency: 2 },
|
|
||||||
)
|
|
||||||
|
|
||||||
const first = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
|
|
||||||
const expiry = (yield* Clock.currentTimeMillis) + Duration.toMillis(parsed.expires_in)
|
|
||||||
|
|
||||||
yield* repo.persistAccount({
|
|
||||||
id: account.id,
|
|
||||||
email: account.email,
|
|
||||||
url: input.server,
|
|
||||||
accessToken: parsed.access_token,
|
|
||||||
refreshToken: parsed.refresh_token,
|
|
||||||
expiry,
|
|
||||||
orgID: first,
|
|
||||||
})
|
|
||||||
|
|
||||||
return new PollSuccess({ email: account.email })
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
active: repo.active,
|
|
||||||
list: repo.list,
|
|
||||||
orgsByAccount,
|
|
||||||
remove: repo.remove,
|
|
||||||
use: repo.use,
|
|
||||||
orgs,
|
|
||||||
config,
|
|
||||||
token,
|
|
||||||
login,
|
|
||||||
poll,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
|
||||||
}
|
|
||||||
@@ -1,33 +1,27 @@
|
|||||||
import { Effect, Option } from "effect"
|
import { Effect, Option } from "effect"
|
||||||
|
|
||||||
import { AccountEffect } from "./effect"
|
|
||||||
import {
|
import {
|
||||||
AccessToken as Token,
|
|
||||||
Account as AccountSchema,
|
Account as AccountSchema,
|
||||||
type AccountError,
|
type AccountError,
|
||||||
AccountID as ID,
|
type AccessToken,
|
||||||
OrgID as Org,
|
AccountID,
|
||||||
} from "./schema"
|
AccountService,
|
||||||
|
OrgID,
|
||||||
|
} from "./service"
|
||||||
|
|
||||||
|
export { AccessToken, AccountID, OrgID } from "./service"
|
||||||
|
|
||||||
import { runtime } from "@/effect/runtime"
|
import { runtime } from "@/effect/runtime"
|
||||||
|
|
||||||
export { AccessToken, AccountID, OrgID } from "./schema"
|
function runSync<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) {
|
||||||
|
return runtime.runSync(AccountService.use(f))
|
||||||
function runSync<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountEffect.Error>) {
|
|
||||||
return runtime.runSync(AccountEffect.Service.use(f))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function runPromise<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
|
function runPromise<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) {
|
||||||
return runtime.runPromise(AccountEffect.Service.use(f))
|
return runtime.runPromise(AccountService.use(f))
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace Account {
|
export namespace Account {
|
||||||
export const AccessToken = Token
|
|
||||||
export type AccessToken = Token
|
|
||||||
export const AccountID = ID
|
|
||||||
export type AccountID = ID
|
|
||||||
export const OrgID = Org
|
|
||||||
export type OrgID = Org
|
|
||||||
export const Account = AccountSchema
|
export const Account = AccountSchema
|
||||||
export type Account = AccountSchema
|
export type Account = AccountSchema
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
||||||
|
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
|
|
||||||
|
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||||
|
import { AccountRepo, type AccountRow } from "./repo"
|
||||||
|
import {
|
||||||
|
type AccountError,
|
||||||
|
AccessToken,
|
||||||
|
Account,
|
||||||
|
AccountID,
|
||||||
|
DeviceCode,
|
||||||
|
RefreshToken,
|
||||||
|
AccountServiceError,
|
||||||
|
Login,
|
||||||
|
Org,
|
||||||
|
OrgID,
|
||||||
|
PollDenied,
|
||||||
|
PollError,
|
||||||
|
PollExpired,
|
||||||
|
PollPending,
|
||||||
|
type PollResult,
|
||||||
|
PollSlow,
|
||||||
|
PollSuccess,
|
||||||
|
UserCode,
|
||||||
|
} from "./schema"
|
||||||
|
|
||||||
|
export * from "./schema"
|
||||||
|
|
||||||
|
export type AccountOrgs = {
|
||||||
|
account: Account
|
||||||
|
orgs: readonly Org[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
|
||||||
|
config: Schema.Record(Schema.String, Schema.Json),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
const DurationFromSeconds = Schema.Number.pipe(
|
||||||
|
Schema.decodeTo(Schema.Duration, {
|
||||||
|
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
|
||||||
|
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
|
||||||
|
access_token: AccessToken,
|
||||||
|
refresh_token: RefreshToken,
|
||||||
|
expires_in: DurationFromSeconds,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
||||||
|
device_code: DeviceCode,
|
||||||
|
user_code: UserCode,
|
||||||
|
verification_uri_complete: Schema.String,
|
||||||
|
expires_in: DurationFromSeconds,
|
||||||
|
interval: DurationFromSeconds,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
|
||||||
|
access_token: AccessToken,
|
||||||
|
refresh_token: RefreshToken,
|
||||||
|
token_type: Schema.Literal("Bearer"),
|
||||||
|
expires_in: DurationFromSeconds,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
|
||||||
|
error: Schema.String,
|
||||||
|
error_description: Schema.String,
|
||||||
|
}) {
|
||||||
|
toPollResult(): PollResult {
|
||||||
|
if (this.error === "authorization_pending") return new PollPending()
|
||||||
|
if (this.error === "slow_down") return new PollSlow()
|
||||||
|
if (this.error === "expired_token") return new PollExpired()
|
||||||
|
if (this.error === "access_denied") return new PollDenied()
|
||||||
|
return new PollError({ cause: this.error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
|
||||||
|
|
||||||
|
class User extends Schema.Class<User>("User")({
|
||||||
|
id: AccountID,
|
||||||
|
email: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
|
||||||
|
|
||||||
|
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
|
||||||
|
grant_type: Schema.String,
|
||||||
|
device_code: DeviceCode,
|
||||||
|
client_id: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
|
||||||
|
grant_type: Schema.String,
|
||||||
|
refresh_token: RefreshToken,
|
||||||
|
client_id: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
const clientId = "opencode-cli"
|
||||||
|
|
||||||
|
const mapAccountServiceError =
|
||||||
|
(message = "Account service operation failed") =>
|
||||||
|
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
|
||||||
|
effect.pipe(
|
||||||
|
Effect.mapError((cause) =>
|
||||||
|
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
export namespace AccountService {
|
||||||
|
export interface Service {
|
||||||
|
readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
|
||||||
|
readonly list: () => Effect.Effect<Account[], AccountError>
|
||||||
|
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
|
||||||
|
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
|
||||||
|
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
|
||||||
|
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
|
||||||
|
readonly config: (
|
||||||
|
accountID: AccountID,
|
||||||
|
orgID: OrgID,
|
||||||
|
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
|
||||||
|
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
|
||||||
|
readonly login: (url: string) => Effect.Effect<Login, AccountError>
|
||||||
|
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AccountService extends ServiceMap.Service<AccountService, AccountService.Service>()("@opencode/Account") {
|
||||||
|
static readonly layer: Layer.Layer<AccountService, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
|
||||||
|
AccountService,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const repo = yield* AccountRepo
|
||||||
|
const http = yield* HttpClient.HttpClient
|
||||||
|
const httpRead = withTransientReadRetry(http)
|
||||||
|
const httpOk = HttpClient.filterStatusOk(http)
|
||||||
|
const httpReadOk = HttpClient.filterStatusOk(httpRead)
|
||||||
|
|
||||||
|
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
|
||||||
|
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
||||||
|
|
||||||
|
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
|
||||||
|
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
||||||
|
|
||||||
|
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||||
|
request.pipe(
|
||||||
|
Effect.flatMap((req) => httpOk.execute(req)),
|
||||||
|
mapAccountServiceError("HTTP request failed"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Returns a usable access token for a stored account row, refreshing and
|
||||||
|
// persisting it when the cached token has expired.
|
||||||
|
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
||||||
|
const now = yield* Clock.currentTimeMillis
|
||||||
|
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
||||||
|
|
||||||
|
const response = yield* executeEffectOk(
|
||||||
|
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
|
||||||
|
new TokenRefreshRequest({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: row.refresh_token,
|
||||||
|
client_id: clientId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
|
||||||
|
mapAccountServiceError("Failed to decode response"),
|
||||||
|
)
|
||||||
|
|
||||||
|
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
|
||||||
|
|
||||||
|
yield* repo.persistToken({
|
||||||
|
accountID: row.id,
|
||||||
|
accessToken: parsed.access_token,
|
||||||
|
refreshToken: parsed.refresh_token,
|
||||||
|
expiry,
|
||||||
|
})
|
||||||
|
|
||||||
|
return parsed.access_token
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
|
||||||
|
const maybeAccount = yield* repo.getRow(accountID)
|
||||||
|
if (Option.isNone(maybeAccount)) return Option.none()
|
||||||
|
|
||||||
|
const account = maybeAccount.value
|
||||||
|
const accessToken = yield* resolveToken(account)
|
||||||
|
return Option.some({ account, accessToken })
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
||||||
|
const response = yield* executeReadOk(
|
||||||
|
HttpClientRequest.get(`${url}/api/orgs`).pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
HttpClientRequest.bearerToken(accessToken),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
|
||||||
|
mapAccountServiceError("Failed to decode response"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
||||||
|
const response = yield* executeReadOk(
|
||||||
|
HttpClientRequest.get(`${url}/api/user`).pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
HttpClientRequest.bearerToken(accessToken),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
|
||||||
|
mapAccountServiceError("Failed to decode response"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const token = Effect.fn("AccountService.token")((accountID: AccountID) =>
|
||||||
|
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
|
||||||
|
)
|
||||||
|
|
||||||
|
const orgsByAccount = Effect.fn("AccountService.orgsByAccount")(function* () {
|
||||||
|
const accounts = yield* repo.list()
|
||||||
|
const [errors, results] = yield* Effect.partition(
|
||||||
|
accounts,
|
||||||
|
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
|
||||||
|
{ concurrency: 3 },
|
||||||
|
)
|
||||||
|
for (const error of errors) {
|
||||||
|
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
|
||||||
|
Effect.annotateLogs({ error: String(error) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
})
|
||||||
|
|
||||||
|
const orgs = Effect.fn("AccountService.orgs")(function* (accountID: AccountID) {
|
||||||
|
const resolved = yield* resolveAccess(accountID)
|
||||||
|
if (Option.isNone(resolved)) return []
|
||||||
|
|
||||||
|
const { account, accessToken } = resolved.value
|
||||||
|
|
||||||
|
return yield* fetchOrgs(account.url, accessToken)
|
||||||
|
})
|
||||||
|
|
||||||
|
const config = Effect.fn("AccountService.config")(function* (accountID: AccountID, orgID: OrgID) {
|
||||||
|
const resolved = yield* resolveAccess(accountID)
|
||||||
|
if (Option.isNone(resolved)) return Option.none()
|
||||||
|
|
||||||
|
const { account, accessToken } = resolved.value
|
||||||
|
|
||||||
|
const response = yield* executeRead(
|
||||||
|
HttpClientRequest.get(`${account.url}/api/config`).pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
HttpClientRequest.bearerToken(accessToken),
|
||||||
|
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.status === 404) return Option.none()
|
||||||
|
|
||||||
|
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
|
||||||
|
|
||||||
|
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
|
||||||
|
mapAccountServiceError("Failed to decode response"),
|
||||||
|
)
|
||||||
|
return Option.some(parsed.config)
|
||||||
|
})
|
||||||
|
|
||||||
|
const login = Effect.fn("AccountService.login")(function* (server: string) {
|
||||||
|
const response = yield* executeEffectOk(
|
||||||
|
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
|
||||||
|
mapAccountServiceError("Failed to decode response"),
|
||||||
|
)
|
||||||
|
return new Login({
|
||||||
|
code: parsed.device_code,
|
||||||
|
user: parsed.user_code,
|
||||||
|
url: `${server}${parsed.verification_uri_complete}`,
|
||||||
|
server,
|
||||||
|
expiry: parsed.expires_in,
|
||||||
|
interval: parsed.interval,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const poll = Effect.fn("AccountService.poll")(function* (input: Login) {
|
||||||
|
const response = yield* executeEffectOk(
|
||||||
|
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
|
||||||
|
new DeviceTokenRequest({
|
||||||
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||||
|
device_code: input.code,
|
||||||
|
client_id: clientId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
|
||||||
|
mapAccountServiceError("Failed to decode response"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
|
||||||
|
const accessToken = parsed.access_token
|
||||||
|
|
||||||
|
const user = fetchUser(input.server, accessToken)
|
||||||
|
const orgs = fetchOrgs(input.server, accessToken)
|
||||||
|
|
||||||
|
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
|
||||||
|
|
||||||
|
// TODO: When there are multiple orgs, let the user choose
|
||||||
|
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
|
||||||
|
|
||||||
|
const now = yield* Clock.currentTimeMillis
|
||||||
|
const expiry = now + Duration.toMillis(parsed.expires_in)
|
||||||
|
const refreshToken = parsed.refresh_token
|
||||||
|
|
||||||
|
yield* repo.persistAccount({
|
||||||
|
id: account.id,
|
||||||
|
email: account.email,
|
||||||
|
url: input.server,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
expiry,
|
||||||
|
orgID: firstOrgID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return new PollSuccess({ email: account.email })
|
||||||
|
})
|
||||||
|
|
||||||
|
return AccountService.of({
|
||||||
|
active: repo.active,
|
||||||
|
list: repo.list,
|
||||||
|
orgsByAccount,
|
||||||
|
remove: repo.remove,
|
||||||
|
use: repo.use,
|
||||||
|
orgs,
|
||||||
|
config,
|
||||||
|
token,
|
||||||
|
login,
|
||||||
|
poll,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
static readonly defaultLayer = AccountService.layer.pipe(
|
||||||
|
Layer.provide(AccountRepo.layer),
|
||||||
|
Layer.provide(FetchHttpClient.layer),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { ModelID, ProviderID } from "../provider/schema"
|
|||||||
import { generateObject, streamObject, type ModelMessage } from "ai"
|
import { generateObject, streamObject, type ModelMessage } from "ai"
|
||||||
import { SystemPrompt } from "../session/system"
|
import { SystemPrompt } from "../session/system"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Truncate } from "../tool/truncate"
|
import { Truncate } from "../tool/truncation"
|
||||||
import { Auth } from "../auth"
|
import { Auth } from "../auth"
|
||||||
import { ProviderTransform } from "../provider/transform"
|
import { ProviderTransform } from "../provider/transform"
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
|||||||
import PROMPT_EXPLORE from "./prompt/explore.txt"
|
import PROMPT_EXPLORE from "./prompt/explore.txt"
|
||||||
import PROMPT_SUMMARY from "./prompt/summary.txt"
|
import PROMPT_SUMMARY from "./prompt/summary.txt"
|
||||||
import PROMPT_TITLE from "./prompt/title.txt"
|
import PROMPT_TITLE from "./prompt/title.txt"
|
||||||
import { PermissionNext } from "@/permission"
|
import { PermissionNext } from "@/permission/next"
|
||||||
import { mergeDeep, pipe, sortBy, values } from "remeda"
|
import { mergeDeep, pipe, sortBy, values } from "remeda"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
import path from "path"
|
|
||||||
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
|
|
||||||
import { Global } from "../global"
|
|
||||||
import { Filesystem } from "../util/filesystem"
|
|
||||||
|
|
||||||
export namespace AuthEffect {
|
|
||||||
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
|
||||||
|
|
||||||
export class Oauth extends Schema.Class<Oauth>("OAuth")({
|
|
||||||
type: Schema.Literal("oauth"),
|
|
||||||
refresh: Schema.String,
|
|
||||||
access: Schema.String,
|
|
||||||
expires: Schema.Number,
|
|
||||||
accountId: Schema.optional(Schema.String),
|
|
||||||
enterpriseUrl: Schema.optional(Schema.String),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class ApiAuth extends Schema.Class<ApiAuth>("ApiAuth")({
|
|
||||||
type: Schema.Literal("api"),
|
|
||||||
key: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
|
|
||||||
type: Schema.Literal("wellknown"),
|
|
||||||
key: Schema.String,
|
|
||||||
token: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export const Info = Schema.Union([Oauth, ApiAuth, WellKnown])
|
|
||||||
export type Info = Schema.Schema.Type<typeof Info>
|
|
||||||
|
|
||||||
export class AuthServiceError extends Schema.TaggedErrorClass<AuthServiceError>()("AuthServiceError", {
|
|
||||||
message: Schema.String,
|
|
||||||
cause: Schema.optional(Schema.Defect),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export type Error = AuthServiceError
|
|
||||||
|
|
||||||
const file = path.join(Global.Path.data, "auth.json")
|
|
||||||
|
|
||||||
const fail = (message: string) => (cause: unknown) => new AuthServiceError({ message, cause })
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthServiceError>
|
|
||||||
readonly all: () => Effect.Effect<Record<string, Info>, AuthServiceError>
|
|
||||||
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthServiceError>
|
|
||||||
readonly remove: (key: string) => Effect.Effect<void, AuthServiceError>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const decode = Schema.decodeUnknownOption(Info)
|
|
||||||
|
|
||||||
const all = Effect.fn("Auth.all")(() =>
|
|
||||||
Effect.tryPromise({
|
|
||||||
try: async () => {
|
|
||||||
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
|
|
||||||
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
|
|
||||||
},
|
|
||||||
catch: fail("Failed to read auth data"),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const get = Effect.fn("Auth.get")(function* (providerID: string) {
|
|
||||||
return (yield* all())[providerID]
|
|
||||||
})
|
|
||||||
|
|
||||||
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
|
|
||||||
const norm = key.replace(/\/+$/, "")
|
|
||||||
const data = yield* all()
|
|
||||||
if (norm !== key) delete data[key]
|
|
||||||
delete data[norm + "/"]
|
|
||||||
yield* Effect.tryPromise({
|
|
||||||
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
|
|
||||||
catch: fail("Failed to write auth data"),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const remove = Effect.fn("Auth.remove")(function* (key: string) {
|
|
||||||
const norm = key.replace(/\/+$/, "")
|
|
||||||
const data = yield* all()
|
|
||||||
delete data[key]
|
|
||||||
delete data[norm]
|
|
||||||
yield* Effect.tryPromise({
|
|
||||||
try: () => Filesystem.writeJson(file, data, 0o600),
|
|
||||||
catch: fail("Failed to write auth data"),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ get, all, set, remove })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { runtime } from "@/effect/runtime"
|
import { runtime } from "@/effect/runtime"
|
||||||
import * as S from "./effect"
|
import * as S from "./service"
|
||||||
|
|
||||||
export const OAUTH_DUMMY_KEY = S.AuthEffect.OAUTH_DUMMY_KEY
|
export { OAUTH_DUMMY_KEY } from "./service"
|
||||||
|
|
||||||
function runPromise<A>(f: (service: S.AuthEffect.Interface) => Effect.Effect<A, S.AuthEffect.Error>) {
|
function runPromise<A>(f: (service: S.AuthService.Service) => Effect.Effect<A, S.AuthServiceError>) {
|
||||||
return runtime.runPromise(S.AuthEffect.Service.use(f))
|
return runtime.runPromise(S.AuthService.use(f))
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace Auth {
|
export namespace Auth {
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import path from "path"
|
||||||
|
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
|
||||||
|
import { Global } from "../global"
|
||||||
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
|
||||||
|
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
||||||
|
|
||||||
|
export class Oauth extends Schema.Class<Oauth>("OAuth")({
|
||||||
|
type: Schema.Literal("oauth"),
|
||||||
|
refresh: Schema.String,
|
||||||
|
access: Schema.String,
|
||||||
|
expires: Schema.Number,
|
||||||
|
accountId: Schema.optional(Schema.String),
|
||||||
|
enterpriseUrl: Schema.optional(Schema.String),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class Api extends Schema.Class<Api>("ApiAuth")({
|
||||||
|
type: Schema.Literal("api"),
|
||||||
|
key: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
|
||||||
|
type: Schema.Literal("wellknown"),
|
||||||
|
key: Schema.String,
|
||||||
|
token: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export const Info = Schema.Union([Oauth, Api, WellKnown])
|
||||||
|
export type Info = Schema.Schema.Type<typeof Info>
|
||||||
|
|
||||||
|
export class AuthServiceError extends Schema.TaggedErrorClass<AuthServiceError>()("AuthServiceError", {
|
||||||
|
message: Schema.String,
|
||||||
|
cause: Schema.optional(Schema.Defect),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
const file = path.join(Global.Path.data, "auth.json")
|
||||||
|
|
||||||
|
const fail = (message: string) => (cause: unknown) => new AuthServiceError({ message, cause })
|
||||||
|
|
||||||
|
export namespace AuthService {
|
||||||
|
export interface Service {
|
||||||
|
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthServiceError>
|
||||||
|
readonly all: () => Effect.Effect<Record<string, Info>, AuthServiceError>
|
||||||
|
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthServiceError>
|
||||||
|
readonly remove: (key: string) => Effect.Effect<void, AuthServiceError>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthService extends ServiceMap.Service<AuthService, AuthService.Service>()("@opencode/Auth") {
|
||||||
|
static readonly layer = Layer.effect(
|
||||||
|
AuthService,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const decode = Schema.decodeUnknownOption(Info)
|
||||||
|
|
||||||
|
const all = Effect.fn("AuthService.all")(() =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: async () => {
|
||||||
|
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
|
||||||
|
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
|
||||||
|
},
|
||||||
|
catch: fail("Failed to read auth data"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const get = Effect.fn("AuthService.get")(function* (providerID: string) {
|
||||||
|
return (yield* all())[providerID]
|
||||||
|
})
|
||||||
|
|
||||||
|
const set = Effect.fn("AuthService.set")(function* (key: string, info: Info) {
|
||||||
|
const norm = key.replace(/\/+$/, "")
|
||||||
|
const data = yield* all()
|
||||||
|
if (norm !== key) delete data[key]
|
||||||
|
delete data[norm + "/"]
|
||||||
|
yield* Effect.tryPromise({
|
||||||
|
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
|
||||||
|
catch: fail("Failed to write auth data"),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const remove = Effect.fn("AuthService.remove")(function* (key: string) {
|
||||||
|
const norm = key.replace(/\/+$/, "")
|
||||||
|
const data = yield* all()
|
||||||
|
delete data[key]
|
||||||
|
delete data[norm]
|
||||||
|
yield* Effect.tryPromise({
|
||||||
|
try: () => Filesystem.writeJson(file, data, 0o600),
|
||||||
|
catch: fail("Failed to write auth data"),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return AuthService.of({
|
||||||
|
get,
|
||||||
|
all,
|
||||||
|
set,
|
||||||
|
remove,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
static readonly defaultLayer = AuthService.layer
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@ import { cmd } from "./cmd"
|
|||||||
import { Duration, Effect, Match, Option } from "effect"
|
import { Duration, Effect, Match, Option } from "effect"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { runtime } from "@/effect/runtime"
|
import { runtime } from "@/effect/runtime"
|
||||||
import { AccountEffect } from "@/account/effect"
|
import { AccountID, AccountService, OrgID, PollExpired, type PollResult } from "@/account/service"
|
||||||
import { type AccountError, AccountID, OrgID, PollExpired, type PollResult } from "@/account/schema"
|
import { type AccountError } from "@/account/schema"
|
||||||
import * as Prompt from "../effect/prompt"
|
import * as Prompt from "../effect/prompt"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ const isActiveOrgChoice = (
|
|||||||
) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID
|
) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID
|
||||||
|
|
||||||
const loginEffect = Effect.fn("login")(function* (url: string) {
|
const loginEffect = Effect.fn("login")(function* (url: string) {
|
||||||
const service = yield* AccountEffect.Service
|
const service = yield* AccountService
|
||||||
|
|
||||||
yield* Prompt.intro("Log in")
|
yield* Prompt.intro("Log in")
|
||||||
const login = yield* service.login(url)
|
const login = yield* service.login(url)
|
||||||
@@ -58,7 +58,7 @@ const loginEffect = Effect.fn("login")(function* (url: string) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const logoutEffect = Effect.fn("logout")(function* (email?: string) {
|
const logoutEffect = Effect.fn("logout")(function* (email?: string) {
|
||||||
const service = yield* AccountEffect.Service
|
const service = yield* AccountService
|
||||||
const accounts = yield* service.list()
|
const accounts = yield* service.list()
|
||||||
if (accounts.length === 0) return yield* println("Not logged in")
|
if (accounts.length === 0) return yield* println("Not logged in")
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ interface OrgChoice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const switchEffect = Effect.fn("switch")(function* () {
|
const switchEffect = Effect.fn("switch")(function* () {
|
||||||
const service = yield* AccountEffect.Service
|
const service = yield* AccountService
|
||||||
|
|
||||||
const groups = yield* service.orgsByAccount()
|
const groups = yield* service.orgsByAccount()
|
||||||
if (groups.length === 0) return yield* println("Not logged in")
|
if (groups.length === 0) return yield* println("Not logged in")
|
||||||
@@ -129,7 +129,7 @@ const switchEffect = Effect.fn("switch")(function* () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const orgsEffect = Effect.fn("orgs")(function* () {
|
const orgsEffect = Effect.fn("orgs")(function* () {
|
||||||
const service = yield* AccountEffect.Service
|
const service = yield* AccountService
|
||||||
|
|
||||||
const groups = yield* service.orgsByAccount()
|
const groups = yield* service.orgsByAccount()
|
||||||
if (groups.length === 0) return yield* println("No accounts found")
|
if (groups.length === 0) return yield* println("No accounts found")
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { MessageV2 } from "../../../session/message-v2"
|
|||||||
import { MessageID, PartID } from "../../../session/schema"
|
import { MessageID, PartID } from "../../../session/schema"
|
||||||
import { ToolRegistry } from "../../../tool/registry"
|
import { ToolRegistry } from "../../../tool/registry"
|
||||||
import { Instance } from "../../../project/instance"
|
import { Instance } from "../../../project/instance"
|
||||||
import { PermissionNext } from "../../../permission"
|
import { PermissionNext } from "../../../permission/next"
|
||||||
import { iife } from "../../../util/iife"
|
import { iife } from "../../../util/iife"
|
||||||
import { bootstrap } from "../../bootstrap"
|
import { bootstrap } from "../../bootstrap"
|
||||||
import { cmd } from "../cmd"
|
import { cmd } from "../cmd"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart
|
|||||||
import { Server } from "../../server/server"
|
import { Server } from "../../server/server"
|
||||||
import { Provider } from "../../provider/provider"
|
import { Provider } from "../../provider/provider"
|
||||||
import { Agent } from "../../agent/agent"
|
import { Agent } from "../../agent/agent"
|
||||||
import { PermissionNext } from "../../permission"
|
import { PermissionNext } from "../../permission/next"
|
||||||
import { Tool } from "../../tool/tool"
|
import { Tool } from "../../tool/tool"
|
||||||
import { GlobTool } from "../../tool/glob"
|
import { GlobTool } from "../../tool/glob"
|
||||||
import { GrepTool } from "../../tool/grep"
|
import { GrepTool } from "../../tool/grep"
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ function init() {
|
|||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
if (store.stack.length === 0) return
|
if (store.stack.length === 0) return
|
||||||
if (evt.defaultPrevented) return
|
if (evt.defaultPrevented) return
|
||||||
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return
|
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()) return
|
||||||
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
|
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
|
||||||
const current = store.stack.at(-1)!
|
const current = store.stack.at(-1)!
|
||||||
current.onClose?.()
|
current.onClose?.()
|
||||||
|
|||||||
@@ -17,21 +17,17 @@ export namespace Editor {
|
|||||||
await Filesystem.write(filepath, opts.value)
|
await Filesystem.write(filepath, opts.value)
|
||||||
opts.renderer.suspend()
|
opts.renderer.suspend()
|
||||||
opts.renderer.currentRenderBuffer.clear()
|
opts.renderer.currentRenderBuffer.clear()
|
||||||
try {
|
const parts = editor.split(" ")
|
||||||
const parts = editor.split(" ")
|
const proc = Process.spawn([...parts, filepath], {
|
||||||
const proc = Process.spawn([...parts, filepath], {
|
stdin: "inherit",
|
||||||
stdin: "inherit",
|
stdout: "inherit",
|
||||||
stdout: "inherit",
|
stderr: "inherit",
|
||||||
stderr: "inherit",
|
})
|
||||||
shell: process.platform === "win32",
|
await proc.exited
|
||||||
})
|
const content = await Filesystem.readText(filepath)
|
||||||
await proc.exited
|
opts.renderer.currentRenderBuffer.clear()
|
||||||
const content = await Filesystem.readText(filepath)
|
opts.renderer.resume()
|
||||||
return content || undefined
|
opts.renderer.requestRender()
|
||||||
} finally {
|
return content || undefined
|
||||||
opts.renderer.currentRenderBuffer.clear()
|
|
||||||
opts.renderer.resume()
|
|
||||||
opts.renderer.requestRender()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1052,12 +1052,7 @@ export namespace Config {
|
|||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
plugin: z.string().array().optional(),
|
plugin: z.string().array().optional(),
|
||||||
snapshot: z
|
snapshot: z.boolean().optional(),
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
|
|
||||||
),
|
|
||||||
share: z
|
share: z
|
||||||
.enum(["manual", "auto", "disabled"])
|
.enum(["manual", "auto", "disabled"])
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { ServiceMap } from "effect";
|
import { ServiceMap } from "effect"
|
||||||
import type { Project } from "@/project/project";
|
import type { Project } from "@/project/project"
|
||||||
|
|
||||||
export declare namespace InstanceContext {
|
export declare namespace InstanceContext {
|
||||||
export interface Shape {
|
export interface Shape {
|
||||||
readonly directory: string;
|
readonly directory: string
|
||||||
readonly worktree: string;
|
readonly project: Project.Info
|
||||||
readonly project: Project.Info;
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InstanceContext extends ServiceMap.Service<
|
export class InstanceContext extends ServiceMap.Service<InstanceContext, InstanceContext.Shape>()(
|
||||||
InstanceContext,
|
"opencode/InstanceContext",
|
||||||
InstanceContext.Shape
|
) {}
|
||||||
>()("opencode/InstanceContext") {}
|
|
||||||
|
|||||||
@@ -1,51 +1,43 @@
|
|||||||
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
|
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
|
||||||
import { File } from "@/file"
|
|
||||||
import { FileTime } from "@/file/time"
|
|
||||||
import { FileWatcher } from "@/file/watcher"
|
|
||||||
import { Format } from "@/format"
|
|
||||||
import { PermissionEffect } from "@/permission/effect"
|
|
||||||
import { Instance } from "@/project/instance"
|
|
||||||
import { Vcs } from "@/project/vcs"
|
|
||||||
import { ProviderAuthEffect } from "@/provider/auth-effect"
|
|
||||||
import { QuestionEffect } from "@/question/effect"
|
|
||||||
import { Skill } from "@/skill/skill"
|
|
||||||
import { Snapshot } from "@/snapshot"
|
|
||||||
import { InstanceContext } from "./instance-context"
|
|
||||||
import { registerDisposer } from "./instance-registry"
|
import { registerDisposer } from "./instance-registry"
|
||||||
|
import { InstanceContext } from "./instance-context"
|
||||||
|
import { ProviderAuthService } from "@/provider/auth-service"
|
||||||
|
import { QuestionService } from "@/question/service"
|
||||||
|
import { PermissionService } from "@/permission/service"
|
||||||
|
import { FileWatcherService } from "@/file/watcher"
|
||||||
|
import { VcsService } from "@/project/vcs"
|
||||||
|
import { FileTimeService } from "@/file/time"
|
||||||
|
import { FormatService } from "@/format"
|
||||||
|
import { FileService } from "@/file"
|
||||||
|
import { SkillService } from "@/skill/skill"
|
||||||
|
import { Instance } from "@/project/instance"
|
||||||
|
|
||||||
export { InstanceContext } from "./instance-context"
|
export { InstanceContext } from "./instance-context"
|
||||||
|
|
||||||
export type InstanceServices =
|
export type InstanceServices =
|
||||||
| QuestionEffect.Service
|
| QuestionService
|
||||||
| PermissionEffect.Service
|
| PermissionService
|
||||||
| ProviderAuthEffect.Service
|
| ProviderAuthService
|
||||||
| FileWatcher.Service
|
| FileWatcherService
|
||||||
| Vcs.Service
|
| VcsService
|
||||||
| FileTime.Service
|
| FileTimeService
|
||||||
| Format.Service
|
| FormatService
|
||||||
| File.Service
|
| FileService
|
||||||
| Skill.Service
|
| SkillService
|
||||||
| Snapshot.Service
|
|
||||||
|
|
||||||
// NOTE: LayerMap only passes the key (directory string) to lookup, but we need
|
function lookup(directory: string) {
|
||||||
// the full instance context (directory, worktree, project). We read from the
|
const project = Instance.project
|
||||||
// legacy Instance ALS here, which is safe because lookup is only triggered via
|
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of({ directory, project }))
|
||||||
// runPromiseInstance -> Instances.get, which always runs inside Instance.provide.
|
|
||||||
// This should go away once the old Instance type is removed and lookup can load
|
|
||||||
// the full context directly.
|
|
||||||
function lookup(_key: string) {
|
|
||||||
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
|
|
||||||
return Layer.mergeAll(
|
return Layer.mergeAll(
|
||||||
Layer.fresh(QuestionEffect.layer),
|
Layer.fresh(QuestionService.layer),
|
||||||
Layer.fresh(PermissionEffect.layer),
|
Layer.fresh(PermissionService.layer),
|
||||||
Layer.fresh(ProviderAuthEffect.defaultLayer),
|
Layer.fresh(ProviderAuthService.layer),
|
||||||
Layer.fresh(FileWatcher.layer).pipe(Layer.orDie),
|
Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie),
|
||||||
Layer.fresh(Vcs.layer),
|
Layer.fresh(VcsService.layer),
|
||||||
Layer.fresh(FileTime.layer).pipe(Layer.orDie),
|
Layer.fresh(FileTimeService.layer).pipe(Layer.orDie),
|
||||||
Layer.fresh(Format.layer),
|
Layer.fresh(FormatService.layer),
|
||||||
Layer.fresh(File.layer),
|
Layer.fresh(FileService.layer),
|
||||||
Layer.fresh(Skill.defaultLayer),
|
Layer.fresh(SkillService.layer),
|
||||||
Layer.fresh(Snapshot.defaultLayer),
|
|
||||||
).pipe(Layer.provide(ctx))
|
).pipe(Layer.provide(ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,9 +47,7 @@ export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<s
|
|||||||
static readonly layer = Layer.effect(
|
static readonly layer = Layer.effect(
|
||||||
Instances,
|
Instances,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const layerMap = yield* LayerMap.make(lookup, {
|
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
|
||||||
idleTimeToLive: Infinity,
|
|
||||||
})
|
|
||||||
const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
|
const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
|
||||||
yield* Effect.addFinalizer(() => Effect.sync(unregister))
|
yield* Effect.addFinalizer(() => Effect.sync(unregister))
|
||||||
return Instances.of(layerMap)
|
return Instances.of(layerMap)
|
||||||
@@ -67,4 +57,8 @@ export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<s
|
|||||||
static get(directory: string): Layer.Layer<InstanceServices, never, Instances> {
|
static get(directory: string): Layer.Layer<InstanceServices, never, Instances> {
|
||||||
return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory))))
|
return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static invalidate(directory: string): Effect.Effect<void, never, Instances> {
|
||||||
|
return Instances.use((map) => map.invalidate(directory))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,14 @@
|
|||||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||||
import { AccountEffect } from "@/account/effect"
|
import { AccountService } from "@/account/service"
|
||||||
import { AuthEffect } from "@/auth/effect"
|
import { AuthService } from "@/auth/service"
|
||||||
import { Instances } from "@/effect/instances"
|
import { Instances } from "@/effect/instances"
|
||||||
import type { InstanceServices } from "@/effect/instances"
|
import type { InstanceServices } from "@/effect/instances"
|
||||||
import { TruncateEffect } from "@/tool/truncate-effect"
|
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
|
|
||||||
export const runtime = ManagedRuntime.make(
|
export const runtime = ManagedRuntime.make(
|
||||||
Layer.mergeAll(
|
Layer.mergeAll(AccountService.defaultLayer, Instances.layer).pipe(Layer.provideMerge(AuthService.defaultLayer)),
|
||||||
AccountEffect.defaultLayer, //
|
|
||||||
TruncateEffect.defaultLayer,
|
|
||||||
Instances.layer,
|
|
||||||
).pipe(Layer.provideMerge(AuthEffect.defaultLayer)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
|
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
|
||||||
return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
|
return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function disposeRuntime() {
|
|
||||||
return runtime.dispose()
|
|
||||||
}
|
|
||||||
|
|||||||
+373
-341
@@ -1,20 +1,272 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { InstanceContext } from "@/effect/instance-context"
|
|
||||||
import { runPromiseInstance } from "@/effect/runtime"
|
|
||||||
import { git } from "@/util/git"
|
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
|
||||||
import { formatPatch, structuredPatch } from "diff"
|
|
||||||
import fs from "fs"
|
|
||||||
import fuzzysort from "fuzzysort"
|
|
||||||
import ignore from "ignore"
|
|
||||||
import path from "path"
|
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Global } from "../global"
|
import { formatPatch, structuredPatch } from "diff"
|
||||||
import { Instance } from "../project/instance"
|
import path from "path"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import fs from "fs"
|
||||||
|
import ignore from "ignore"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { Protected } from "./protected"
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
import { Instance } from "../project/instance"
|
||||||
import { Ripgrep } from "./ripgrep"
|
import { Ripgrep } from "./ripgrep"
|
||||||
|
import fuzzysort from "fuzzysort"
|
||||||
|
import { Global } from "../global"
|
||||||
|
import { git } from "@/util/git"
|
||||||
|
import { Protected } from "./protected"
|
||||||
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "file" })
|
||||||
|
|
||||||
|
const binaryExtensions = new Set([
|
||||||
|
"exe",
|
||||||
|
"dll",
|
||||||
|
"pdb",
|
||||||
|
"bin",
|
||||||
|
"so",
|
||||||
|
"dylib",
|
||||||
|
"o",
|
||||||
|
"a",
|
||||||
|
"lib",
|
||||||
|
"wav",
|
||||||
|
"mp3",
|
||||||
|
"ogg",
|
||||||
|
"oga",
|
||||||
|
"ogv",
|
||||||
|
"ogx",
|
||||||
|
"flac",
|
||||||
|
"aac",
|
||||||
|
"wma",
|
||||||
|
"m4a",
|
||||||
|
"weba",
|
||||||
|
"mp4",
|
||||||
|
"avi",
|
||||||
|
"mov",
|
||||||
|
"wmv",
|
||||||
|
"flv",
|
||||||
|
"webm",
|
||||||
|
"mkv",
|
||||||
|
"zip",
|
||||||
|
"tar",
|
||||||
|
"gz",
|
||||||
|
"gzip",
|
||||||
|
"bz",
|
||||||
|
"bz2",
|
||||||
|
"bzip",
|
||||||
|
"bzip2",
|
||||||
|
"7z",
|
||||||
|
"rar",
|
||||||
|
"xz",
|
||||||
|
"lz",
|
||||||
|
"z",
|
||||||
|
"pdf",
|
||||||
|
"doc",
|
||||||
|
"docx",
|
||||||
|
"ppt",
|
||||||
|
"pptx",
|
||||||
|
"xls",
|
||||||
|
"xlsx",
|
||||||
|
"dmg",
|
||||||
|
"iso",
|
||||||
|
"img",
|
||||||
|
"vmdk",
|
||||||
|
"ttf",
|
||||||
|
"otf",
|
||||||
|
"woff",
|
||||||
|
"woff2",
|
||||||
|
"eot",
|
||||||
|
"sqlite",
|
||||||
|
"db",
|
||||||
|
"mdb",
|
||||||
|
"apk",
|
||||||
|
"ipa",
|
||||||
|
"aab",
|
||||||
|
"xapk",
|
||||||
|
"app",
|
||||||
|
"pkg",
|
||||||
|
"deb",
|
||||||
|
"rpm",
|
||||||
|
"snap",
|
||||||
|
"flatpak",
|
||||||
|
"appimage",
|
||||||
|
"msi",
|
||||||
|
"msp",
|
||||||
|
"jar",
|
||||||
|
"war",
|
||||||
|
"ear",
|
||||||
|
"class",
|
||||||
|
"kotlin_module",
|
||||||
|
"dex",
|
||||||
|
"vdex",
|
||||||
|
"odex",
|
||||||
|
"oat",
|
||||||
|
"art",
|
||||||
|
"wasm",
|
||||||
|
"wat",
|
||||||
|
"bc",
|
||||||
|
"ll",
|
||||||
|
"s",
|
||||||
|
"ko",
|
||||||
|
"sys",
|
||||||
|
"drv",
|
||||||
|
"efi",
|
||||||
|
"rom",
|
||||||
|
"com",
|
||||||
|
"cmd",
|
||||||
|
"ps1",
|
||||||
|
"sh",
|
||||||
|
"bash",
|
||||||
|
"zsh",
|
||||||
|
"fish",
|
||||||
|
])
|
||||||
|
|
||||||
|
const imageExtensions = new Set([
|
||||||
|
"png",
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"gif",
|
||||||
|
"bmp",
|
||||||
|
"webp",
|
||||||
|
"ico",
|
||||||
|
"tif",
|
||||||
|
"tiff",
|
||||||
|
"svg",
|
||||||
|
"svgz",
|
||||||
|
"avif",
|
||||||
|
"apng",
|
||||||
|
"jxl",
|
||||||
|
"heic",
|
||||||
|
"heif",
|
||||||
|
"raw",
|
||||||
|
"cr2",
|
||||||
|
"nef",
|
||||||
|
"arw",
|
||||||
|
"dng",
|
||||||
|
"orf",
|
||||||
|
"raf",
|
||||||
|
"pef",
|
||||||
|
"x3f",
|
||||||
|
])
|
||||||
|
|
||||||
|
const textExtensions = new Set([
|
||||||
|
"ts",
|
||||||
|
"tsx",
|
||||||
|
"mts",
|
||||||
|
"cts",
|
||||||
|
"mtsx",
|
||||||
|
"ctsx",
|
||||||
|
"js",
|
||||||
|
"jsx",
|
||||||
|
"mjs",
|
||||||
|
"cjs",
|
||||||
|
"sh",
|
||||||
|
"bash",
|
||||||
|
"zsh",
|
||||||
|
"fish",
|
||||||
|
"ps1",
|
||||||
|
"psm1",
|
||||||
|
"cmd",
|
||||||
|
"bat",
|
||||||
|
"json",
|
||||||
|
"jsonc",
|
||||||
|
"json5",
|
||||||
|
"yaml",
|
||||||
|
"yml",
|
||||||
|
"toml",
|
||||||
|
"md",
|
||||||
|
"mdx",
|
||||||
|
"txt",
|
||||||
|
"xml",
|
||||||
|
"html",
|
||||||
|
"htm",
|
||||||
|
"css",
|
||||||
|
"scss",
|
||||||
|
"sass",
|
||||||
|
"less",
|
||||||
|
"graphql",
|
||||||
|
"gql",
|
||||||
|
"sql",
|
||||||
|
"ini",
|
||||||
|
"cfg",
|
||||||
|
"conf",
|
||||||
|
"env",
|
||||||
|
])
|
||||||
|
|
||||||
|
const textNames = new Set([
|
||||||
|
"dockerfile",
|
||||||
|
"makefile",
|
||||||
|
".gitignore",
|
||||||
|
".gitattributes",
|
||||||
|
".editorconfig",
|
||||||
|
".npmrc",
|
||||||
|
".nvmrc",
|
||||||
|
".prettierrc",
|
||||||
|
".eslintrc",
|
||||||
|
])
|
||||||
|
|
||||||
|
function isImageByExtension(filepath: string): boolean {
|
||||||
|
const ext = path.extname(filepath).toLowerCase().slice(1)
|
||||||
|
return imageExtensions.has(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTextByExtension(filepath: string): boolean {
|
||||||
|
const ext = path.extname(filepath).toLowerCase().slice(1)
|
||||||
|
return textExtensions.has(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTextByName(filepath: string): boolean {
|
||||||
|
const name = path.basename(filepath).toLowerCase()
|
||||||
|
return textNames.has(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageMimeType(filepath: string): string {
|
||||||
|
const ext = path.extname(filepath).toLowerCase().slice(1)
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
png: "image/png",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
gif: "image/gif",
|
||||||
|
bmp: "image/bmp",
|
||||||
|
webp: "image/webp",
|
||||||
|
ico: "image/x-icon",
|
||||||
|
tif: "image/tiff",
|
||||||
|
tiff: "image/tiff",
|
||||||
|
svg: "image/svg+xml",
|
||||||
|
svgz: "image/svg+xml",
|
||||||
|
avif: "image/avif",
|
||||||
|
apng: "image/apng",
|
||||||
|
jxl: "image/jxl",
|
||||||
|
heic: "image/heic",
|
||||||
|
heif: "image/heif",
|
||||||
|
}
|
||||||
|
return mimeTypes[ext] || "image/" + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBinaryByExtension(filepath: string): boolean {
|
||||||
|
const ext = path.extname(filepath).toLowerCase().slice(1)
|
||||||
|
return binaryExtensions.has(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImage(mimeType: string): boolean {
|
||||||
|
return mimeType.startsWith("image/")
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldEncode(mimeType: string): boolean {
|
||||||
|
const type = mimeType.toLowerCase()
|
||||||
|
log.info("shouldEncode", { type })
|
||||||
|
if (!type) return false
|
||||||
|
|
||||||
|
if (type.startsWith("text/")) return false
|
||||||
|
if (type.includes("charset=")) return false
|
||||||
|
|
||||||
|
const parts = type.split("/", 2)
|
||||||
|
const top = parts[0]
|
||||||
|
|
||||||
|
const tops = ["image", "audio", "video", "font", "model", "multipart"]
|
||||||
|
if (tops.includes(top)) return true
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
export namespace File {
|
export namespace File {
|
||||||
export const Info = z
|
export const Info = z
|
||||||
@@ -84,270 +336,28 @@ export namespace File {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function init() {
|
export function init() {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.init()))
|
return runPromiseInstance(FileService.use((s) => s.init()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function status() {
|
export async function status() {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.status()))
|
return runPromiseInstance(FileService.use((s) => s.status()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function read(file: string): Promise<Content> {
|
export async function read(file: string): Promise<Content> {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.read(file)))
|
return runPromiseInstance(FileService.use((s) => s.read(file)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function list(dir?: string) {
|
export async function list(dir?: string) {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.list(dir)))
|
return runPromiseInstance(FileService.use((s) => s.list(dir)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
|
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.search(input)))
|
return runPromiseInstance(FileService.use((s) => s.search(input)))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const log = Log.create({ service: "file" })
|
export namespace FileService {
|
||||||
|
export interface Service {
|
||||||
const binary = new Set([
|
|
||||||
"exe",
|
|
||||||
"dll",
|
|
||||||
"pdb",
|
|
||||||
"bin",
|
|
||||||
"so",
|
|
||||||
"dylib",
|
|
||||||
"o",
|
|
||||||
"a",
|
|
||||||
"lib",
|
|
||||||
"wav",
|
|
||||||
"mp3",
|
|
||||||
"ogg",
|
|
||||||
"oga",
|
|
||||||
"ogv",
|
|
||||||
"ogx",
|
|
||||||
"flac",
|
|
||||||
"aac",
|
|
||||||
"wma",
|
|
||||||
"m4a",
|
|
||||||
"weba",
|
|
||||||
"mp4",
|
|
||||||
"avi",
|
|
||||||
"mov",
|
|
||||||
"wmv",
|
|
||||||
"flv",
|
|
||||||
"webm",
|
|
||||||
"mkv",
|
|
||||||
"zip",
|
|
||||||
"tar",
|
|
||||||
"gz",
|
|
||||||
"gzip",
|
|
||||||
"bz",
|
|
||||||
"bz2",
|
|
||||||
"bzip",
|
|
||||||
"bzip2",
|
|
||||||
"7z",
|
|
||||||
"rar",
|
|
||||||
"xz",
|
|
||||||
"lz",
|
|
||||||
"z",
|
|
||||||
"pdf",
|
|
||||||
"doc",
|
|
||||||
"docx",
|
|
||||||
"ppt",
|
|
||||||
"pptx",
|
|
||||||
"xls",
|
|
||||||
"xlsx",
|
|
||||||
"dmg",
|
|
||||||
"iso",
|
|
||||||
"img",
|
|
||||||
"vmdk",
|
|
||||||
"ttf",
|
|
||||||
"otf",
|
|
||||||
"woff",
|
|
||||||
"woff2",
|
|
||||||
"eot",
|
|
||||||
"sqlite",
|
|
||||||
"db",
|
|
||||||
"mdb",
|
|
||||||
"apk",
|
|
||||||
"ipa",
|
|
||||||
"aab",
|
|
||||||
"xapk",
|
|
||||||
"app",
|
|
||||||
"pkg",
|
|
||||||
"deb",
|
|
||||||
"rpm",
|
|
||||||
"snap",
|
|
||||||
"flatpak",
|
|
||||||
"appimage",
|
|
||||||
"msi",
|
|
||||||
"msp",
|
|
||||||
"jar",
|
|
||||||
"war",
|
|
||||||
"ear",
|
|
||||||
"class",
|
|
||||||
"kotlin_module",
|
|
||||||
"dex",
|
|
||||||
"vdex",
|
|
||||||
"odex",
|
|
||||||
"oat",
|
|
||||||
"art",
|
|
||||||
"wasm",
|
|
||||||
"wat",
|
|
||||||
"bc",
|
|
||||||
"ll",
|
|
||||||
"s",
|
|
||||||
"ko",
|
|
||||||
"sys",
|
|
||||||
"drv",
|
|
||||||
"efi",
|
|
||||||
"rom",
|
|
||||||
"com",
|
|
||||||
"cmd",
|
|
||||||
"ps1",
|
|
||||||
"sh",
|
|
||||||
"bash",
|
|
||||||
"zsh",
|
|
||||||
"fish",
|
|
||||||
])
|
|
||||||
|
|
||||||
const image = new Set([
|
|
||||||
"png",
|
|
||||||
"jpg",
|
|
||||||
"jpeg",
|
|
||||||
"gif",
|
|
||||||
"bmp",
|
|
||||||
"webp",
|
|
||||||
"ico",
|
|
||||||
"tif",
|
|
||||||
"tiff",
|
|
||||||
"svg",
|
|
||||||
"svgz",
|
|
||||||
"avif",
|
|
||||||
"apng",
|
|
||||||
"jxl",
|
|
||||||
"heic",
|
|
||||||
"heif",
|
|
||||||
"raw",
|
|
||||||
"cr2",
|
|
||||||
"nef",
|
|
||||||
"arw",
|
|
||||||
"dng",
|
|
||||||
"orf",
|
|
||||||
"raf",
|
|
||||||
"pef",
|
|
||||||
"x3f",
|
|
||||||
])
|
|
||||||
|
|
||||||
const text = new Set([
|
|
||||||
"ts",
|
|
||||||
"tsx",
|
|
||||||
"mts",
|
|
||||||
"cts",
|
|
||||||
"mtsx",
|
|
||||||
"ctsx",
|
|
||||||
"js",
|
|
||||||
"jsx",
|
|
||||||
"mjs",
|
|
||||||
"cjs",
|
|
||||||
"sh",
|
|
||||||
"bash",
|
|
||||||
"zsh",
|
|
||||||
"fish",
|
|
||||||
"ps1",
|
|
||||||
"psm1",
|
|
||||||
"cmd",
|
|
||||||
"bat",
|
|
||||||
"json",
|
|
||||||
"jsonc",
|
|
||||||
"json5",
|
|
||||||
"yaml",
|
|
||||||
"yml",
|
|
||||||
"toml",
|
|
||||||
"md",
|
|
||||||
"mdx",
|
|
||||||
"txt",
|
|
||||||
"xml",
|
|
||||||
"html",
|
|
||||||
"htm",
|
|
||||||
"css",
|
|
||||||
"scss",
|
|
||||||
"sass",
|
|
||||||
"less",
|
|
||||||
"graphql",
|
|
||||||
"gql",
|
|
||||||
"sql",
|
|
||||||
"ini",
|
|
||||||
"cfg",
|
|
||||||
"conf",
|
|
||||||
"env",
|
|
||||||
])
|
|
||||||
|
|
||||||
const textName = new Set([
|
|
||||||
"dockerfile",
|
|
||||||
"makefile",
|
|
||||||
".gitignore",
|
|
||||||
".gitattributes",
|
|
||||||
".editorconfig",
|
|
||||||
".npmrc",
|
|
||||||
".nvmrc",
|
|
||||||
".prettierrc",
|
|
||||||
".eslintrc",
|
|
||||||
])
|
|
||||||
|
|
||||||
const mime: Record<string, string> = {
|
|
||||||
png: "image/png",
|
|
||||||
jpg: "image/jpeg",
|
|
||||||
jpeg: "image/jpeg",
|
|
||||||
gif: "image/gif",
|
|
||||||
bmp: "image/bmp",
|
|
||||||
webp: "image/webp",
|
|
||||||
ico: "image/x-icon",
|
|
||||||
tif: "image/tiff",
|
|
||||||
tiff: "image/tiff",
|
|
||||||
svg: "image/svg+xml",
|
|
||||||
svgz: "image/svg+xml",
|
|
||||||
avif: "image/avif",
|
|
||||||
apng: "image/apng",
|
|
||||||
jxl: "image/jxl",
|
|
||||||
heic: "image/heic",
|
|
||||||
heif: "image/heif",
|
|
||||||
}
|
|
||||||
|
|
||||||
type Entry = { files: string[]; dirs: string[] }
|
|
||||||
|
|
||||||
const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
|
|
||||||
const name = (file: string) => path.basename(file).toLowerCase()
|
|
||||||
const isImageByExtension = (file: string) => image.has(ext(file))
|
|
||||||
const isTextByExtension = (file: string) => text.has(ext(file))
|
|
||||||
const isTextByName = (file: string) => textName.has(name(file))
|
|
||||||
const isBinaryByExtension = (file: string) => binary.has(ext(file))
|
|
||||||
const isImage = (mimeType: string) => mimeType.startsWith("image/")
|
|
||||||
const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
|
|
||||||
|
|
||||||
function shouldEncode(mimeType: string) {
|
|
||||||
const type = mimeType.toLowerCase()
|
|
||||||
log.info("shouldEncode", { type })
|
|
||||||
if (!type) return false
|
|
||||||
if (type.startsWith("text/")) return false
|
|
||||||
if (type.includes("charset=")) return false
|
|
||||||
const top = type.split("/", 2)[0]
|
|
||||||
return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
|
|
||||||
}
|
|
||||||
|
|
||||||
const hidden = (item: string) => {
|
|
||||||
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
|
|
||||||
return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortHiddenLast = (items: string[], prefer: boolean) => {
|
|
||||||
if (prefer) return items
|
|
||||||
const visible: string[] = []
|
|
||||||
const hiddenItems: string[] = []
|
|
||||||
for (const item of items) {
|
|
||||||
if (hidden(item)) hiddenItems.push(item)
|
|
||||||
else visible.push(item)
|
|
||||||
}
|
|
||||||
return [...visible, ...hiddenItems]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly init: () => Effect.Effect<void>
|
readonly init: () => Effect.Effect<void>
|
||||||
readonly status: () => Effect.Effect<File.Info[]>
|
readonly status: () => Effect.Effect<File.Info[]>
|
||||||
readonly read: (file: string) => Effect.Effect<File.Content>
|
readonly read: (file: string) => Effect.Effect<File.Content>
|
||||||
@@ -359,29 +369,36 @@ export namespace File {
|
|||||||
type?: "file" | "directory"
|
type?: "file" | "directory"
|
||||||
}) => Effect.Effect<string[]>
|
}) => Effect.Effect<string[]>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
|
export class FileService extends ServiceMap.Service<FileService, FileService.Service>()("@opencode/File") {
|
||||||
|
static readonly layer = Layer.effect(
|
||||||
export const layer = Layer.effect(
|
FileService,
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const instance = yield* InstanceContext
|
const instance = yield* InstanceContext
|
||||||
|
|
||||||
|
// File cache state
|
||||||
|
type Entry = { files: string[]; dirs: string[] }
|
||||||
let cache: Entry = { files: [], dirs: [] }
|
let cache: Entry = { files: [], dirs: [] }
|
||||||
let task: Promise<void> | undefined
|
let task: Promise<void> | undefined
|
||||||
|
|
||||||
const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
|
const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
|
||||||
|
|
||||||
function kick() {
|
function kick() {
|
||||||
if (task) return task
|
if (task) return task
|
||||||
task = (async () => {
|
task = (async () => {
|
||||||
|
// Disable scanning if in root of file system
|
||||||
if (instance.directory === path.parse(instance.directory).root) return
|
if (instance.directory === path.parse(instance.directory).root) return
|
||||||
const next: Entry = { files: [], dirs: [] }
|
const next: Entry = { files: [], dirs: [] }
|
||||||
try {
|
try {
|
||||||
if (isGlobalHome) {
|
if (isGlobalHome) {
|
||||||
const dirs = new Set<string>()
|
const dirs = new Set<string>()
|
||||||
const protectedNames = Protected.names()
|
const protectedNames = Protected.names()
|
||||||
|
|
||||||
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
|
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
|
||||||
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
|
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
|
||||||
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
|
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
|
||||||
|
|
||||||
const top = await fs.promises
|
const top = await fs.promises
|
||||||
.readdir(instance.directory, { withFileTypes: true })
|
.readdir(instance.directory, { withFileTypes: true })
|
||||||
.catch(() => [] as fs.Dirent[])
|
.catch(() => [] as fs.Dirent[])
|
||||||
@@ -392,7 +409,9 @@ export namespace File {
|
|||||||
dirs.add(entry.name + "/")
|
dirs.add(entry.name + "/")
|
||||||
|
|
||||||
const base = path.join(instance.directory, entry.name)
|
const base = path.join(instance.directory, entry.name)
|
||||||
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
|
const children = await fs.promises
|
||||||
|
.readdir(base, { withFileTypes: true })
|
||||||
|
.catch(() => [] as fs.Dirent[])
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
if (!child.isDirectory()) continue
|
if (!child.isDirectory()) continue
|
||||||
if (shouldIgnoreNested(child.name)) continue
|
if (shouldIgnoreNested(child.name)) continue
|
||||||
@@ -402,7 +421,7 @@ export namespace File {
|
|||||||
|
|
||||||
next.dirs = Array.from(dirs).toSorted()
|
next.dirs = Array.from(dirs).toSorted()
|
||||||
} else {
|
} else {
|
||||||
const seen = new Set<string>()
|
const set = new Set<string>()
|
||||||
for await (const file of Ripgrep.files({ cwd: instance.directory })) {
|
for await (const file of Ripgrep.files({ cwd: instance.directory })) {
|
||||||
next.files.push(file)
|
next.files.push(file)
|
||||||
let current = file
|
let current = file
|
||||||
@@ -411,8 +430,8 @@ export namespace File {
|
|||||||
if (dir === ".") break
|
if (dir === ".") break
|
||||||
if (dir === current) break
|
if (dir === current) break
|
||||||
current = dir
|
current = dir
|
||||||
if (seen.has(dir)) continue
|
if (set.has(dir)) continue
|
||||||
seen.add(dir)
|
set.add(dir)
|
||||||
next.dirs.push(dir + "/")
|
next.dirs.push(dir + "/")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -430,11 +449,11 @@ export namespace File {
|
|||||||
return cache
|
return cache
|
||||||
}
|
}
|
||||||
|
|
||||||
const init = Effect.fn("File.init")(function* () {
|
const init = Effect.fn("FileService.init")(function* () {
|
||||||
yield* Effect.promise(() => kick())
|
void kick()
|
||||||
})
|
})
|
||||||
|
|
||||||
const status = Effect.fn("File.status")(function* () {
|
const status = Effect.fn("FileService.status")(function* () {
|
||||||
if (instance.project.vcs !== "git") return []
|
if (instance.project.vcs !== "git") return []
|
||||||
|
|
||||||
return yield* Effect.promise(async () => {
|
return yield* Effect.promise(async () => {
|
||||||
@@ -444,13 +463,14 @@ export namespace File {
|
|||||||
})
|
})
|
||||||
).text()
|
).text()
|
||||||
|
|
||||||
const changed: File.Info[] = []
|
const changedFiles: File.Info[] = []
|
||||||
|
|
||||||
if (diffOutput.trim()) {
|
if (diffOutput.trim()) {
|
||||||
for (const line of diffOutput.trim().split("\n")) {
|
const lines = diffOutput.trim().split("\n")
|
||||||
const [added, removed, file] = line.split("\t")
|
for (const line of lines) {
|
||||||
changed.push({
|
const [added, removed, filepath] = line.split("\t")
|
||||||
path: file,
|
changedFiles.push({
|
||||||
|
path: filepath,
|
||||||
added: added === "-" ? 0 : parseInt(added, 10),
|
added: added === "-" ? 0 : parseInt(added, 10),
|
||||||
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
||||||
status: "modified",
|
status: "modified",
|
||||||
@@ -476,12 +496,14 @@ export namespace File {
|
|||||||
).text()
|
).text()
|
||||||
|
|
||||||
if (untrackedOutput.trim()) {
|
if (untrackedOutput.trim()) {
|
||||||
for (const file of untrackedOutput.trim().split("\n")) {
|
const untrackedFiles = untrackedOutput.trim().split("\n")
|
||||||
|
for (const filepath of untrackedFiles) {
|
||||||
try {
|
try {
|
||||||
const content = await Filesystem.readText(path.join(instance.directory, file))
|
const content = await Filesystem.readText(path.join(instance.directory, filepath))
|
||||||
changed.push({
|
const lines = content.split("\n").length
|
||||||
path: file,
|
changedFiles.push({
|
||||||
added: content.split("\n").length,
|
path: filepath,
|
||||||
|
added: lines,
|
||||||
removed: 0,
|
removed: 0,
|
||||||
status: "added",
|
status: "added",
|
||||||
})
|
})
|
||||||
@@ -491,6 +513,7 @@ export namespace File {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get deleted files
|
||||||
const deletedOutput = (
|
const deletedOutput = (
|
||||||
await git(
|
await git(
|
||||||
[
|
[
|
||||||
@@ -510,51 +533,50 @@ export namespace File {
|
|||||||
).text()
|
).text()
|
||||||
|
|
||||||
if (deletedOutput.trim()) {
|
if (deletedOutput.trim()) {
|
||||||
for (const file of deletedOutput.trim().split("\n")) {
|
const deletedFiles = deletedOutput.trim().split("\n")
|
||||||
changed.push({
|
for (const filepath of deletedFiles) {
|
||||||
path: file,
|
changedFiles.push({
|
||||||
|
path: filepath,
|
||||||
added: 0,
|
added: 0,
|
||||||
removed: 0,
|
removed: 0, // Could get original line count but would require another git command
|
||||||
status: "deleted",
|
status: "deleted",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return changed.map((item) => {
|
return changedFiles.map((x) => {
|
||||||
const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path)
|
const full = path.isAbsolute(x.path) ? x.path : path.join(instance.directory, x.path)
|
||||||
return {
|
return {
|
||||||
...item,
|
...x,
|
||||||
path: path.relative(instance.directory, full),
|
path: path.relative(instance.directory, full),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const read = Effect.fn("File.read")(function* (file: string) {
|
const read = Effect.fn("FileService.read")(function* (file: string) {
|
||||||
return yield* Effect.promise(async (): Promise<File.Content> => {
|
return yield* Effect.promise(async (): Promise<File.Content> => {
|
||||||
using _ = log.time("read", { file })
|
using _ = log.time("read", { file })
|
||||||
const full = path.join(instance.directory, file)
|
const full = path.join(instance.directory, file)
|
||||||
|
|
||||||
if (!Instance.containsPath(full)) {
|
if (!Instance.containsPath(full)) {
|
||||||
throw new Error("Access denied: path escapes project directory")
|
throw new Error(`Access denied: path escapes project directory`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fast path: check extension before any filesystem operations
|
||||||
if (isImageByExtension(file)) {
|
if (isImageByExtension(file)) {
|
||||||
if (await Filesystem.exists(full)) {
|
if (await Filesystem.exists(full)) {
|
||||||
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
||||||
return {
|
const content = buffer.toString("base64")
|
||||||
type: "text",
|
const mimeType = getImageMimeType(file)
|
||||||
content: buffer.toString("base64"),
|
return { type: "text", content, mimeType, encoding: "base64" }
|
||||||
mimeType: getImageMimeType(file),
|
|
||||||
encoding: "base64",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { type: "text", content: "" }
|
return { type: "text", content: "" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const knownText = isTextByExtension(file) || isTextByName(file)
|
const text = isTextByExtension(file) || isTextByName(file)
|
||||||
|
|
||||||
if (isBinaryByExtension(file) && !knownText) {
|
if (isBinaryByExtension(file) && !text) {
|
||||||
return { type: "binary", content: "" }
|
return { type: "binary", content: "" }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,7 +585,7 @@ export namespace File {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mimeType = Filesystem.mimeType(full)
|
const mimeType = Filesystem.mimeType(full)
|
||||||
const encode = knownText ? false : shouldEncode(mimeType)
|
const encode = text ? false : shouldEncode(mimeType)
|
||||||
|
|
||||||
if (encode && !isImage(mimeType)) {
|
if (encode && !isImage(mimeType)) {
|
||||||
return { type: "binary", content: "", mimeType }
|
return { type: "binary", content: "", mimeType }
|
||||||
@@ -571,12 +593,8 @@ export namespace File {
|
|||||||
|
|
||||||
if (encode) {
|
if (encode) {
|
||||||
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
||||||
return {
|
const content = buffer.toString("base64")
|
||||||
type: "text",
|
return { type: "text", content, mimeType, encoding: "base64" }
|
||||||
content: buffer.toString("base64"),
|
|
||||||
mimeType,
|
|
||||||
encoding: "base64",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = (await Filesystem.readText(full).catch(() => "")).trim()
|
const content = (await Filesystem.readText(full).catch(() => "")).trim()
|
||||||
@@ -587,9 +605,7 @@ export namespace File {
|
|||||||
).text()
|
).text()
|
||||||
if (!diff.trim()) {
|
if (!diff.trim()) {
|
||||||
diff = (
|
diff = (
|
||||||
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
|
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: instance.directory })
|
||||||
cwd: instance.directory,
|
|
||||||
})
|
|
||||||
).text()
|
).text()
|
||||||
}
|
}
|
||||||
if (diff.trim()) {
|
if (diff.trim()) {
|
||||||
@@ -598,64 +614,64 @@ export namespace File {
|
|||||||
context: Infinity,
|
context: Infinity,
|
||||||
ignoreWhitespace: true,
|
ignoreWhitespace: true,
|
||||||
})
|
})
|
||||||
return {
|
const diff = formatPatch(patch)
|
||||||
type: "text",
|
return { type: "text", content, patch, diff }
|
||||||
content,
|
|
||||||
patch,
|
|
||||||
diff: formatPatch(patch),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { type: "text", content }
|
return { type: "text", content }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const list = Effect.fn("File.list")(function* (dir?: string) {
|
const list = Effect.fn("FileService.list")(function* (dir?: string) {
|
||||||
return yield* Effect.promise(async () => {
|
return yield* Effect.promise(async () => {
|
||||||
const exclude = [".git", ".DS_Store"]
|
const exclude = [".git", ".DS_Store"]
|
||||||
let ignored = (_: string) => false
|
let ignored = (_: string) => false
|
||||||
if (instance.project.vcs === "git") {
|
if (instance.project.vcs === "git") {
|
||||||
const ig = ignore()
|
const ig = ignore()
|
||||||
const gitignore = path.join(instance.project.worktree, ".gitignore")
|
const gitignorePath = path.join(instance.project.worktree, ".gitignore")
|
||||||
if (await Filesystem.exists(gitignore)) {
|
if (await Filesystem.exists(gitignorePath)) {
|
||||||
ig.add(await Filesystem.readText(gitignore))
|
ig.add(await Filesystem.readText(gitignorePath))
|
||||||
}
|
}
|
||||||
const ignoreFile = path.join(instance.project.worktree, ".ignore")
|
const ignorePath = path.join(instance.project.worktree, ".ignore")
|
||||||
if (await Filesystem.exists(ignoreFile)) {
|
if (await Filesystem.exists(ignorePath)) {
|
||||||
ig.add(await Filesystem.readText(ignoreFile))
|
ig.add(await Filesystem.readText(ignorePath))
|
||||||
}
|
}
|
||||||
ignored = ig.ignores.bind(ig)
|
ignored = ig.ignores.bind(ig)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolved = dir ? path.join(instance.directory, dir) : instance.directory
|
const resolved = dir ? path.join(instance.directory, dir) : instance.directory
|
||||||
|
|
||||||
if (!Instance.containsPath(resolved)) {
|
if (!Instance.containsPath(resolved)) {
|
||||||
throw new Error("Access denied: path escapes project directory")
|
throw new Error(`Access denied: path escapes project directory`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodes: File.Node[] = []
|
const nodes: File.Node[] = []
|
||||||
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
|
for (const entry of await fs.promises
|
||||||
|
.readdir(resolved, {
|
||||||
|
withFileTypes: true,
|
||||||
|
})
|
||||||
|
.catch(() => [])) {
|
||||||
if (exclude.includes(entry.name)) continue
|
if (exclude.includes(entry.name)) continue
|
||||||
const absolute = path.join(resolved, entry.name)
|
const fullPath = path.join(resolved, entry.name)
|
||||||
const file = path.relative(instance.directory, absolute)
|
const relativePath = path.relative(instance.directory, fullPath)
|
||||||
const type = entry.isDirectory() ? "directory" : "file"
|
const type = entry.isDirectory() ? "directory" : "file"
|
||||||
nodes.push({
|
nodes.push({
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
path: file,
|
path: relativePath,
|
||||||
absolute,
|
absolute: fullPath,
|
||||||
type,
|
type,
|
||||||
ignored: ignored(type === "directory" ? file + "/" : file),
|
ignored: ignored(type === "directory" ? relativePath + "/" : relativePath),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return nodes.sort((a, b) => {
|
return nodes.sort((a, b) => {
|
||||||
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
|
if (a.type !== b.type) {
|
||||||
|
return a.type === "directory" ? -1 : 1
|
||||||
|
}
|
||||||
return a.name.localeCompare(b.name)
|
return a.name.localeCompare(b.name)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const search = Effect.fn("File.search")(function* (input: {
|
const search = Effect.fn("FileService.search")(function* (input: {
|
||||||
query: string
|
query: string
|
||||||
limit?: number
|
limit?: number
|
||||||
dirs?: boolean
|
dirs?: boolean
|
||||||
@@ -668,19 +684,34 @@ export namespace File {
|
|||||||
log.info("search", { query, kind })
|
log.info("search", { query, kind })
|
||||||
|
|
||||||
const result = await getFiles()
|
const result = await getFiles()
|
||||||
const preferHidden = query.startsWith(".") || query.includes("/.")
|
|
||||||
|
|
||||||
|
const hidden = (item: string) => {
|
||||||
|
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
|
||||||
|
return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1)
|
||||||
|
}
|
||||||
|
const preferHidden = query.startsWith(".") || query.includes("/.")
|
||||||
|
const sortHiddenLast = (items: string[]) => {
|
||||||
|
if (preferHidden) return items
|
||||||
|
const visible: string[] = []
|
||||||
|
const hiddenItems: string[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
const isHidden = hidden(item)
|
||||||
|
if (isHidden) hiddenItems.push(item)
|
||||||
|
if (!isHidden) visible.push(item)
|
||||||
|
}
|
||||||
|
return [...visible, ...hiddenItems]
|
||||||
|
}
|
||||||
if (!query) {
|
if (!query) {
|
||||||
if (kind === "file") return result.files.slice(0, limit)
|
if (kind === "file") return result.files.slice(0, limit)
|
||||||
return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
|
return sortHiddenLast(result.dirs.toSorted()).slice(0, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
const items =
|
const items =
|
||||||
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
|
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
|
||||||
|
|
||||||
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
|
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
|
||||||
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
|
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target)
|
||||||
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
|
const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted
|
||||||
|
|
||||||
log.info("search", { query, kind, results: output.length })
|
log.info("search", { query, kind, results: output.length })
|
||||||
return output
|
return output
|
||||||
@@ -688,7 +719,8 @@ export namespace File {
|
|||||||
})
|
})
|
||||||
|
|
||||||
log.info("init")
|
log.info("init")
|
||||||
return Service.of({ init, status, read, list, search })
|
|
||||||
|
return FileService.of({ init, status, read, list, search })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,110 +1,115 @@
|
|||||||
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
|
|
||||||
import { runPromiseInstance } from "@/effect/runtime"
|
|
||||||
import { Flag } from "@/flag/flag"
|
|
||||||
import type { SessionID } from "@/session/schema"
|
|
||||||
import { Filesystem } from "../util/filesystem"
|
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
import { Effect, Layer, ServiceMap, Semaphore } from "effect"
|
||||||
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
|
import type { SessionID } from "@/session/schema"
|
||||||
|
|
||||||
export namespace FileTime {
|
const log = Log.create({ service: "file.time" })
|
||||||
const log = Log.create({ service: "file.time" })
|
|
||||||
|
|
||||||
export type Stamp = {
|
export namespace FileTimeService {
|
||||||
readonly read: Date
|
export interface Service {
|
||||||
readonly mtime: number | undefined
|
|
||||||
readonly ctime: number | undefined
|
|
||||||
readonly size: number | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const stamp = Effect.fnUntraced(function* (file: string) {
|
|
||||||
const stat = Filesystem.stat(file)
|
|
||||||
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
|
|
||||||
return {
|
|
||||||
read: yield* DateTime.nowAsDate,
|
|
||||||
mtime: stat?.mtime?.getTime(),
|
|
||||||
ctime: stat?.ctime?.getTime(),
|
|
||||||
size,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
|
|
||||||
const value = reads.get(sessionID)
|
|
||||||
if (value) return value
|
|
||||||
|
|
||||||
const next = new Map<string, Stamp>()
|
|
||||||
reads.set(sessionID, next)
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
|
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
|
||||||
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
|
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
|
||||||
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
|
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
|
||||||
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
|
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
|
type Stamp = {
|
||||||
|
readonly read: Date
|
||||||
|
readonly mtime: number | undefined
|
||||||
|
readonly ctime: number | undefined
|
||||||
|
readonly size: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
function stamp(file: string): Stamp {
|
||||||
Service,
|
const stat = Filesystem.stat(file)
|
||||||
|
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
|
||||||
|
return {
|
||||||
|
read: new Date(),
|
||||||
|
mtime: stat?.mtime?.getTime(),
|
||||||
|
ctime: stat?.ctime?.getTime(),
|
||||||
|
size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function session(reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) {
|
||||||
|
let value = reads.get(sessionID)
|
||||||
|
if (!value) {
|
||||||
|
value = new Map<string, Stamp>()
|
||||||
|
reads.set(sessionID, value)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FileTimeService extends ServiceMap.Service<FileTimeService, FileTimeService.Service>()(
|
||||||
|
"@opencode/FileTime",
|
||||||
|
) {
|
||||||
|
static readonly layer = Layer.effect(
|
||||||
|
FileTimeService,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
|
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
|
||||||
const reads = new Map<SessionID, Map<string, Stamp>>()
|
const reads = new Map<SessionID, Map<string, Stamp>>()
|
||||||
const locks = new Map<string, Semaphore.Semaphore>()
|
const locks = new Map<string, Semaphore.Semaphore>()
|
||||||
|
|
||||||
const getLock = (filepath: string) => {
|
function getLock(filepath: string) {
|
||||||
const lock = locks.get(filepath)
|
let lock = locks.get(filepath)
|
||||||
if (lock) return lock
|
if (!lock) {
|
||||||
|
lock = Semaphore.makeUnsafe(1)
|
||||||
const next = Semaphore.makeUnsafe(1)
|
locks.set(filepath, lock)
|
||||||
locks.set(filepath, next)
|
}
|
||||||
return next
|
return lock
|
||||||
}
|
}
|
||||||
|
|
||||||
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
|
return FileTimeService.of({
|
||||||
log.info("read", { sessionID, file })
|
read: Effect.fn("FileTimeService.read")(function* (sessionID: SessionID, file: string) {
|
||||||
session(reads, sessionID).set(file, yield* stamp(file))
|
log.info("read", { sessionID, file })
|
||||||
|
session(reads, sessionID).set(file, stamp(file))
|
||||||
|
}),
|
||||||
|
|
||||||
|
get: Effect.fn("FileTimeService.get")(function* (sessionID: SessionID, file: string) {
|
||||||
|
return reads.get(sessionID)?.get(file)?.read
|
||||||
|
}),
|
||||||
|
|
||||||
|
assert: Effect.fn("FileTimeService.assert")(function* (sessionID: SessionID, filepath: string) {
|
||||||
|
if (disableCheck) return
|
||||||
|
|
||||||
|
const time = reads.get(sessionID)?.get(filepath)
|
||||||
|
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
|
||||||
|
const next = stamp(filepath)
|
||||||
|
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
throw new Error(
|
||||||
|
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
withLock: Effect.fn("FileTimeService.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
|
||||||
|
const lock = getLock(filepath)
|
||||||
|
return yield* Effect.promise(fn).pipe(lock.withPermits(1))
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
|
|
||||||
return reads.get(sessionID)?.get(file)?.read
|
|
||||||
})
|
|
||||||
|
|
||||||
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
|
|
||||||
if (disableCheck) return
|
|
||||||
|
|
||||||
const time = reads.get(sessionID)?.get(filepath)
|
|
||||||
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
|
|
||||||
|
|
||||||
const next = yield* stamp(filepath)
|
|
||||||
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
|
|
||||||
if (!changed) return
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
|
|
||||||
return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1))
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ read, get, assert, withLock })
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace FileTime {
|
||||||
export function read(sessionID: SessionID, file: string) {
|
export function read(sessionID: SessionID, file: string) {
|
||||||
return runPromiseInstance(Service.use((s) => s.read(sessionID, file)))
|
return runPromiseInstance(FileTimeService.use((s) => s.read(sessionID, file)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function get(sessionID: SessionID, file: string) {
|
export function get(sessionID: SessionID, file: string) {
|
||||||
return runPromiseInstance(Service.use((s) => s.get(sessionID, file)))
|
return runPromiseInstance(FileTimeService.use((s) => s.get(sessionID, file)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function assert(sessionID: SessionID, filepath: string) {
|
export async function assert(sessionID: SessionID, filepath: string) {
|
||||||
return runPromiseInstance(Service.use((s) => s.assert(sessionID, filepath)))
|
return runPromiseInstance(FileTimeService.use((s) => s.assert(sessionID, filepath)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
|
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
|
||||||
return runPromiseInstance(Service.use((s) => s.withLock(filepath, fn)))
|
return runPromiseInstance(FileTimeService.use((s) => s.withLock(filepath, fn)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +1,89 @@
|
|||||||
import { Cause, Effect, Layer, ServiceMap } from "effect"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
|
import { Instance } from "@/project/instance"
|
||||||
|
import z from "zod"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
import { FileIgnore } from "./ignore"
|
||||||
|
import { Config } from "../config/config"
|
||||||
|
import path from "path"
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||||
|
import { lazy } from "@/util/lazy"
|
||||||
import type ParcelWatcher from "@parcel/watcher"
|
import type ParcelWatcher from "@parcel/watcher"
|
||||||
import { readdir } from "fs/promises"
|
import { readdir } from "fs/promises"
|
||||||
import path from "path"
|
|
||||||
import z from "zod"
|
|
||||||
import { Bus } from "@/bus"
|
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
|
||||||
import { InstanceContext } from "@/effect/instance-context"
|
|
||||||
import { Flag } from "@/flag/flag"
|
|
||||||
import { Instance } from "@/project/instance"
|
|
||||||
import { git } from "@/util/git"
|
import { git } from "@/util/git"
|
||||||
import { lazy } from "@/util/lazy"
|
|
||||||
import { Config } from "../config/config"
|
|
||||||
import { FileIgnore } from "./ignore"
|
|
||||||
import { Protected } from "./protected"
|
import { Protected } from "./protected"
|
||||||
import { Log } from "../util/log"
|
import { Flag } from "@/flag/flag"
|
||||||
|
import { Cause, Effect, Layer, ServiceMap } from "effect"
|
||||||
|
|
||||||
|
const SUBSCRIBE_TIMEOUT_MS = 10_000
|
||||||
|
|
||||||
declare const OPENCODE_LIBC: string | undefined
|
declare const OPENCODE_LIBC: string | undefined
|
||||||
|
|
||||||
|
const log = Log.create({ service: "file.watcher" })
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
Updated: BusEvent.define(
|
||||||
|
"file.watcher.updated",
|
||||||
|
z.object({
|
||||||
|
file: z.string(),
|
||||||
|
event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
|
||||||
|
try {
|
||||||
|
const binding = require(
|
||||||
|
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
|
||||||
|
)
|
||||||
|
return createWrapper(binding) as typeof import("@parcel/watcher")
|
||||||
|
} catch (error) {
|
||||||
|
log.error("failed to load watcher binding", { error })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function getBackend() {
|
||||||
|
if (process.platform === "win32") return "windows"
|
||||||
|
if (process.platform === "darwin") return "fs-events"
|
||||||
|
if (process.platform === "linux") return "inotify"
|
||||||
|
}
|
||||||
|
|
||||||
export namespace FileWatcher {
|
export namespace FileWatcher {
|
||||||
const log = Log.create({ service: "file.watcher" })
|
export const Event = event
|
||||||
const SUBSCRIBE_TIMEOUT_MS = 10_000
|
/** Whether the native @parcel/watcher binding is available on this platform. */
|
||||||
|
|
||||||
export const Event = {
|
|
||||||
Updated: BusEvent.define(
|
|
||||||
"file.watcher.updated",
|
|
||||||
z.object({
|
|
||||||
file: z.string(),
|
|
||||||
event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
|
|
||||||
try {
|
|
||||||
const binding = require(
|
|
||||||
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
|
|
||||||
)
|
|
||||||
return createWrapper(binding) as typeof import("@parcel/watcher")
|
|
||||||
} catch (error) {
|
|
||||||
log.error("failed to load watcher binding", { error })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function getBackend() {
|
|
||||||
if (process.platform === "win32") return "windows"
|
|
||||||
if (process.platform === "darwin") return "fs-events"
|
|
||||||
if (process.platform === "linux") return "inotify"
|
|
||||||
}
|
|
||||||
|
|
||||||
export const hasNativeBinding = () => !!watcher()
|
export const hasNativeBinding = () => !!watcher()
|
||||||
|
}
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, {}>()("@opencode/FileWatcher") {}
|
const init = Effect.fn("FileWatcherService.init")(function* () {})
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export namespace FileWatcherService {
|
||||||
Service,
|
export interface Service {
|
||||||
|
readonly init: () => Effect.Effect<void>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FileWatcherService extends ServiceMap.Service<FileWatcherService, FileWatcherService.Service>()(
|
||||||
|
"@opencode/FileWatcher",
|
||||||
|
) {
|
||||||
|
static readonly layer = Layer.effect(
|
||||||
|
FileWatcherService,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const instance = yield* InstanceContext
|
const instance = yield* InstanceContext
|
||||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({})
|
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return FileWatcherService.of({ init })
|
||||||
|
|
||||||
log.info("init", { directory: instance.directory })
|
log.info("init", { directory: instance.directory })
|
||||||
|
|
||||||
const backend = getBackend()
|
const backend = getBackend()
|
||||||
if (!backend) {
|
if (!backend) {
|
||||||
log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform })
|
log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform })
|
||||||
return Service.of({})
|
return FileWatcherService.of({ init })
|
||||||
}
|
}
|
||||||
|
|
||||||
const w = watcher()
|
const w = watcher()
|
||||||
if (!w) return Service.of({})
|
if (!w) return FileWatcherService.of({ init })
|
||||||
|
|
||||||
log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend })
|
log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend })
|
||||||
|
|
||||||
@@ -80,9 +93,9 @@ export namespace FileWatcher {
|
|||||||
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
|
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
|
||||||
if (err) return
|
if (err) return
|
||||||
for (const evt of evts) {
|
for (const evt of evts) {
|
||||||
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
|
if (evt.type === "create") Bus.publish(event.Updated, { file: evt.path, event: "add" })
|
||||||
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
|
if (evt.type === "update") Bus.publish(event.Updated, { file: evt.path, event: "change" })
|
||||||
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
|
if (evt.type === "delete") Bus.publish(event.Updated, { file: evt.path, event: "unlink" })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -95,6 +108,7 @@ export namespace FileWatcher {
|
|||||||
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
|
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
|
||||||
Effect.catchCause((cause) => {
|
Effect.catchCause((cause) => {
|
||||||
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
|
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
|
||||||
|
// Clean up a subscription that resolves after timeout
|
||||||
pending.then((s) => s.unsubscribe()).catch(() => {})
|
pending.then((s) => s.unsubscribe()).catch(() => {})
|
||||||
return Effect.void
|
return Effect.void
|
||||||
}),
|
}),
|
||||||
@@ -123,11 +137,11 @@ export namespace FileWatcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Service.of({})
|
return FileWatcherService.of({ init })
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.catchCause((cause) => {
|
Effect.catchCause((cause) => {
|
||||||
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
|
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
|
||||||
return Effect.succeed(Service.of({}))
|
return Effect.succeed(FileWatcherService.of({ init }))
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import { Effect, Layer, ServiceMap } from "effect"
|
|
||||||
import { runPromiseInstance } from "@/effect/runtime"
|
|
||||||
import { InstanceContext } from "@/effect/instance-context"
|
|
||||||
import path from "path"
|
|
||||||
import { mergeDeep } from "remeda"
|
|
||||||
import z from "zod"
|
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { Config } from "../config/config"
|
|
||||||
import { File } from "../file"
|
import { File } from "../file"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
import path from "path"
|
||||||
|
import z from "zod"
|
||||||
|
|
||||||
|
import * as Formatter from "./formatter"
|
||||||
|
import { Config } from "../config/config"
|
||||||
|
import { mergeDeep } from "remeda"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Process } from "../util/process"
|
import { Process } from "../util/process"
|
||||||
import { Log } from "../util/log"
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
import * as Formatter from "./formatter"
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "format" })
|
||||||
|
|
||||||
export namespace Format {
|
export namespace Format {
|
||||||
const log = Log.create({ service: "format" })
|
|
||||||
|
|
||||||
export const Status = z
|
export const Status = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
@@ -26,14 +27,25 @@ export namespace Format {
|
|||||||
})
|
})
|
||||||
export type Status = z.infer<typeof Status>
|
export type Status = z.infer<typeof Status>
|
||||||
|
|
||||||
export interface Interface {
|
export async function init() {
|
||||||
readonly status: () => Effect.Effect<Status[]>
|
return runPromiseInstance(FormatService.use((s) => s.init()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
|
export async function status() {
|
||||||
|
return runPromiseInstance(FormatService.use((s) => s.status()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export namespace FormatService {
|
||||||
Service,
|
export interface Service {
|
||||||
|
readonly init: () => Effect.Effect<void>
|
||||||
|
readonly status: () => Effect.Effect<Format.Status[]>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FormatService extends ServiceMap.Service<FormatService, FormatService.Service>()("@opencode/Format") {
|
||||||
|
static readonly layer = Layer.effect(
|
||||||
|
FormatService,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const instance = yield* InstanceContext
|
const instance = yield* InstanceContext
|
||||||
|
|
||||||
@@ -51,19 +63,17 @@ export namespace Format {
|
|||||||
delete formatters[name]
|
delete formatters[name]
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const info = mergeDeep(formatters[name] ?? {}, {
|
const result = mergeDeep(formatters[name] ?? {}, {
|
||||||
command: [],
|
command: [],
|
||||||
extensions: [],
|
extensions: [],
|
||||||
...item,
|
...item,
|
||||||
})
|
}) as Formatter.Info
|
||||||
|
|
||||||
if (info.command.length === 0) continue
|
if (result.command.length === 0) continue
|
||||||
|
|
||||||
formatters[name] = {
|
result.enabled = async () => true
|
||||||
...info,
|
result.name = name
|
||||||
name,
|
formatters[name] = result
|
||||||
enabled: async () => true,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.info("all formatters are disabled")
|
log.info("all formatters are disabled")
|
||||||
@@ -110,12 +120,11 @@ export namespace Format {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
const exit = await proc.exited
|
const exit = await proc.exited
|
||||||
if (exit !== 0) {
|
if (exit !== 0)
|
||||||
log.error("failed", {
|
log.error("failed", {
|
||||||
command: item.command,
|
command: item.command,
|
||||||
...item.environment,
|
...item.environment,
|
||||||
})
|
})
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("failed to format file", {
|
log.error("failed to format file", {
|
||||||
error,
|
error,
|
||||||
@@ -131,8 +140,10 @@ export namespace Format {
|
|||||||
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
|
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
|
||||||
log.info("init")
|
log.info("init")
|
||||||
|
|
||||||
const status = Effect.fn("Format.status")(function* () {
|
const init = Effect.fn("FormatService.init")(function* () {})
|
||||||
const result: Status[] = []
|
|
||||||
|
const status = Effect.fn("FormatService.status")(function* () {
|
||||||
|
const result: Format.Status[] = []
|
||||||
for (const formatter of Object.values(formatters)) {
|
for (const formatter of Object.values(formatters)) {
|
||||||
const isOn = yield* Effect.promise(() => isEnabled(formatter))
|
const isOn = yield* Effect.promise(() => isEnabled(formatter))
|
||||||
result.push({
|
result.push({
|
||||||
@@ -144,11 +155,7 @@ export namespace Format {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ status })
|
return FormatService.of({ init, status })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export async function status() {
|
|
||||||
return runPromiseInstance(Service.use((s) => s.status()))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-5
@@ -3,7 +3,7 @@ import { Config } from "@/config/config"
|
|||||||
import { fn } from "@/util/fn"
|
import { fn } from "@/util/fn"
|
||||||
import { Wildcard } from "@/util/wildcard"
|
import { Wildcard } from "@/util/wildcard"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import { PermissionEffect as S } from "./effect"
|
import * as S from "./service"
|
||||||
|
|
||||||
export namespace PermissionNext {
|
export namespace PermissionNext {
|
||||||
function expand(pattern: string): string {
|
function expand(pattern: string): string {
|
||||||
@@ -26,7 +26,7 @@ export namespace PermissionNext {
|
|||||||
export type Reply = S.Reply
|
export type Reply = S.Reply
|
||||||
export const Approval = S.Approval
|
export const Approval = S.Approval
|
||||||
export const Event = S.Event
|
export const Event = S.Event
|
||||||
export const Service = S.Service
|
export const Service = S.PermissionService
|
||||||
export const RejectedError = S.RejectedError
|
export const RejectedError = S.RejectedError
|
||||||
export const CorrectedError = S.CorrectedError
|
export const CorrectedError = S.CorrectedError
|
||||||
export const DeniedError = S.DeniedError
|
export const DeniedError = S.DeniedError
|
||||||
@@ -53,14 +53,16 @@ export namespace PermissionNext {
|
|||||||
return rulesets.flat()
|
return rulesets.flat()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((service) => service.ask(input))))
|
export const ask = fn(S.AskInput, async (input) =>
|
||||||
|
runPromiseInstance(S.PermissionService.use((service) => service.ask(input))),
|
||||||
|
)
|
||||||
|
|
||||||
export const reply = fn(S.ReplyInput, async (input) =>
|
export const reply = fn(S.ReplyInput, async (input) =>
|
||||||
runPromiseInstance(S.Service.use((service) => service.reply(input))),
|
runPromiseInstance(S.PermissionService.use((service) => service.reply(input))),
|
||||||
)
|
)
|
||||||
|
|
||||||
export async function list() {
|
export async function list() {
|
||||||
return runPromiseInstance(S.Service.use((service) => service.list()))
|
return runPromiseInstance(S.PermissionService.use((service) => service.list()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||||
+119
-112
@@ -11,128 +11,121 @@ import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { PermissionID } from "./schema"
|
import { PermissionID } from "./schema"
|
||||||
|
|
||||||
export namespace PermissionEffect {
|
const log = Log.create({ service: "permission" })
|
||||||
const log = Log.create({ service: "permission" })
|
|
||||||
|
|
||||||
export const Action = z.enum(["allow", "deny", "ask"]).meta({
|
export const Action = z.enum(["allow", "deny", "ask"]).meta({
|
||||||
ref: "PermissionAction",
|
ref: "PermissionAction",
|
||||||
|
})
|
||||||
|
export type Action = z.infer<typeof Action>
|
||||||
|
|
||||||
|
export const Rule = z
|
||||||
|
.object({
|
||||||
|
permission: z.string(),
|
||||||
|
pattern: z.string(),
|
||||||
|
action: Action,
|
||||||
})
|
})
|
||||||
export type Action = z.infer<typeof Action>
|
.meta({
|
||||||
|
ref: "PermissionRule",
|
||||||
export const Rule = z
|
|
||||||
.object({
|
|
||||||
permission: z.string(),
|
|
||||||
pattern: z.string(),
|
|
||||||
action: Action,
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "PermissionRule",
|
|
||||||
})
|
|
||||||
export type Rule = z.infer<typeof Rule>
|
|
||||||
|
|
||||||
export const Ruleset = Rule.array().meta({
|
|
||||||
ref: "PermissionRuleset",
|
|
||||||
})
|
})
|
||||||
export type Ruleset = z.infer<typeof Ruleset>
|
export type Rule = z.infer<typeof Rule>
|
||||||
|
|
||||||
export const Request = z
|
export const Ruleset = Rule.array().meta({
|
||||||
.object({
|
ref: "PermissionRuleset",
|
||||||
id: PermissionID.zod,
|
})
|
||||||
sessionID: SessionID.zod,
|
export type Ruleset = z.infer<typeof Ruleset>
|
||||||
permission: z.string(),
|
|
||||||
patterns: z.string().array(),
|
|
||||||
metadata: z.record(z.string(), z.any()),
|
|
||||||
always: z.string().array(),
|
|
||||||
tool: z
|
|
||||||
.object({
|
|
||||||
messageID: MessageID.zod,
|
|
||||||
callID: z.string(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "PermissionRequest",
|
|
||||||
})
|
|
||||||
export type Request = z.infer<typeof Request>
|
|
||||||
|
|
||||||
export const Reply = z.enum(["once", "always", "reject"])
|
export const Request = z
|
||||||
export type Reply = z.infer<typeof Reply>
|
.object({
|
||||||
|
id: PermissionID.zod,
|
||||||
export const Approval = z.object({
|
sessionID: SessionID.zod,
|
||||||
projectID: ProjectID.zod,
|
permission: z.string(),
|
||||||
patterns: z.string().array(),
|
patterns: z.string().array(),
|
||||||
|
metadata: z.record(z.string(), z.any()),
|
||||||
|
always: z.string().array(),
|
||||||
|
tool: z
|
||||||
|
.object({
|
||||||
|
messageID: MessageID.zod,
|
||||||
|
callID: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
|
.meta({
|
||||||
export const Event = {
|
ref: "PermissionRequest",
|
||||||
Asked: BusEvent.define("permission.asked", Request),
|
|
||||||
Replied: BusEvent.define(
|
|
||||||
"permission.replied",
|
|
||||||
z.object({
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
requestID: PermissionID.zod,
|
|
||||||
reply: Reply,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
|
|
||||||
override get message() {
|
|
||||||
return "The user rejected permission to use this specific tool call."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
|
|
||||||
feedback: Schema.String,
|
|
||||||
}) {
|
|
||||||
override get message() {
|
|
||||||
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
|
|
||||||
ruleset: Schema.Any,
|
|
||||||
}) {
|
|
||||||
override get message() {
|
|
||||||
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Error = DeniedError | RejectedError | CorrectedError
|
|
||||||
|
|
||||||
export const AskInput = Request.partial({ id: true }).extend({
|
|
||||||
ruleset: Ruleset,
|
|
||||||
})
|
})
|
||||||
|
export type Request = z.infer<typeof Request>
|
||||||
|
|
||||||
export const ReplyInput = z.object({
|
export const Reply = z.enum(["once", "always", "reject"])
|
||||||
requestID: PermissionID.zod,
|
export type Reply = z.infer<typeof Reply>
|
||||||
reply: Reply,
|
|
||||||
message: z.string().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export interface Interface {
|
export const Approval = z.object({
|
||||||
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
|
projectID: ProjectID.zod,
|
||||||
|
patterns: z.string().array(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Asked: BusEvent.define("permission.asked", Request),
|
||||||
|
Replied: BusEvent.define(
|
||||||
|
"permission.replied",
|
||||||
|
z.object({
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
requestID: PermissionID.zod,
|
||||||
|
reply: Reply,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
|
||||||
|
override get message() {
|
||||||
|
return "The user rejected permission to use this specific tool call."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
|
||||||
|
feedback: Schema.String,
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
|
||||||
|
ruleset: Schema.Any,
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PermissionError = DeniedError | RejectedError | CorrectedError
|
||||||
|
|
||||||
|
interface PendingEntry {
|
||||||
|
info: Request
|
||||||
|
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AskInput = Request.partial({ id: true }).extend({
|
||||||
|
ruleset: Ruleset,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ReplyInput = z.object({
|
||||||
|
requestID: PermissionID.zod,
|
||||||
|
reply: Reply,
|
||||||
|
message: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export declare namespace PermissionService {
|
||||||
|
export interface Api {
|
||||||
|
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, PermissionError>
|
||||||
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
|
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
|
||||||
readonly list: () => Effect.Effect<Request[]>
|
readonly list: () => Effect.Effect<Request[]>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface PendingEntry {
|
export class PermissionService extends ServiceMap.Service<PermissionService, PermissionService.Api>()(
|
||||||
info: Request
|
"@opencode/PermissionNext",
|
||||||
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
) {
|
||||||
}
|
static readonly layer = Layer.effect(
|
||||||
|
PermissionService,
|
||||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
|
||||||
const rules = rulesets.flat()
|
|
||||||
log.info("evaluate", { permission, pattern, ruleset: rules })
|
|
||||||
const match = rules.findLast(
|
|
||||||
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
|
|
||||||
)
|
|
||||||
return match ?? { action: "ask", permission, pattern: "*" }
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { project } = yield* InstanceContext
|
const { project } = yield* InstanceContext
|
||||||
const row = Database.use((db) =>
|
const row = Database.use((db) =>
|
||||||
@@ -141,7 +134,7 @@ export namespace PermissionEffect {
|
|||||||
const pending = new Map<PermissionID, PendingEntry>()
|
const pending = new Map<PermissionID, PendingEntry>()
|
||||||
const approved: Ruleset = row?.data ?? []
|
const approved: Ruleset = row?.data ?? []
|
||||||
|
|
||||||
const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
|
const ask = Effect.fn("PermissionService.ask")(function* (input: z.infer<typeof AskInput>) {
|
||||||
const { ruleset, ...request } = input
|
const { ruleset, ...request } = input
|
||||||
let needsAsk = false
|
let needsAsk = false
|
||||||
|
|
||||||
@@ -177,7 +170,7 @@ export namespace PermissionEffect {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
|
const reply = Effect.fn("PermissionService.reply")(function* (input: z.infer<typeof ReplyInput>) {
|
||||||
const existing = pending.get(input.requestID)
|
const existing = pending.get(input.requestID)
|
||||||
if (!existing) return
|
if (!existing) return
|
||||||
|
|
||||||
@@ -232,13 +225,27 @@ export namespace PermissionEffect {
|
|||||||
})
|
})
|
||||||
yield* Deferred.succeed(item.deferred, undefined)
|
yield* Deferred.succeed(item.deferred, undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: we don't save the permission ruleset to disk yet until there's
|
||||||
|
// UI to manage it
|
||||||
|
// db().insert(PermissionTable).values({ projectID: Instance.project.id, data: s.approved })
|
||||||
|
// .onConflictDoUpdate({ target: PermissionTable.projectID, set: { data: s.approved } }).run()
|
||||||
})
|
})
|
||||||
|
|
||||||
const list = Effect.fn("Permission.list")(function* () {
|
const list = Effect.fn("PermissionService.list")(function* () {
|
||||||
return Array.from(pending.values(), (item) => item.info)
|
return Array.from(pending.values(), (item) => item.info)
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ ask, reply, list })
|
return PermissionService.of({ ask, reply, list })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||||
|
const merged = rulesets.flat()
|
||||||
|
log.info("evaluate", { permission, pattern, ruleset: merged })
|
||||||
|
const match = merged.findLast(
|
||||||
|
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
|
||||||
|
)
|
||||||
|
return match ?? { action: "ask", permission, pattern: "*" }
|
||||||
|
}
|
||||||
@@ -185,10 +185,12 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
const deploymentType = inputs.deploymentType || "github.com"
|
const deploymentType = inputs.deploymentType || "github.com"
|
||||||
|
|
||||||
let domain = "github.com"
|
let domain = "github.com"
|
||||||
|
let actualProvider = "github-copilot"
|
||||||
|
|
||||||
if (deploymentType === "enterprise") {
|
if (deploymentType === "enterprise") {
|
||||||
const enterpriseUrl = inputs.enterpriseUrl
|
const enterpriseUrl = inputs.enterpriseUrl
|
||||||
domain = normalizeDomain(enterpriseUrl!)
|
domain = normalizeDomain(enterpriseUrl!)
|
||||||
|
actualProvider = "github-copilot-enterprise"
|
||||||
}
|
}
|
||||||
|
|
||||||
const urls = getUrls(domain)
|
const urls = getUrls(domain)
|
||||||
@@ -260,7 +262,8 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
expires: 0,
|
expires: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deploymentType === "enterprise") {
|
if (actualProvider === "github-copilot-enterprise") {
|
||||||
|
result.provider = "github-copilot-enterprise"
|
||||||
result.enterpriseUrl = domain
|
result.enterpriseUrl = domain
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,34 @@
|
|||||||
import { Plugin } from "../plugin"
|
import { Plugin } from "../plugin"
|
||||||
|
import { Format } from "../format"
|
||||||
import { LSP } from "../lsp"
|
import { LSP } from "../lsp"
|
||||||
|
import { FileWatcherService } from "../file/watcher"
|
||||||
import { File } from "../file"
|
import { File } from "../file"
|
||||||
import { Project } from "./project"
|
import { Project } from "./project"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { Command } from "../command"
|
import { Command } from "../command"
|
||||||
import { Instance } from "./instance"
|
import { Instance } from "./instance"
|
||||||
|
import { VcsService } from "./vcs"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import { ShareNext } from "@/share/share-next"
|
import { ShareNext } from "@/share/share-next"
|
||||||
|
import { Snapshot } from "../snapshot"
|
||||||
|
import { Truncate } from "../tool/truncation"
|
||||||
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
|
|
||||||
export async function InstanceBootstrap() {
|
export async function InstanceBootstrap() {
|
||||||
Log.Default.info("bootstrapping", { directory: Instance.directory })
|
Log.Default.info("bootstrapping", { directory: Instance.directory })
|
||||||
await Plugin.init()
|
await Plugin.init()
|
||||||
ShareNext.init()
|
ShareNext.init()
|
||||||
|
await Format.init()
|
||||||
await LSP.init()
|
await LSP.init()
|
||||||
|
await runPromiseInstance(FileWatcherService.use((service) => service.init()))
|
||||||
File.init()
|
File.init()
|
||||||
|
await runPromiseInstance(VcsService.use((s) => s.init()))
|
||||||
|
Snapshot.init()
|
||||||
|
Truncate.init()
|
||||||
|
|
||||||
Bus.subscribe(Command.Event.Executed, async (payload) => {
|
Bus.subscribe(Command.Event.Executed, async (payload) => {
|
||||||
if (payload.properties.name === Command.Default.INIT) {
|
if (payload.properties.name === Command.Default.INIT) {
|
||||||
Project.setInitialized(Instance.project.id)
|
await Project.setInitialized(Instance.project.id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,185 +1,163 @@
|
|||||||
import { GlobalBus } from "@/bus/global";
|
import { Log } from "@/util/log"
|
||||||
import { disposeInstance } from "@/effect/instance-registry";
|
import { Context } from "../util/context"
|
||||||
import { Filesystem } from "@/util/filesystem";
|
import { Project } from "./project"
|
||||||
import { iife } from "@/util/iife";
|
import { State } from "./state"
|
||||||
import { Log } from "@/util/log";
|
import { iife } from "@/util/iife"
|
||||||
import { Context } from "../util/context";
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { Project } from "./project";
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { State } from "./state";
|
import { disposeInstance } from "@/effect/instance-registry"
|
||||||
|
|
||||||
interface Context {
|
interface Context {
|
||||||
directory: string;
|
directory: string
|
||||||
worktree: string;
|
worktree: string
|
||||||
project: Project.Info;
|
project: Project.Info
|
||||||
}
|
}
|
||||||
const context = Context.create<Context>("instance");
|
const context = Context.create<Context>("instance")
|
||||||
const cache = new Map<string, Promise<Context>>();
|
const cache = new Map<string, Promise<Context>>()
|
||||||
|
|
||||||
const disposal = {
|
const disposal = {
|
||||||
all: undefined as Promise<void> | undefined,
|
all: undefined as Promise<void> | undefined,
|
||||||
};
|
|
||||||
|
|
||||||
function emit(directory: string) {
|
|
||||||
GlobalBus.emit("event", {
|
|
||||||
directory,
|
|
||||||
payload: {
|
|
||||||
type: "server.instance.disposed",
|
|
||||||
properties: {
|
|
||||||
directory,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function boot(input: {
|
function emit(directory: string) {
|
||||||
directory: string;
|
GlobalBus.emit("event", {
|
||||||
init?: () => Promise<any>;
|
directory,
|
||||||
project?: Project.Info;
|
payload: {
|
||||||
worktree?: string;
|
type: "server.instance.disposed",
|
||||||
}) {
|
properties: {
|
||||||
return iife(async () => {
|
directory,
|
||||||
const ctx =
|
},
|
||||||
input.project && input.worktree
|
},
|
||||||
? {
|
})
|
||||||
directory: input.directory,
|
}
|
||||||
worktree: input.worktree,
|
|
||||||
project: input.project,
|
function boot(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
|
||||||
}
|
return iife(async () => {
|
||||||
: await Project.fromDirectory(input.directory).then(
|
const ctx =
|
||||||
({ project, sandbox }) => ({
|
input.project && input.worktree
|
||||||
directory: input.directory,
|
? {
|
||||||
worktree: sandbox,
|
directory: input.directory,
|
||||||
project,
|
worktree: input.worktree,
|
||||||
}),
|
project: input.project,
|
||||||
);
|
}
|
||||||
await context.provide(ctx, async () => {
|
: await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({
|
||||||
await input.init?.();
|
directory: input.directory,
|
||||||
});
|
worktree: sandbox,
|
||||||
return ctx;
|
project,
|
||||||
});
|
}))
|
||||||
|
await context.provide(ctx, async () => {
|
||||||
|
await input.init?.()
|
||||||
|
})
|
||||||
|
return ctx
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function track(directory: string, next: Promise<Context>) {
|
function track(directory: string, next: Promise<Context>) {
|
||||||
const task = next.catch((error) => {
|
const task = next.catch((error) => {
|
||||||
if (cache.get(directory) === task) cache.delete(directory);
|
if (cache.get(directory) === task) cache.delete(directory)
|
||||||
throw error;
|
throw error
|
||||||
});
|
})
|
||||||
cache.set(directory, task);
|
cache.set(directory, task)
|
||||||
return task;
|
return task
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Instance = {
|
export const Instance = {
|
||||||
async provide<R>(input: {
|
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
|
||||||
directory: string;
|
const directory = Filesystem.resolve(input.directory)
|
||||||
init?: () => Promise<any>;
|
let existing = cache.get(directory)
|
||||||
fn: () => R;
|
if (!existing) {
|
||||||
}): Promise<R> {
|
Log.Default.info("creating instance", { directory })
|
||||||
const directory = Filesystem.resolve(input.directory);
|
existing = track(
|
||||||
let existing = cache.get(directory);
|
directory,
|
||||||
if (!existing) {
|
boot({
|
||||||
Log.Default.info("creating instance", { directory });
|
directory,
|
||||||
existing = track(
|
init: input.init,
|
||||||
directory,
|
}),
|
||||||
boot({
|
)
|
||||||
directory,
|
}
|
||||||
init: input.init,
|
const ctx = await existing
|
||||||
}),
|
return context.provide(ctx, async () => {
|
||||||
);
|
return input.fn()
|
||||||
}
|
})
|
||||||
const ctx = await existing;
|
},
|
||||||
return context.provide(ctx, async () => {
|
get directory() {
|
||||||
return input.fn();
|
return context.use().directory
|
||||||
});
|
},
|
||||||
},
|
get worktree() {
|
||||||
get current() {
|
return context.use().worktree
|
||||||
return context.use();
|
},
|
||||||
},
|
get project() {
|
||||||
get directory() {
|
return context.use().project
|
||||||
return context.use().directory;
|
},
|
||||||
},
|
/**
|
||||||
get worktree() {
|
* Check if a path is within the project boundary.
|
||||||
return context.use().worktree;
|
* Returns true if path is inside Instance.directory OR Instance.worktree.
|
||||||
},
|
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
|
||||||
get project() {
|
*/
|
||||||
return context.use().project;
|
containsPath(filepath: string) {
|
||||||
},
|
if (Filesystem.contains(Instance.directory, filepath)) return true
|
||||||
/**
|
// Non-git projects set worktree to "/" which would match ANY absolute path.
|
||||||
* Check if a path is within the project boundary.
|
// Skip worktree check in this case to preserve external_directory permissions.
|
||||||
* Returns true if path is inside Instance.directory OR Instance.worktree.
|
if (Instance.worktree === "/") return false
|
||||||
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
|
return Filesystem.contains(Instance.worktree, filepath)
|
||||||
*/
|
},
|
||||||
containsPath(filepath: string) {
|
/**
|
||||||
if (Filesystem.contains(Instance.directory, filepath)) return true;
|
* Captures the current instance ALS context and returns a wrapper that
|
||||||
// Non-git projects set worktree to "/" which would match ANY absolute path.
|
* restores it when called. Use this for callbacks that fire outside the
|
||||||
// Skip worktree check in this case to preserve external_directory permissions.
|
* instance async context (native addons, event emitters, timers, etc.).
|
||||||
if (Instance.worktree === "/") return false;
|
*/
|
||||||
return Filesystem.contains(Instance.worktree, filepath);
|
bind<F extends (...args: any[]) => any>(fn: F): F {
|
||||||
},
|
const ctx = context.use()
|
||||||
/**
|
return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F
|
||||||
* Captures the current instance ALS context and returns a wrapper that
|
},
|
||||||
* restores it when called. Use this for callbacks that fire outside the
|
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
|
||||||
* instance async context (native addons, event emitters, timers, etc.).
|
return State.create(() => Instance.directory, init, dispose)
|
||||||
*/
|
},
|
||||||
bind<F extends (...args: any[]) => any>(fn: F): F {
|
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
|
||||||
const ctx = context.use();
|
const directory = Filesystem.resolve(input.directory)
|
||||||
return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F;
|
Log.Default.info("reloading instance", { directory })
|
||||||
},
|
await Promise.all([State.dispose(directory), disposeInstance(directory)])
|
||||||
state<S>(
|
cache.delete(directory)
|
||||||
init: () => S,
|
const next = track(directory, boot({ ...input, directory }))
|
||||||
dispose?: (state: Awaited<S>) => Promise<void>,
|
emit(directory)
|
||||||
): () => S {
|
return await next
|
||||||
return State.create(() => Instance.directory, init, dispose);
|
},
|
||||||
},
|
async dispose() {
|
||||||
async reload(input: {
|
const directory = Instance.directory
|
||||||
directory: string;
|
Log.Default.info("disposing instance", { directory })
|
||||||
init?: () => Promise<any>;
|
await Promise.all([State.dispose(directory), disposeInstance(directory)])
|
||||||
project?: Project.Info;
|
cache.delete(directory)
|
||||||
worktree?: string;
|
emit(directory)
|
||||||
}) {
|
},
|
||||||
const directory = Filesystem.resolve(input.directory);
|
async disposeAll() {
|
||||||
Log.Default.info("reloading instance", { directory });
|
if (disposal.all) return disposal.all
|
||||||
await Promise.all([State.dispose(directory), disposeInstance(directory)]);
|
|
||||||
cache.delete(directory);
|
|
||||||
const next = track(directory, boot({ ...input, directory }));
|
|
||||||
emit(directory);
|
|
||||||
return await next;
|
|
||||||
},
|
|
||||||
async dispose() {
|
|
||||||
const directory = Instance.directory;
|
|
||||||
Log.Default.info("disposing instance", { directory });
|
|
||||||
await Promise.all([State.dispose(directory), disposeInstance(directory)]);
|
|
||||||
cache.delete(directory);
|
|
||||||
emit(directory);
|
|
||||||
},
|
|
||||||
async disposeAll() {
|
|
||||||
if (disposal.all) return disposal.all;
|
|
||||||
|
|
||||||
disposal.all = iife(async () => {
|
disposal.all = iife(async () => {
|
||||||
Log.Default.info("disposing all instances");
|
Log.Default.info("disposing all instances")
|
||||||
const entries = [...cache.entries()];
|
const entries = [...cache.entries()]
|
||||||
for (const [key, value] of entries) {
|
for (const [key, value] of entries) {
|
||||||
if (cache.get(key) !== value) continue;
|
if (cache.get(key) !== value) continue
|
||||||
|
|
||||||
const ctx = await value.catch((error) => {
|
const ctx = await value.catch((error) => {
|
||||||
Log.Default.warn("instance dispose failed", { key, error });
|
Log.Default.warn("instance dispose failed", { key, error })
|
||||||
return undefined;
|
return undefined
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
if (cache.get(key) === value) cache.delete(key);
|
if (cache.get(key) === value) cache.delete(key)
|
||||||
continue;
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cache.get(key) !== value) continue;
|
if (cache.get(key) !== value) continue
|
||||||
|
|
||||||
await context.provide(ctx, async () => {
|
await context.provide(ctx, async () => {
|
||||||
await Instance.dispose();
|
await Instance.dispose()
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
disposal.all = undefined;
|
disposal.all = undefined
|
||||||
});
|
})
|
||||||
|
|
||||||
return disposal.all;
|
return disposal.all
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Effect, Layer, ServiceMap } from "effect"
|
|
||||||
import { Bus } from "@/bus"
|
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import z from "zod"
|
||||||
|
import { Log } from "@/util/log"
|
||||||
|
import { Instance } from "./instance"
|
||||||
import { InstanceContext } from "@/effect/instance-context"
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
import { FileWatcher } from "@/file/watcher"
|
import { FileWatcher } from "@/file/watcher"
|
||||||
import { Log } from "@/util/log"
|
|
||||||
import { git } from "@/util/git"
|
import { git } from "@/util/git"
|
||||||
import { Instance } from "./instance"
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
import z from "zod"
|
|
||||||
|
const log = Log.create({ service: "vcs" })
|
||||||
|
|
||||||
export namespace Vcs {
|
export namespace Vcs {
|
||||||
const log = Log.create({ service: "vcs" })
|
|
||||||
|
|
||||||
export const Event = {
|
export const Event = {
|
||||||
BranchUpdated: BusEvent.define(
|
BranchUpdated: BusEvent.define(
|
||||||
"vcs.branch.updated",
|
"vcs.branch.updated",
|
||||||
@@ -28,15 +28,18 @@ export namespace Vcs {
|
|||||||
ref: "VcsInfo",
|
ref: "VcsInfo",
|
||||||
})
|
})
|
||||||
export type Info = z.infer<typeof Info>
|
export type Info = z.infer<typeof Info>
|
||||||
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export namespace VcsService {
|
||||||
|
export interface Service {
|
||||||
|
readonly init: () => Effect.Effect<void>
|
||||||
readonly branch: () => Effect.Effect<string | undefined>
|
readonly branch: () => Effect.Effect<string | undefined>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
|
export class VcsService extends ServiceMap.Service<VcsService, VcsService.Service>()("@opencode/Vcs") {
|
||||||
|
static readonly layer = Layer.effect(
|
||||||
export const layer = Layer.effect(
|
VcsService,
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const instance = yield* InstanceContext
|
const instance = yield* InstanceContext
|
||||||
let current: string | undefined
|
let current: string | undefined
|
||||||
@@ -62,7 +65,7 @@ export namespace Vcs {
|
|||||||
if (next !== current) {
|
if (next !== current) {
|
||||||
log.info("branch changed", { from: current, to: next })
|
log.info("branch changed", { from: current, to: next })
|
||||||
current = next
|
current = next
|
||||||
Bus.publish(Event.BranchUpdated, { branch: next })
|
Bus.publish(Vcs.Event.BranchUpdated, { branch: next })
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -70,8 +73,9 @@ export namespace Vcs {
|
|||||||
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
|
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
|
||||||
}
|
}
|
||||||
|
|
||||||
return Service.of({
|
return VcsService.of({
|
||||||
branch: Effect.fn("Vcs.branch")(function* () {
|
init: Effect.fn("VcsService.init")(function* () {}),
|
||||||
|
branch: Effect.fn("VcsService.branch")(function* () {
|
||||||
return current
|
return current
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
+63
-54
@@ -1,67 +1,73 @@
|
|||||||
import type { AuthOuathResult } from "@opencode-ai/plugin"
|
import type { AuthOuathResult } from "@opencode-ai/plugin"
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
|
import * as Auth from "@/auth/service"
|
||||||
|
import { ProviderID } from "./schema"
|
||||||
import { Effect, Layer, Record, ServiceMap, Struct } from "effect"
|
import { Effect, Layer, Record, ServiceMap, Struct } from "effect"
|
||||||
import { filter, fromEntries, map, pipe } from "remeda"
|
import { filter, fromEntries, map, pipe } from "remeda"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import * as Auth from "@/auth/effect"
|
|
||||||
import { ProviderID } from "./schema"
|
|
||||||
|
|
||||||
export namespace ProviderAuthEffect {
|
export const Method = z
|
||||||
export const Method = z
|
.object({
|
||||||
.object({
|
type: z.union([z.literal("oauth"), z.literal("api")]),
|
||||||
type: z.union([z.literal("oauth"), z.literal("api")]),
|
label: z.string(),
|
||||||
label: z.string(),
|
})
|
||||||
})
|
.meta({
|
||||||
.meta({
|
ref: "ProviderAuthMethod",
|
||||||
ref: "ProviderAuthMethod",
|
})
|
||||||
})
|
export type Method = z.infer<typeof Method>
|
||||||
export type Method = z.infer<typeof Method>
|
|
||||||
|
|
||||||
export const Authorization = z
|
export const Authorization = z
|
||||||
.object({
|
.object({
|
||||||
url: z.string(),
|
url: z.string(),
|
||||||
method: z.union([z.literal("auto"), z.literal("code")]),
|
method: z.union([z.literal("auto"), z.literal("code")]),
|
||||||
instructions: z.string(),
|
instructions: z.string(),
|
||||||
})
|
})
|
||||||
.meta({
|
.meta({
|
||||||
ref: "ProviderAuthAuthorization",
|
ref: "ProviderAuthAuthorization",
|
||||||
})
|
})
|
||||||
export type Authorization = z.infer<typeof Authorization>
|
export type Authorization = z.infer<typeof Authorization>
|
||||||
|
|
||||||
export const OauthMissing = NamedError.create(
|
export const OauthMissing = NamedError.create(
|
||||||
"ProviderAuthOauthMissing",
|
"ProviderAuthOauthMissing",
|
||||||
z.object({
|
z.object({
|
||||||
providerID: ProviderID.zod,
|
providerID: ProviderID.zod,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const OauthCodeMissing = NamedError.create(
|
export const OauthCodeMissing = NamedError.create(
|
||||||
"ProviderAuthOauthCodeMissing",
|
"ProviderAuthOauthCodeMissing",
|
||||||
z.object({
|
z.object({
|
||||||
providerID: ProviderID.zod,
|
providerID: ProviderID.zod,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
|
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
|
||||||
|
|
||||||
export type Error =
|
export type ProviderAuthError =
|
||||||
| Auth.AuthEffect.AuthServiceError
|
| Auth.AuthServiceError
|
||||||
| InstanceType<typeof OauthMissing>
|
| InstanceType<typeof OauthMissing>
|
||||||
| InstanceType<typeof OauthCodeMissing>
|
| InstanceType<typeof OauthCodeMissing>
|
||||||
| InstanceType<typeof OauthCallbackFailed>
|
| InstanceType<typeof OauthCallbackFailed>
|
||||||
|
|
||||||
export interface Interface {
|
export namespace ProviderAuthService {
|
||||||
|
export interface Service {
|
||||||
readonly methods: () => Effect.Effect<Record<string, Method[]>>
|
readonly methods: () => Effect.Effect<Record<string, Method[]>>
|
||||||
readonly authorize: (input: { providerID: ProviderID; method: number }) => Effect.Effect<Authorization | undefined>
|
readonly authorize: (input: { providerID: ProviderID; method: number }) => Effect.Effect<Authorization | undefined>
|
||||||
readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error>
|
readonly callback: (input: {
|
||||||
|
providerID: ProviderID
|
||||||
|
method: number
|
||||||
|
code?: string
|
||||||
|
}) => Effect.Effect<void, ProviderAuthError>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
|
export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService, ProviderAuthService.Service>()(
|
||||||
|
"@opencode/ProviderAuth",
|
||||||
export const layer = Layer.effect(
|
) {
|
||||||
Service,
|
static readonly layer = Layer.effect(
|
||||||
|
ProviderAuthService,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const auth = yield* Auth.AuthEffect.Service
|
const auth = yield* Auth.AuthService
|
||||||
const hooks = yield* Effect.promise(async () => {
|
const hooks = yield* Effect.promise(async () => {
|
||||||
const mod = await import("../plugin")
|
const mod = await import("../plugin")
|
||||||
return pipe(
|
return pipe(
|
||||||
@@ -73,11 +79,11 @@ export namespace ProviderAuthEffect {
|
|||||||
})
|
})
|
||||||
const pending = new Map<ProviderID, AuthOuathResult>()
|
const pending = new Map<ProviderID, AuthOuathResult>()
|
||||||
|
|
||||||
const methods = Effect.fn("ProviderAuth.methods")(function* () {
|
const methods = Effect.fn("ProviderAuthService.methods")(function* () {
|
||||||
return Record.map(hooks, (item) => item.methods.map((method): Method => Struct.pick(method, ["type", "label"])))
|
return Record.map(hooks, (item) => item.methods.map((method): Method => Struct.pick(method, ["type", "label"])))
|
||||||
})
|
})
|
||||||
|
|
||||||
const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: {
|
const authorize = Effect.fn("ProviderAuthService.authorize")(function* (input: {
|
||||||
providerID: ProviderID
|
providerID: ProviderID
|
||||||
method: number
|
method: number
|
||||||
}) {
|
}) {
|
||||||
@@ -92,16 +98,15 @@ export namespace ProviderAuthEffect {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const callback = Effect.fn("ProviderAuth.callback")(function* (input: {
|
const callback = Effect.fn("ProviderAuthService.callback")(function* (input: {
|
||||||
providerID: ProviderID
|
providerID: ProviderID
|
||||||
method: number
|
method: number
|
||||||
code?: string
|
code?: string
|
||||||
}) {
|
}) {
|
||||||
const match = pending.get(input.providerID)
|
const match = pending.get(input.providerID)
|
||||||
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
|
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
|
||||||
if (match.method === "code" && !input.code) {
|
if (match.method === "code" && !input.code)
|
||||||
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
|
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
|
||||||
}
|
|
||||||
|
|
||||||
const result = yield* Effect.promise(() =>
|
const result = yield* Effect.promise(() =>
|
||||||
match.method === "code" ? match.callback(input.code!) : match.callback(),
|
match.method === "code" ? match.callback(input.code!) : match.callback(),
|
||||||
@@ -126,9 +131,13 @@ export namespace ProviderAuthEffect {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ methods, authorize, callback })
|
return ProviderAuthService.of({
|
||||||
|
methods,
|
||||||
|
authorize,
|
||||||
|
callback,
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.AuthEffect.defaultLayer))
|
static readonly defaultLayer = ProviderAuthService.layer.pipe(Layer.provide(Auth.AuthService.defaultLayer))
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@ import z from "zod"
|
|||||||
|
|
||||||
import { runPromiseInstance } from "@/effect/runtime"
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
import { fn } from "@/util/fn"
|
import { fn } from "@/util/fn"
|
||||||
import { ProviderAuthEffect as S } from "./auth-effect"
|
import * as S from "./auth-service"
|
||||||
import { ProviderID } from "./schema"
|
import { ProviderID } from "./schema"
|
||||||
|
|
||||||
export namespace ProviderAuth {
|
export namespace ProviderAuth {
|
||||||
@@ -10,7 +10,7 @@ export namespace ProviderAuth {
|
|||||||
export type Method = S.Method
|
export type Method = S.Method
|
||||||
|
|
||||||
export async function methods() {
|
export async function methods() {
|
||||||
return runPromiseInstance(S.Service.use((service) => service.methods()))
|
return runPromiseInstance(S.ProviderAuthService.use((service) => service.methods()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Authorization = S.Authorization
|
export const Authorization = S.Authorization
|
||||||
@@ -22,7 +22,7 @@ export namespace ProviderAuth {
|
|||||||
method: z.number(),
|
method: z.number(),
|
||||||
}),
|
}),
|
||||||
async (input): Promise<Authorization | undefined> =>
|
async (input): Promise<Authorization | undefined> =>
|
||||||
runPromiseInstance(S.Service.use((service) => service.authorize(input))),
|
runPromiseInstance(S.ProviderAuthService.use((service) => service.authorize(input))),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const callback = fn(
|
export const callback = fn(
|
||||||
@@ -31,7 +31,7 @@ export namespace ProviderAuth {
|
|||||||
method: z.number(),
|
method: z.number(),
|
||||||
code: z.string().optional(),
|
code: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
async (input) => runPromiseInstance(S.Service.use((service) => service.callback(input))),
|
async (input) => runPromiseInstance(S.ProviderAuthService.use((service) => service.callback(input))),
|
||||||
)
|
)
|
||||||
|
|
||||||
export import OauthMissing = S.OauthMissing
|
export import OauthMissing = S.OauthMissing
|
||||||
|
|||||||
@@ -197,6 +197,16 @@ export namespace Provider {
|
|||||||
options: {},
|
options: {},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"github-copilot-enterprise": async () => {
|
||||||
|
return {
|
||||||
|
autoload: false,
|
||||||
|
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
||||||
|
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
|
||||||
|
return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
|
||||||
|
},
|
||||||
|
options: {},
|
||||||
|
}
|
||||||
|
},
|
||||||
azure: async (provider) => {
|
azure: async (provider) => {
|
||||||
const resource = iife(() => {
|
const resource = iife(() => {
|
||||||
const name = provider.options?.resourceName
|
const name = provider.options?.resourceName
|
||||||
@@ -853,6 +863,20 @@ export namespace Provider {
|
|||||||
|
|
||||||
const configProviders = Object.entries(config.provider ?? {})
|
const configProviders = Object.entries(config.provider ?? {})
|
||||||
|
|
||||||
|
// Add GitHub Copilot Enterprise provider that inherits from GitHub Copilot
|
||||||
|
if (database["github-copilot"]) {
|
||||||
|
const githubCopilot = database["github-copilot"]
|
||||||
|
database["github-copilot-enterprise"] = {
|
||||||
|
...githubCopilot,
|
||||||
|
id: ProviderID.githubCopilotEnterprise,
|
||||||
|
name: "GitHub Copilot Enterprise",
|
||||||
|
models: mapValues(githubCopilot.models, (model) => ({
|
||||||
|
...model,
|
||||||
|
providerID: ProviderID.githubCopilotEnterprise,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function mergeProvider(providerID: ProviderID, provider: Partial<Info>) {
|
function mergeProvider(providerID: ProviderID, provider: Partial<Info>) {
|
||||||
const existing = providers[providerID]
|
const existing = providers[providerID]
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -979,16 +1003,46 @@ export namespace Provider {
|
|||||||
const providerID = ProviderID.make(plugin.auth.provider)
|
const providerID = ProviderID.make(plugin.auth.provider)
|
||||||
if (disabled.has(providerID)) continue
|
if (disabled.has(providerID)) continue
|
||||||
|
|
||||||
|
// For github-copilot plugin, check if auth exists for either github-copilot or github-copilot-enterprise
|
||||||
|
let hasAuth = false
|
||||||
const auth = await Auth.get(providerID)
|
const auth = await Auth.get(providerID)
|
||||||
if (!auth) continue
|
if (auth) hasAuth = true
|
||||||
|
|
||||||
|
// Special handling for github-copilot: also check for enterprise auth
|
||||||
|
if (providerID === ProviderID.githubCopilot && !hasAuth) {
|
||||||
|
const enterpriseAuth = await Auth.get("github-copilot-enterprise")
|
||||||
|
if (enterpriseAuth) hasAuth = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAuth) continue
|
||||||
if (!plugin.auth.loader) continue
|
if (!plugin.auth.loader) continue
|
||||||
|
|
||||||
|
// Load for the main provider if auth exists
|
||||||
if (auth) {
|
if (auth) {
|
||||||
const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider])
|
const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider])
|
||||||
const opts = options ?? {}
|
const opts = options ?? {}
|
||||||
const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
|
const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
|
||||||
mergeProvider(providerID, patch)
|
mergeProvider(providerID, patch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If this is github-copilot plugin, also register for github-copilot-enterprise if auth exists
|
||||||
|
if (providerID === ProviderID.githubCopilot) {
|
||||||
|
const enterpriseProviderID = ProviderID.githubCopilotEnterprise
|
||||||
|
if (!disabled.has(enterpriseProviderID)) {
|
||||||
|
const enterpriseAuth = await Auth.get(enterpriseProviderID)
|
||||||
|
if (enterpriseAuth) {
|
||||||
|
const enterpriseOptions = await plugin.auth.loader(
|
||||||
|
() => Auth.get(enterpriseProviderID) as any,
|
||||||
|
database[enterpriseProviderID],
|
||||||
|
)
|
||||||
|
const opts = enterpriseOptions ?? {}
|
||||||
|
const patch: Partial<Info> = providers[enterpriseProviderID]
|
||||||
|
? { options: opts }
|
||||||
|
: { source: "custom", options: opts }
|
||||||
|
mergeProvider(enterpriseProviderID, patch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [id, fn] of Object.entries(CUSTOM_LOADERS)) {
|
for (const [id, fn] of Object.entries(CUSTOM_LOADERS)) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const ProviderID = providerIdSchema.pipe(
|
|||||||
google: schema.makeUnsafe("google"),
|
google: schema.makeUnsafe("google"),
|
||||||
googleVertex: schema.makeUnsafe("google-vertex"),
|
googleVertex: schema.makeUnsafe("google-vertex"),
|
||||||
githubCopilot: schema.makeUnsafe("github-copilot"),
|
githubCopilot: schema.makeUnsafe("github-copilot"),
|
||||||
|
githubCopilotEnterprise: schema.makeUnsafe("github-copilot-enterprise"),
|
||||||
amazonBedrock: schema.makeUnsafe("amazon-bedrock"),
|
amazonBedrock: schema.makeUnsafe("amazon-bedrock"),
|
||||||
azure: schema.makeUnsafe("azure"),
|
azure: schema.makeUnsafe("azure"),
|
||||||
openrouter: schema.makeUnsafe("openrouter"),
|
openrouter: schema.makeUnsafe("openrouter"),
|
||||||
|
|||||||
@@ -1,168 +0,0 @@
|
|||||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
|
||||||
import { Bus } from "@/bus"
|
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
|
||||||
import { SessionID, MessageID } from "@/session/schema"
|
|
||||||
import { Log } from "@/util/log"
|
|
||||||
import z from "zod"
|
|
||||||
import { QuestionID } from "./schema"
|
|
||||||
|
|
||||||
export namespace QuestionEffect {
|
|
||||||
const log = Log.create({ service: "question" })
|
|
||||||
|
|
||||||
export const Option = z
|
|
||||||
.object({
|
|
||||||
label: z.string().describe("Display text (1-5 words, concise)"),
|
|
||||||
description: z.string().describe("Explanation of choice"),
|
|
||||||
})
|
|
||||||
.meta({ ref: "QuestionOption" })
|
|
||||||
export type Option = z.infer<typeof Option>
|
|
||||||
|
|
||||||
export const Info = z
|
|
||||||
.object({
|
|
||||||
question: z.string().describe("Complete question"),
|
|
||||||
header: z.string().describe("Very short label (max 30 chars)"),
|
|
||||||
options: z.array(Option).describe("Available choices"),
|
|
||||||
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
|
|
||||||
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
|
|
||||||
})
|
|
||||||
.meta({ ref: "QuestionInfo" })
|
|
||||||
export type Info = z.infer<typeof Info>
|
|
||||||
|
|
||||||
export const Request = z
|
|
||||||
.object({
|
|
||||||
id: QuestionID.zod,
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
questions: z.array(Info).describe("Questions to ask"),
|
|
||||||
tool: z
|
|
||||||
.object({
|
|
||||||
messageID: MessageID.zod,
|
|
||||||
callID: z.string(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.meta({ ref: "QuestionRequest" })
|
|
||||||
export type Request = z.infer<typeof Request>
|
|
||||||
|
|
||||||
export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
|
|
||||||
export type Answer = z.infer<typeof Answer>
|
|
||||||
|
|
||||||
export const Reply = z.object({
|
|
||||||
answers: z.array(Answer).describe("User answers in order of questions (each answer is an array of selected labels)"),
|
|
||||||
})
|
|
||||||
export type Reply = z.infer<typeof Reply>
|
|
||||||
|
|
||||||
export const Event = {
|
|
||||||
Asked: BusEvent.define("question.asked", Request),
|
|
||||||
Replied: BusEvent.define(
|
|
||||||
"question.replied",
|
|
||||||
z.object({
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
requestID: QuestionID.zod,
|
|
||||||
answers: z.array(Answer),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Rejected: BusEvent.define(
|
|
||||||
"question.rejected",
|
|
||||||
z.object({
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
requestID: QuestionID.zod,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
|
|
||||||
override get message() {
|
|
||||||
return "The user dismissed this question"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Error = RejectedError
|
|
||||||
|
|
||||||
interface Pending {
|
|
||||||
info: Request
|
|
||||||
deferred: Deferred.Deferred<Answer[], RejectedError>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly ask: (input: {
|
|
||||||
sessionID: SessionID
|
|
||||||
questions: Info[]
|
|
||||||
tool?: { messageID: MessageID; callID: string }
|
|
||||||
}) => Effect.Effect<Answer[], RejectedError>
|
|
||||||
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
|
|
||||||
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
|
|
||||||
readonly list: () => Effect.Effect<Request[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Question") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const pending = new Map<QuestionID, Pending>()
|
|
||||||
|
|
||||||
const ask = Effect.fn("Question.ask")(function* (input: {
|
|
||||||
sessionID: SessionID
|
|
||||||
questions: Info[]
|
|
||||||
tool?: { messageID: MessageID; callID: string }
|
|
||||||
}) {
|
|
||||||
const id = QuestionID.ascending()
|
|
||||||
log.info("asking", { id, questions: input.questions.length })
|
|
||||||
|
|
||||||
const deferred = yield* Deferred.make<Answer[], RejectedError>()
|
|
||||||
const info: Request = {
|
|
||||||
id,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
questions: input.questions,
|
|
||||||
tool: input.tool,
|
|
||||||
}
|
|
||||||
pending.set(id, { info, deferred })
|
|
||||||
Bus.publish(Event.Asked, info)
|
|
||||||
|
|
||||||
return yield* Effect.ensuring(
|
|
||||||
Deferred.await(deferred),
|
|
||||||
Effect.sync(() => {
|
|
||||||
pending.delete(id)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
|
|
||||||
const existing = pending.get(input.requestID)
|
|
||||||
if (!existing) {
|
|
||||||
log.warn("reply for unknown request", { requestID: input.requestID })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pending.delete(input.requestID)
|
|
||||||
log.info("replied", { requestID: input.requestID, answers: input.answers })
|
|
||||||
Bus.publish(Event.Replied, {
|
|
||||||
sessionID: existing.info.sessionID,
|
|
||||||
requestID: existing.info.id,
|
|
||||||
answers: input.answers,
|
|
||||||
})
|
|
||||||
yield* Deferred.succeed(existing.deferred, input.answers)
|
|
||||||
})
|
|
||||||
|
|
||||||
const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
|
|
||||||
const existing = pending.get(requestID)
|
|
||||||
if (!existing) {
|
|
||||||
log.warn("reject for unknown request", { requestID })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pending.delete(requestID)
|
|
||||||
log.info("rejected", { requestID })
|
|
||||||
Bus.publish(Event.Rejected, {
|
|
||||||
sessionID: existing.info.sessionID,
|
|
||||||
requestID: existing.info.id,
|
|
||||||
})
|
|
||||||
yield* Deferred.fail(existing.deferred, new RejectedError())
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = Effect.fn("Question.list")(function* () {
|
|
||||||
return Array.from(pending.values(), (x) => x.info)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ ask, reply, reject, list })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,39 +1,39 @@
|
|||||||
import { runPromiseInstance } from "@/effect/runtime"
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
import * as S from "./effect"
|
import * as S from "./service"
|
||||||
import type { QuestionID } from "./schema"
|
import type { QuestionID } from "./schema"
|
||||||
import type { SessionID, MessageID } from "@/session/schema"
|
import type { SessionID, MessageID } from "@/session/schema"
|
||||||
|
|
||||||
export namespace Question {
|
export namespace Question {
|
||||||
export const Option = S.QuestionEffect.Option
|
export const Option = S.Option
|
||||||
export type Option = S.QuestionEffect.Option
|
export type Option = S.Option
|
||||||
export const Info = S.QuestionEffect.Info
|
export const Info = S.Info
|
||||||
export type Info = S.QuestionEffect.Info
|
export type Info = S.Info
|
||||||
export const Request = S.QuestionEffect.Request
|
export const Request = S.Request
|
||||||
export type Request = S.QuestionEffect.Request
|
export type Request = S.Request
|
||||||
export const Answer = S.QuestionEffect.Answer
|
export const Answer = S.Answer
|
||||||
export type Answer = S.QuestionEffect.Answer
|
export type Answer = S.Answer
|
||||||
export const Reply = S.QuestionEffect.Reply
|
export const Reply = S.Reply
|
||||||
export type Reply = S.QuestionEffect.Reply
|
export type Reply = S.Reply
|
||||||
export const Event = S.QuestionEffect.Event
|
export const Event = S.Event
|
||||||
export const RejectedError = S.QuestionEffect.RejectedError
|
export const RejectedError = S.RejectedError
|
||||||
|
|
||||||
export async function ask(input: {
|
export async function ask(input: {
|
||||||
sessionID: SessionID
|
sessionID: SessionID
|
||||||
questions: Info[]
|
questions: Info[]
|
||||||
tool?: { messageID: MessageID; callID: string }
|
tool?: { messageID: MessageID; callID: string }
|
||||||
}): Promise<Answer[]> {
|
}): Promise<Answer[]> {
|
||||||
return runPromiseInstance(S.QuestionEffect.Service.use((service) => service.ask(input)))
|
return runPromiseInstance(S.QuestionService.use((service) => service.ask(input)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> {
|
export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> {
|
||||||
return runPromiseInstance(S.QuestionEffect.Service.use((service) => service.reply(input)))
|
return runPromiseInstance(S.QuestionService.use((service) => service.reply(input)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reject(requestID: QuestionID): Promise<void> {
|
export async function reject(requestID: QuestionID): Promise<void> {
|
||||||
return runPromiseInstance(S.QuestionEffect.Service.use((service) => service.reject(requestID)))
|
return runPromiseInstance(S.QuestionService.use((service) => service.reject(requestID)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function list(): Promise<Request[]> {
|
export async function list(): Promise<Request[]> {
|
||||||
return runPromiseInstance(S.QuestionEffect.Service.use((service) => service.list()))
|
return runPromiseInstance(S.QuestionService.use((service) => service.list()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { SessionID, MessageID } from "@/session/schema"
|
||||||
|
import { Log } from "@/util/log"
|
||||||
|
import z from "zod"
|
||||||
|
import { QuestionID } from "./schema"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "question" })
|
||||||
|
|
||||||
|
// --- Zod schemas (re-exported by facade) ---
|
||||||
|
|
||||||
|
export const Option = z
|
||||||
|
.object({
|
||||||
|
label: z.string().describe("Display text (1-5 words, concise)"),
|
||||||
|
description: z.string().describe("Explanation of choice"),
|
||||||
|
})
|
||||||
|
.meta({ ref: "QuestionOption" })
|
||||||
|
export type Option = z.infer<typeof Option>
|
||||||
|
|
||||||
|
export const Info = z
|
||||||
|
.object({
|
||||||
|
question: z.string().describe("Complete question"),
|
||||||
|
header: z.string().describe("Very short label (max 30 chars)"),
|
||||||
|
options: z.array(Option).describe("Available choices"),
|
||||||
|
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
|
||||||
|
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
|
||||||
|
})
|
||||||
|
.meta({ ref: "QuestionInfo" })
|
||||||
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
|
export const Request = z
|
||||||
|
.object({
|
||||||
|
id: QuestionID.zod,
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
questions: z.array(Info).describe("Questions to ask"),
|
||||||
|
tool: z
|
||||||
|
.object({
|
||||||
|
messageID: MessageID.zod,
|
||||||
|
callID: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.meta({ ref: "QuestionRequest" })
|
||||||
|
export type Request = z.infer<typeof Request>
|
||||||
|
|
||||||
|
export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
|
||||||
|
export type Answer = z.infer<typeof Answer>
|
||||||
|
|
||||||
|
export const Reply = z.object({
|
||||||
|
answers: z.array(Answer).describe("User answers in order of questions (each answer is an array of selected labels)"),
|
||||||
|
})
|
||||||
|
export type Reply = z.infer<typeof Reply>
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Asked: BusEvent.define("question.asked", Request),
|
||||||
|
Replied: BusEvent.define(
|
||||||
|
"question.replied",
|
||||||
|
z.object({
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
requestID: QuestionID.zod,
|
||||||
|
answers: z.array(Answer),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Rejected: BusEvent.define(
|
||||||
|
"question.rejected",
|
||||||
|
z.object({
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
requestID: QuestionID.zod,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
|
||||||
|
override get message() {
|
||||||
|
return "The user dismissed this question"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Effect service ---
|
||||||
|
|
||||||
|
interface PendingEntry {
|
||||||
|
info: Request
|
||||||
|
deferred: Deferred.Deferred<Answer[], RejectedError>
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace QuestionService {
|
||||||
|
export interface Service {
|
||||||
|
readonly ask: (input: {
|
||||||
|
sessionID: SessionID
|
||||||
|
questions: Info[]
|
||||||
|
tool?: { messageID: MessageID; callID: string }
|
||||||
|
}) => Effect.Effect<Answer[], RejectedError>
|
||||||
|
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
|
||||||
|
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
|
||||||
|
readonly list: () => Effect.Effect<Request[]>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QuestionService extends ServiceMap.Service<QuestionService, QuestionService.Service>()(
|
||||||
|
"@opencode/Question",
|
||||||
|
) {
|
||||||
|
static readonly layer = Layer.effect(
|
||||||
|
QuestionService,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pending = new Map<QuestionID, PendingEntry>()
|
||||||
|
|
||||||
|
const ask = Effect.fn("QuestionService.ask")(function* (input: {
|
||||||
|
sessionID: SessionID
|
||||||
|
questions: Info[]
|
||||||
|
tool?: { messageID: MessageID; callID: string }
|
||||||
|
}) {
|
||||||
|
const id = QuestionID.ascending()
|
||||||
|
log.info("asking", { id, questions: input.questions.length })
|
||||||
|
|
||||||
|
const deferred = yield* Deferred.make<Answer[], RejectedError>()
|
||||||
|
const info: Request = {
|
||||||
|
id,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
questions: input.questions,
|
||||||
|
tool: input.tool,
|
||||||
|
}
|
||||||
|
pending.set(id, { info, deferred })
|
||||||
|
Bus.publish(Event.Asked, info)
|
||||||
|
|
||||||
|
return yield* Effect.ensuring(
|
||||||
|
Deferred.await(deferred),
|
||||||
|
Effect.sync(() => {
|
||||||
|
pending.delete(id)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const reply = Effect.fn("QuestionService.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
|
||||||
|
const existing = pending.get(input.requestID)
|
||||||
|
if (!existing) {
|
||||||
|
log.warn("reply for unknown request", { requestID: input.requestID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pending.delete(input.requestID)
|
||||||
|
log.info("replied", { requestID: input.requestID, answers: input.answers })
|
||||||
|
Bus.publish(Event.Replied, {
|
||||||
|
sessionID: existing.info.sessionID,
|
||||||
|
requestID: existing.info.id,
|
||||||
|
answers: input.answers,
|
||||||
|
})
|
||||||
|
yield* Deferred.succeed(existing.deferred, input.answers)
|
||||||
|
})
|
||||||
|
|
||||||
|
const reject = Effect.fn("QuestionService.reject")(function* (requestID: QuestionID) {
|
||||||
|
const existing = pending.get(requestID)
|
||||||
|
if (!existing) {
|
||||||
|
log.warn("reject for unknown request", { requestID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pending.delete(requestID)
|
||||||
|
log.info("rejected", { requestID })
|
||||||
|
Bus.publish(Event.Rejected, {
|
||||||
|
sessionID: existing.info.sessionID,
|
||||||
|
requestID: existing.info.id,
|
||||||
|
})
|
||||||
|
yield* Deferred.fail(existing.deferred, new RejectedError())
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Effect.fn("QuestionService.list")(function* () {
|
||||||
|
return Array.from(pending.values(), (x) => x.info)
|
||||||
|
})
|
||||||
|
|
||||||
|
return QuestionService.of({ ask, reply, reject, list })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Instance } from "../project/instance"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
|
||||||
|
export namespace Scheduler {
|
||||||
|
const log = Log.create({ service: "scheduler" })
|
||||||
|
|
||||||
|
export type Task = {
|
||||||
|
id: string
|
||||||
|
interval: number
|
||||||
|
run: () => Promise<void>
|
||||||
|
scope?: "instance" | "global"
|
||||||
|
}
|
||||||
|
|
||||||
|
type Timer = ReturnType<typeof setInterval>
|
||||||
|
type Entry = {
|
||||||
|
tasks: Map<string, Task>
|
||||||
|
timers: Map<string, Timer>
|
||||||
|
}
|
||||||
|
|
||||||
|
const create = (): Entry => {
|
||||||
|
const tasks = new Map<string, Task>()
|
||||||
|
const timers = new Map<string, Timer>()
|
||||||
|
return { tasks, timers }
|
||||||
|
}
|
||||||
|
|
||||||
|
const shared = create()
|
||||||
|
|
||||||
|
const state = Instance.state(
|
||||||
|
() => create(),
|
||||||
|
async (entry) => {
|
||||||
|
for (const timer of entry.timers.values()) {
|
||||||
|
clearInterval(timer)
|
||||||
|
}
|
||||||
|
entry.tasks.clear()
|
||||||
|
entry.timers.clear()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export function register(task: Task) {
|
||||||
|
const scope = task.scope ?? "instance"
|
||||||
|
const entry = scope === "global" ? shared : state()
|
||||||
|
const current = entry.timers.get(task.id)
|
||||||
|
if (current && scope === "global") return
|
||||||
|
if (current) clearInterval(current)
|
||||||
|
|
||||||
|
entry.tasks.set(task.id, task)
|
||||||
|
void run(task)
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
void run(task)
|
||||||
|
}, task.interval)
|
||||||
|
timer.unref()
|
||||||
|
entry.timers.set(task.id, timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(task: Task) {
|
||||||
|
log.info("run", { id: task.id })
|
||||||
|
await task.run().catch((error) => {
|
||||||
|
log.error("run failed", { id: task.id, error })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { PermissionNext } from "@/permission"
|
import { PermissionNext } from "@/permission/next"
|
||||||
import { PermissionID } from "@/permission/schema"
|
import { PermissionID } from "@/permission/schema"
|
||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
import { lazy } from "../../util/lazy"
|
import { lazy } from "../../util/lazy"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { Todo } from "../../session/todo"
|
|||||||
import { Agent } from "../../agent/agent"
|
import { Agent } from "../../agent/agent"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
import { Log } from "../../util/log"
|
import { Log } from "../../util/log"
|
||||||
import { PermissionNext } from "@/permission"
|
import { PermissionNext } from "@/permission/next"
|
||||||
import { PermissionID } from "@/permission/schema"
|
import { PermissionID } from "@/permission/schema"
|
||||||
import { ModelID, ProviderID } from "@/provider/schema"
|
import { ModelID, ProviderID } from "@/provider/schema"
|
||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { LSP } from "../lsp"
|
|||||||
import { Format } from "../format"
|
import { Format } from "../format"
|
||||||
import { TuiRoutes } from "./routes/tui"
|
import { TuiRoutes } from "./routes/tui"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Vcs } from "../project/vcs"
|
import { Vcs, VcsService } from "../project/vcs"
|
||||||
import { runPromiseInstance } from "@/effect/runtime"
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
import { Agent } from "../agent/agent"
|
import { Agent } from "../agent/agent"
|
||||||
import { Skill } from "../skill/skill"
|
import { Skill } from "../skill/skill"
|
||||||
@@ -331,7 +331,7 @@ export namespace Server {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch()))
|
const branch = await runPromiseInstance(VcsService.use((s) => s.branch()))
|
||||||
return c.json({
|
return c.json({
|
||||||
branch,
|
branch,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { SessionID, MessageID, PartID } from "./schema"
|
|||||||
|
|
||||||
import type { Provider } from "@/provider/provider"
|
import type { Provider } from "@/provider/provider"
|
||||||
import { ModelID, ProviderID } from "@/provider/schema"
|
import { ModelID, ProviderID } from "@/provider/schema"
|
||||||
import { PermissionNext } from "@/permission"
|
import { PermissionNext } from "@/permission/next"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
|
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import type { MessageV2 } from "./message-v2"
|
|||||||
import { Plugin } from "@/plugin"
|
import { Plugin } from "@/plugin"
|
||||||
import { SystemPrompt } from "./system"
|
import { SystemPrompt } from "./system"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { PermissionNext } from "@/permission"
|
import { PermissionNext } from "@/permission/next"
|
||||||
import { Auth } from "@/auth"
|
import { Auth } from "@/auth"
|
||||||
|
|
||||||
export namespace LLM {
|
export namespace LLM {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type { Provider } from "@/provider/provider"
|
|||||||
import { LLM } from "./llm"
|
import { LLM } from "./llm"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { SessionCompaction } from "./compaction"
|
import { SessionCompaction } from "./compaction"
|
||||||
import { PermissionNext } from "@/permission"
|
import { PermissionNext } from "@/permission/next"
|
||||||
import { Question } from "@/question"
|
import { Question } from "@/question"
|
||||||
import { PartID } from "./schema"
|
import { PartID } from "./schema"
|
||||||
import type { SessionID, MessageID } from "./schema"
|
import type { SessionID, MessageID } from "./schema"
|
||||||
|
|||||||
@@ -41,12 +41,12 @@ import { fn } from "@/util/fn"
|
|||||||
import { SessionProcessor } from "./processor"
|
import { SessionProcessor } from "./processor"
|
||||||
import { TaskTool } from "@/tool/task"
|
import { TaskTool } from "@/tool/task"
|
||||||
import { Tool } from "@/tool/tool"
|
import { Tool } from "@/tool/tool"
|
||||||
import { PermissionNext } from "@/permission"
|
import { PermissionNext } from "@/permission/next"
|
||||||
import { SessionStatus } from "./status"
|
import { SessionStatus } from "./status"
|
||||||
import { LLM } from "./llm"
|
import { LLM } from "./llm"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
import { Shell } from "@/shell/shell"
|
import { Shell } from "@/shell/shell"
|
||||||
import { Truncate } from "@/tool/truncate"
|
import { Truncate } from "@/tool/truncation"
|
||||||
import { decodeDataUrl } from "@/util/data-url"
|
import { decodeDataUrl } from "@/util/data-url"
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlit
|
|||||||
import { ProjectTable } from "../project/project.sql"
|
import { ProjectTable } from "../project/project.sql"
|
||||||
import type { MessageV2 } from "./message-v2"
|
import type { MessageV2 } from "./message-v2"
|
||||||
import type { Snapshot } from "../snapshot"
|
import type { Snapshot } from "../snapshot"
|
||||||
import type { PermissionNext } from "../permission"
|
import type { PermissionNext } from "../permission/next"
|
||||||
import type { ProjectID } from "../project/schema"
|
import type { ProjectID } from "../project/schema"
|
||||||
import type { SessionID, MessageID, PartID } from "./schema"
|
import type { SessionID, MessageID, PartID } from "./schema"
|
||||||
import type { WorkspaceID } from "../control-plane/schema"
|
import type { WorkspaceID } from "../control-plane/schema"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import PROMPT_CODEX from "./prompt/codex_header.txt"
|
|||||||
import PROMPT_TRINITY from "./prompt/trinity.txt"
|
import PROMPT_TRINITY from "./prompt/trinity.txt"
|
||||||
import type { Provider } from "@/provider/provider"
|
import type { Provider } from "@/provider/provider"
|
||||||
import type { Agent } from "@/agent/agent"
|
import type { Agent } from "@/agent/agent"
|
||||||
import { PermissionNext } from "@/permission"
|
import { PermissionNext } from "@/permission/next"
|
||||||
import { Skill } from "@/skill"
|
import { Skill } from "@/skill"
|
||||||
|
|
||||||
export namespace SystemPrompt {
|
export namespace SystemPrompt {
|
||||||
|
|||||||
@@ -1,117 +1,116 @@
|
|||||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||||
import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect"
|
import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect"
|
||||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||||
|
|
||||||
export namespace Discovery {
|
class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
|
||||||
const skillConcurrency = 4
|
name: Schema.String,
|
||||||
const fileConcurrency = 8
|
files: Schema.Array(Schema.String),
|
||||||
|
}) {}
|
||||||
|
|
||||||
class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
|
class Index extends Schema.Class<Index>("Index")({
|
||||||
name: Schema.String,
|
skills: Schema.Array(IndexSkill),
|
||||||
files: Schema.Array(Schema.String),
|
}) {}
|
||||||
}) {}
|
|
||||||
|
|
||||||
class Index extends Schema.Class<Index>("Index")({
|
const skillConcurrency = 4
|
||||||
skills: Schema.Array(IndexSkill),
|
const fileConcurrency = 8
|
||||||
}) {}
|
|
||||||
|
|
||||||
export interface Interface {
|
export namespace DiscoveryService {
|
||||||
|
export interface Service {
|
||||||
readonly pull: (url: string) => Effect.Effect<string[]>
|
readonly pull: (url: string) => Effect.Effect<string[]>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SkillDiscovery") {}
|
export class DiscoveryService extends ServiceMap.Service<DiscoveryService, DiscoveryService.Service>()(
|
||||||
|
"@opencode/SkillDiscovery",
|
||||||
|
) {
|
||||||
|
static readonly layer = Layer.effect(
|
||||||
|
DiscoveryService,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const log = Log.create({ service: "skill-discovery" })
|
||||||
|
const fs = yield* FileSystem.FileSystem
|
||||||
|
const path = yield* Path.Path
|
||||||
|
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
||||||
|
const cache = path.join(Global.Path.cache, "skills")
|
||||||
|
|
||||||
export const layer: Layer.Layer<Service, never, FileSystem.FileSystem | Path.Path | HttpClient.HttpClient> =
|
const download = Effect.fn("DiscoveryService.download")(function* (url: string, dest: string) {
|
||||||
Layer.effect(
|
if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const log = Log.create({ service: "skill-discovery" })
|
|
||||||
const fs = yield* FileSystem.FileSystem
|
|
||||||
const path = yield* Path.Path
|
|
||||||
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
|
||||||
const cache = path.join(Global.Path.cache, "skills")
|
|
||||||
|
|
||||||
const download = Effect.fn("Discovery.download")(function* (url: string, dest: string) {
|
return yield* HttpClientRequest.get(url).pipe(
|
||||||
if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
|
http.execute,
|
||||||
|
Effect.flatMap((res) => res.arrayBuffer),
|
||||||
return yield* HttpClientRequest.get(url).pipe(
|
Effect.flatMap((body) =>
|
||||||
http.execute,
|
fs
|
||||||
Effect.flatMap((res) => res.arrayBuffer),
|
.makeDirectory(path.dirname(dest), { recursive: true })
|
||||||
Effect.flatMap((body) =>
|
.pipe(Effect.flatMap(() => fs.writeFile(dest, new Uint8Array(body)))),
|
||||||
fs
|
),
|
||||||
.makeDirectory(path.dirname(dest), { recursive: true })
|
Effect.as(true),
|
||||||
.pipe(Effect.flatMap(() => fs.writeFile(dest, new Uint8Array(body)))),
|
Effect.catch((err) =>
|
||||||
),
|
Effect.sync(() => {
|
||||||
Effect.as(true),
|
log.error("failed to download", { url, err })
|
||||||
Effect.catch((err) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
log.error("failed to download", { url, err })
|
|
||||||
return false
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const pull = Effect.fn("Discovery.pull")(function* (url: string) {
|
|
||||||
const base = url.endsWith("/") ? url : `${url}/`
|
|
||||||
const index = new URL("index.json", base).href
|
|
||||||
const host = base.slice(0, -1)
|
|
||||||
|
|
||||||
log.info("fetching index", { url: index })
|
|
||||||
|
|
||||||
const data = yield* HttpClientRequest.get(index).pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
http.execute,
|
|
||||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
|
|
||||||
Effect.catch((err) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
log.error("failed to fetch index", { url: index, err })
|
|
||||||
return null
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!data) return []
|
|
||||||
|
|
||||||
const list = data.skills.filter((skill) => {
|
|
||||||
if (!skill.files.includes("SKILL.md")) {
|
|
||||||
log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name })
|
|
||||||
return false
|
return false
|
||||||
}
|
}),
|
||||||
return true
|
),
|
||||||
})
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const dirs = yield* Effect.forEach(
|
const pull: DiscoveryService.Service["pull"] = Effect.fn("DiscoveryService.pull")(function* (url: string) {
|
||||||
list,
|
const base = url.endsWith("/") ? url : `${url}/`
|
||||||
(skill) =>
|
const index = new URL("index.json", base).href
|
||||||
Effect.gen(function* () {
|
const host = base.slice(0, -1)
|
||||||
const root = path.join(cache, skill.name)
|
|
||||||
|
|
||||||
yield* Effect.forEach(
|
log.info("fetching index", { url: index })
|
||||||
skill.files,
|
|
||||||
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
|
|
||||||
{
|
|
||||||
concurrency: fileConcurrency,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const md = path.join(root, "SKILL.md")
|
const data = yield* HttpClientRequest.get(index).pipe(
|
||||||
return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
|
HttpClientRequest.acceptJson,
|
||||||
}),
|
http.execute,
|
||||||
{ concurrency: skillConcurrency },
|
Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
|
||||||
)
|
Effect.catch((err) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
log.error("failed to fetch index", { url: index, err })
|
||||||
|
return null
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return dirs.filter((dir): dir is string => dir !== null)
|
if (!data) return []
|
||||||
|
|
||||||
|
const list = data.skills.filter((skill) => {
|
||||||
|
if (!skill.files.includes("SKILL.md")) {
|
||||||
|
log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ pull })
|
const dirs = yield* Effect.forEach(
|
||||||
}),
|
list,
|
||||||
)
|
(skill) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const root = path.join(cache, skill.name)
|
||||||
|
|
||||||
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
yield* Effect.forEach(
|
||||||
|
skill.files,
|
||||||
|
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
|
||||||
|
{ concurrency: fileConcurrency },
|
||||||
|
)
|
||||||
|
|
||||||
|
const md = path.join(root, "SKILL.md")
|
||||||
|
return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
|
||||||
|
}),
|
||||||
|
{ concurrency: skillConcurrency },
|
||||||
|
)
|
||||||
|
|
||||||
|
return dirs.filter((dir): dir is string => dir !== null)
|
||||||
|
})
|
||||||
|
|
||||||
|
return DiscoveryService.of({ pull })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
static readonly defaultLayer = DiscoveryService.layer.pipe(
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(NodeFileSystem.layer),
|
Layer.provide(NodeFileSystem.layer),
|
||||||
Layer.provide(NodePath.layer),
|
Layer.provide(NodePath.layer),
|
||||||
|
|||||||
@@ -1,30 +1,34 @@
|
|||||||
import os from "os"
|
|
||||||
import path from "path"
|
|
||||||
import { pathToFileURL } from "url"
|
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
import path from "path"
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import os from "os"
|
||||||
import type { Agent } from "@/agent/agent"
|
|
||||||
import { Bus } from "@/bus"
|
|
||||||
import { InstanceContext } from "@/effect/instance-context"
|
|
||||||
import { runPromiseInstance } from "@/effect/runtime"
|
|
||||||
import { Flag } from "@/flag/flag"
|
|
||||||
import { Global } from "@/global"
|
|
||||||
import { PermissionNext } from "@/permission"
|
|
||||||
import { Filesystem } from "@/util/filesystem"
|
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
|
import { Instance } from "../project/instance"
|
||||||
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
import { ConfigMarkdown } from "../config/markdown"
|
import { ConfigMarkdown } from "../config/markdown"
|
||||||
import { Glob } from "../util/glob"
|
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { Discovery } from "./discovery"
|
import { Global } from "@/global"
|
||||||
|
import { Filesystem } from "@/util/filesystem"
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { DiscoveryService } from "./discovery"
|
||||||
|
import { Glob } from "../util/glob"
|
||||||
|
import { pathToFileURL } from "url"
|
||||||
|
import type { Agent } from "@/agent/agent"
|
||||||
|
import { PermissionNext } from "@/permission/next"
|
||||||
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "skill" })
|
||||||
|
|
||||||
|
// External skill directories to search for (project-level and global)
|
||||||
|
// These follow the directory layout used by Claude Code and other agents.
|
||||||
|
const EXTERNAL_DIRS = [".claude", ".agents"]
|
||||||
|
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
|
||||||
|
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
|
||||||
|
const SKILL_PATTERN = "**/SKILL.md"
|
||||||
|
|
||||||
export namespace Skill {
|
export namespace Skill {
|
||||||
const log = Log.create({ service: "skill" })
|
|
||||||
const EXTERNAL_DIRS = [".claude", ".agents"]
|
|
||||||
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
|
|
||||||
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
|
|
||||||
const SKILL_PATTERN = "**/SKILL.md"
|
|
||||||
|
|
||||||
export const Info = z.object({
|
export const Info = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
@@ -51,205 +55,213 @@ export namespace Skill {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
type State = {
|
|
||||||
skills: Record<string, Info>
|
|
||||||
dirs: Set<string>
|
|
||||||
task?: Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
type Cache = State & {
|
|
||||||
ensure: () => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
|
||||||
readonly all: () => Effect.Effect<Info[]>
|
|
||||||
readonly dirs: () => Effect.Effect<string[]>
|
|
||||||
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
const add = async (state: State, match: string) => {
|
|
||||||
const md = await ConfigMarkdown.parse(match).catch(async (err) => {
|
|
||||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
|
||||||
? err.data.message
|
|
||||||
: `Failed to parse skill ${match}`
|
|
||||||
const { Session } = await import("@/session")
|
|
||||||
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
|
||||||
log.error("failed to load skill", { skill: match, err })
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!md) return
|
|
||||||
|
|
||||||
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
|
|
||||||
if (!parsed.success) return
|
|
||||||
|
|
||||||
if (state.skills[parsed.data.name]) {
|
|
||||||
log.warn("duplicate skill name", {
|
|
||||||
name: parsed.data.name,
|
|
||||||
existing: state.skills[parsed.data.name].location,
|
|
||||||
duplicate: match,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
state.dirs.add(path.dirname(match))
|
|
||||||
state.skills[parsed.data.name] = {
|
|
||||||
name: parsed.data.name,
|
|
||||||
description: parsed.data.description,
|
|
||||||
location: match,
|
|
||||||
content: md.content,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
|
|
||||||
return Glob.scan(pattern, {
|
|
||||||
cwd: root,
|
|
||||||
absolute: true,
|
|
||||||
include: "file",
|
|
||||||
symlink: true,
|
|
||||||
dot: opts?.dot,
|
|
||||||
})
|
|
||||||
.then((matches) => Promise.all(matches.map((match) => add(state, match))))
|
|
||||||
.catch((error) => {
|
|
||||||
if (!opts?.scope) throw error
|
|
||||||
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Migrate to Effect
|
|
||||||
const create = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => {
|
|
||||||
const state: State = {
|
|
||||||
skills: {},
|
|
||||||
dirs: new Set<string>(),
|
|
||||||
}
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
|
||||||
for (const dir of EXTERNAL_DIRS) {
|
|
||||||
const root = path.join(Global.Path.home, dir)
|
|
||||||
if (!(await Filesystem.isDir(root))) continue
|
|
||||||
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
|
||||||
}
|
|
||||||
|
|
||||||
for await (const root of Filesystem.up({
|
|
||||||
targets: EXTERNAL_DIRS,
|
|
||||||
start: instance.directory,
|
|
||||||
stop: instance.project.worktree,
|
|
||||||
})) {
|
|
||||||
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const dir of await Config.directories()) {
|
|
||||||
await scan(state, dir, OPENCODE_SKILL_PATTERN)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cfg = await Config.get()
|
|
||||||
for (const item of cfg.skills?.paths ?? []) {
|
|
||||||
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
|
|
||||||
const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
|
|
||||||
if (!(await Filesystem.isDir(dir))) {
|
|
||||||
log.warn("skill path not found", { path: dir })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
await scan(state, dir, SKILL_PATTERN)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const url of cfg.skills?.urls ?? []) {
|
|
||||||
for (const dir of await Effect.runPromise(discovery.pull(url))) {
|
|
||||||
state.dirs.add(dir)
|
|
||||||
await scan(state, dir, SKILL_PATTERN)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("init", { count: Object.keys(state.skills).length })
|
|
||||||
}
|
|
||||||
|
|
||||||
const ensure = () => {
|
|
||||||
if (state.task) return state.task
|
|
||||||
state.task = load().catch((err) => {
|
|
||||||
state.task = undefined
|
|
||||||
throw err
|
|
||||||
})
|
|
||||||
return state.task
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...state, ensure }
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
|
|
||||||
|
|
||||||
export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service> = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const instance = yield* InstanceContext
|
|
||||||
const discovery = yield* Discovery.Service
|
|
||||||
const state = create(instance, discovery)
|
|
||||||
|
|
||||||
const get = Effect.fn("Skill.get")(function* (name: string) {
|
|
||||||
yield* Effect.promise(() => state.ensure())
|
|
||||||
return state.skills[name]
|
|
||||||
})
|
|
||||||
|
|
||||||
const all = Effect.fn("Skill.all")(function* () {
|
|
||||||
yield* Effect.promise(() => state.ensure())
|
|
||||||
return Object.values(state.skills)
|
|
||||||
})
|
|
||||||
|
|
||||||
const dirs = Effect.fn("Skill.dirs")(function* () {
|
|
||||||
yield* Effect.promise(() => state.ensure())
|
|
||||||
return Array.from(state.dirs)
|
|
||||||
})
|
|
||||||
|
|
||||||
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
|
|
||||||
yield* Effect.promise(() => state.ensure())
|
|
||||||
const list = Object.values(state.skills)
|
|
||||||
if (!agent) return list
|
|
||||||
return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ get, all, dirs, available })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer: Layer.Layer<Service, never, InstanceContext> = layer.pipe(
|
|
||||||
Layer.provide(Discovery.defaultLayer),
|
|
||||||
)
|
|
||||||
|
|
||||||
export async function get(name: string) {
|
export async function get(name: string) {
|
||||||
return runPromiseInstance(Service.use((skill) => skill.get(name)))
|
return runPromiseInstance(SkillService.use((s) => s.get(name)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function all() {
|
export async function all() {
|
||||||
return runPromiseInstance(Service.use((skill) => skill.all()))
|
return runPromiseInstance(SkillService.use((s) => s.all()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function dirs() {
|
export async function dirs() {
|
||||||
return runPromiseInstance(Service.use((skill) => skill.dirs()))
|
return runPromiseInstance(SkillService.use((s) => s.dirs()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function available(agent?: Agent.Info) {
|
export async function available(agent?: Agent.Info) {
|
||||||
return runPromiseInstance(Service.use((skill) => skill.available(agent)))
|
return runPromiseInstance(SkillService.use((s) => s.available(agent)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fmt(list: Info[], opts: { verbose: boolean }) {
|
export function fmt(list: Info[], opts: { verbose: boolean }) {
|
||||||
if (list.length === 0) return "No skills are currently available."
|
if (list.length === 0) {
|
||||||
|
return "No skills are currently available."
|
||||||
|
}
|
||||||
if (opts.verbose) {
|
if (opts.verbose) {
|
||||||
return [
|
return [
|
||||||
"<available_skills>",
|
"<available_skills>",
|
||||||
...list.flatMap((skill) => [
|
...list.flatMap((skill) => [
|
||||||
" <skill>",
|
` <skill>`,
|
||||||
` <name>${skill.name}</name>`,
|
` <name>${skill.name}</name>`,
|
||||||
` <description>${skill.description}</description>`,
|
` <description>${skill.description}</description>`,
|
||||||
` <location>${pathToFileURL(skill.location).href}</location>`,
|
` <location>${pathToFileURL(skill.location).href}</location>`,
|
||||||
" </skill>",
|
` </skill>`,
|
||||||
]),
|
]),
|
||||||
"</available_skills>",
|
"</available_skills>",
|
||||||
].join("\n")
|
].join("\n")
|
||||||
}
|
}
|
||||||
|
return ["## Available Skills", ...list.flatMap((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
|
||||||
return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace SkillService {
|
||||||
|
export interface Service {
|
||||||
|
readonly get: (name: string) => Effect.Effect<Skill.Info | undefined>
|
||||||
|
readonly all: () => Effect.Effect<Skill.Info[]>
|
||||||
|
readonly dirs: () => Effect.Effect<string[]>
|
||||||
|
readonly available: (agent?: Agent.Info) => Effect.Effect<Skill.Info[]>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SkillService extends ServiceMap.Service<SkillService, SkillService.Service>()("@opencode/Skill") {
|
||||||
|
static readonly layer = Layer.effect(
|
||||||
|
SkillService,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const instance = yield* InstanceContext
|
||||||
|
const discovery = yield* DiscoveryService
|
||||||
|
|
||||||
|
const skills: Record<string, Skill.Info> = {}
|
||||||
|
const skillDirs = new Set<string>()
|
||||||
|
let task: Promise<void> | undefined
|
||||||
|
|
||||||
|
const addSkill = async (match: string) => {
|
||||||
|
const md = await ConfigMarkdown.parse(match).catch(async (err) => {
|
||||||
|
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||||
|
? err.data.message
|
||||||
|
: `Failed to parse skill ${match}`
|
||||||
|
const { Session } = await import("@/session")
|
||||||
|
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||||
|
log.error("failed to load skill", { skill: match, err })
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!md) return
|
||||||
|
|
||||||
|
const parsed = Skill.Info.pick({ name: true, description: true }).safeParse(md.data)
|
||||||
|
if (!parsed.success) return
|
||||||
|
|
||||||
|
// Warn on duplicate skill names
|
||||||
|
if (skills[parsed.data.name]) {
|
||||||
|
log.warn("duplicate skill name", {
|
||||||
|
name: parsed.data.name,
|
||||||
|
existing: skills[parsed.data.name].location,
|
||||||
|
duplicate: match,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
skillDirs.add(path.dirname(match))
|
||||||
|
|
||||||
|
skills[parsed.data.name] = {
|
||||||
|
name: parsed.data.name,
|
||||||
|
description: parsed.data.description,
|
||||||
|
location: match,
|
||||||
|
content: md.content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scanExternal = async (root: string, scope: "global" | "project") => {
|
||||||
|
return Glob.scan(EXTERNAL_SKILL_PATTERN, {
|
||||||
|
cwd: root,
|
||||||
|
absolute: true,
|
||||||
|
include: "file",
|
||||||
|
dot: true,
|
||||||
|
symlink: true,
|
||||||
|
})
|
||||||
|
.then((matches) => Promise.all(matches.map(addSkill)))
|
||||||
|
.catch((error) => {
|
||||||
|
log.error(`failed to scan ${scope} skills`, { dir: root, error })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureScanned() {
|
||||||
|
if (task) return task
|
||||||
|
task = (async () => {
|
||||||
|
// Scan external skill directories (.claude/skills/, .agents/skills/, etc.)
|
||||||
|
// Load global (home) first, then project-level (so project-level overwrites)
|
||||||
|
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
||||||
|
for (const dir of EXTERNAL_DIRS) {
|
||||||
|
const root = path.join(Global.Path.home, dir)
|
||||||
|
if (!(await Filesystem.isDir(root))) continue
|
||||||
|
await scanExternal(root, "global")
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (const root of Filesystem.up({
|
||||||
|
targets: EXTERNAL_DIRS,
|
||||||
|
start: instance.directory,
|
||||||
|
stop: instance.project.worktree,
|
||||||
|
})) {
|
||||||
|
await scanExternal(root, "project")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan .opencode/skill/ directories
|
||||||
|
for (const dir of await Config.directories()) {
|
||||||
|
const matches = await Glob.scan(OPENCODE_SKILL_PATTERN, {
|
||||||
|
cwd: dir,
|
||||||
|
absolute: true,
|
||||||
|
include: "file",
|
||||||
|
symlink: true,
|
||||||
|
})
|
||||||
|
for (const match of matches) {
|
||||||
|
await addSkill(match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan additional skill paths from config
|
||||||
|
const config = await Config.get()
|
||||||
|
for (const skillPath of config.skills?.paths ?? []) {
|
||||||
|
const expanded = skillPath.startsWith("~/") ? path.join(os.homedir(), skillPath.slice(2)) : skillPath
|
||||||
|
const resolved = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
|
||||||
|
if (!(await Filesystem.isDir(resolved))) {
|
||||||
|
log.warn("skill path not found", { path: resolved })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const matches = await Glob.scan(SKILL_PATTERN, {
|
||||||
|
cwd: resolved,
|
||||||
|
absolute: true,
|
||||||
|
include: "file",
|
||||||
|
symlink: true,
|
||||||
|
})
|
||||||
|
for (const match of matches) {
|
||||||
|
await addSkill(match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download and load skills from URLs
|
||||||
|
for (const url of config.skills?.urls ?? []) {
|
||||||
|
const list = await Effect.runPromise(discovery.pull(url))
|
||||||
|
for (const dir of list) {
|
||||||
|
skillDirs.add(dir)
|
||||||
|
const matches = await Glob.scan(SKILL_PATTERN, {
|
||||||
|
cwd: dir,
|
||||||
|
absolute: true,
|
||||||
|
include: "file",
|
||||||
|
symlink: true,
|
||||||
|
})
|
||||||
|
for (const match of matches) {
|
||||||
|
await addSkill(match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("init", { count: Object.keys(skills).length })
|
||||||
|
})().catch((err) => {
|
||||||
|
task = undefined
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
return SkillService.of({
|
||||||
|
get: Effect.fn("SkillService.get")(function* (name: string) {
|
||||||
|
yield* Effect.promise(() => ensureScanned())
|
||||||
|
return skills[name]
|
||||||
|
}),
|
||||||
|
all: Effect.fn("SkillService.all")(function* () {
|
||||||
|
yield* Effect.promise(() => ensureScanned())
|
||||||
|
return Object.values(skills)
|
||||||
|
}),
|
||||||
|
dirs: Effect.fn("SkillService.dirs")(function* () {
|
||||||
|
yield* Effect.promise(() => ensureScanned())
|
||||||
|
return Array.from(skillDirs)
|
||||||
|
}),
|
||||||
|
available: Effect.fn("SkillService.available")(function* (agent?: Agent.Info) {
|
||||||
|
yield* Effect.promise(() => ensureScanned())
|
||||||
|
const list = Object.values(skills)
|
||||||
|
if (!agent) return list
|
||||||
|
return list.filter(
|
||||||
|
(skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny",
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
).pipe(Layer.provide(DiscoveryService.defaultLayer))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,21 +1,257 @@
|
|||||||
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
|
|
||||||
import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap, Stream } from "effect"
|
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import z from "zod"
|
import fs from "fs/promises"
|
||||||
import { InstanceContext } from "@/effect/instance-context"
|
import { Filesystem } from "../util/filesystem"
|
||||||
import { runPromiseInstance } from "@/effect/runtime"
|
|
||||||
import { Config } from "../config/config"
|
|
||||||
import { Global } from "../global"
|
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
import { Flag } from "../flag/flag"
|
||||||
|
import { Global } from "../global"
|
||||||
|
import z from "zod"
|
||||||
|
import { Config } from "../config/config"
|
||||||
|
import { Instance } from "../project/instance"
|
||||||
|
import { Scheduler } from "../scheduler"
|
||||||
|
import { Process } from "@/util/process"
|
||||||
|
|
||||||
export namespace Snapshot {
|
export namespace Snapshot {
|
||||||
|
const log = Log.create({ service: "snapshot" })
|
||||||
|
const hour = 60 * 60 * 1000
|
||||||
|
const prune = "7.days"
|
||||||
|
|
||||||
|
function args(git: string, cmd: string[]) {
|
||||||
|
return ["--git-dir", git, "--work-tree", Instance.worktree, ...cmd]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function init() {
|
||||||
|
Scheduler.register({
|
||||||
|
id: "snapshot.cleanup",
|
||||||
|
interval: hour,
|
||||||
|
run: cleanup,
|
||||||
|
scope: "instance",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanup() {
|
||||||
|
if (Instance.project.vcs !== "git") return
|
||||||
|
const cfg = await Config.get()
|
||||||
|
if (cfg.snapshot === false) return
|
||||||
|
const git = gitdir()
|
||||||
|
const exists = await fs
|
||||||
|
.stat(git)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (!exists) return
|
||||||
|
const result = await Process.run(["git", ...args(git, ["gc", `--prune=${prune}`])], {
|
||||||
|
cwd: Instance.directory,
|
||||||
|
nothrow: true,
|
||||||
|
})
|
||||||
|
if (result.code !== 0) {
|
||||||
|
log.warn("cleanup failed", {
|
||||||
|
exitCode: result.code,
|
||||||
|
stderr: result.stderr.toString(),
|
||||||
|
stdout: result.stdout.toString(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.info("cleanup", { prune })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function track() {
|
||||||
|
if (Instance.project.vcs !== "git") return
|
||||||
|
const cfg = await Config.get()
|
||||||
|
if (cfg.snapshot === false) return
|
||||||
|
const git = gitdir()
|
||||||
|
if (await fs.mkdir(git, { recursive: true })) {
|
||||||
|
await Process.run(["git", "init"], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GIT_DIR: git,
|
||||||
|
GIT_WORK_TREE: Instance.worktree,
|
||||||
|
},
|
||||||
|
nothrow: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Configure git to not convert line endings on Windows
|
||||||
|
await Process.run(["git", "--git-dir", git, "config", "core.autocrlf", "false"], { nothrow: true })
|
||||||
|
await Process.run(["git", "--git-dir", git, "config", "core.longpaths", "true"], { nothrow: true })
|
||||||
|
await Process.run(["git", "--git-dir", git, "config", "core.symlinks", "true"], { nothrow: true })
|
||||||
|
await Process.run(["git", "--git-dir", git, "config", "core.fsmonitor", "false"], { nothrow: true })
|
||||||
|
log.info("initialized")
|
||||||
|
}
|
||||||
|
await add(git)
|
||||||
|
const hash = await Process.text(["git", ...args(git, ["write-tree"])], {
|
||||||
|
cwd: Instance.directory,
|
||||||
|
nothrow: true,
|
||||||
|
}).then((x) => x.text)
|
||||||
|
log.info("tracking", { hash, cwd: Instance.directory, git })
|
||||||
|
return hash.trim()
|
||||||
|
}
|
||||||
|
|
||||||
export const Patch = z.object({
|
export const Patch = z.object({
|
||||||
hash: z.string(),
|
hash: z.string(),
|
||||||
files: z.string().array(),
|
files: z.string().array(),
|
||||||
})
|
})
|
||||||
export type Patch = z.infer<typeof Patch>
|
export type Patch = z.infer<typeof Patch>
|
||||||
|
|
||||||
|
export async function patch(hash: string): Promise<Patch> {
|
||||||
|
const git = gitdir()
|
||||||
|
await add(git)
|
||||||
|
const result = await Process.text(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"-c",
|
||||||
|
"core.autocrlf=false",
|
||||||
|
"-c",
|
||||||
|
"core.longpaths=true",
|
||||||
|
"-c",
|
||||||
|
"core.symlinks=true",
|
||||||
|
"-c",
|
||||||
|
"core.quotepath=false",
|
||||||
|
...args(git, ["diff", "--no-ext-diff", "--name-only", hash, "--", "."]),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: Instance.directory,
|
||||||
|
nothrow: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// If git diff fails, return empty patch
|
||||||
|
if (result.code !== 0) {
|
||||||
|
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||||
|
return { hash, files: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = result.text
|
||||||
|
return {
|
||||||
|
hash,
|
||||||
|
files: files
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((x) => x.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((x) => path.join(Instance.worktree, x).replaceAll("\\", "/")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restore(snapshot: string) {
|
||||||
|
log.info("restore", { commit: snapshot })
|
||||||
|
const git = gitdir()
|
||||||
|
const result = await Process.run(
|
||||||
|
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["read-tree", snapshot])],
|
||||||
|
{
|
||||||
|
cwd: Instance.worktree,
|
||||||
|
nothrow: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (result.code === 0) {
|
||||||
|
const checkout = await Process.run(
|
||||||
|
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["checkout-index", "-a", "-f"])],
|
||||||
|
{
|
||||||
|
cwd: Instance.worktree,
|
||||||
|
nothrow: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (checkout.code === 0) return
|
||||||
|
log.error("failed to restore snapshot", {
|
||||||
|
snapshot,
|
||||||
|
exitCode: checkout.code,
|
||||||
|
stderr: checkout.stderr.toString(),
|
||||||
|
stdout: checkout.stdout.toString(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.error("failed to restore snapshot", {
|
||||||
|
snapshot,
|
||||||
|
exitCode: result.code,
|
||||||
|
stderr: result.stderr.toString(),
|
||||||
|
stdout: result.stdout.toString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revert(patches: Patch[]) {
|
||||||
|
const files = new Set<string>()
|
||||||
|
const git = gitdir()
|
||||||
|
for (const item of patches) {
|
||||||
|
for (const file of item.files) {
|
||||||
|
if (files.has(file)) continue
|
||||||
|
log.info("reverting", { file, hash: item.hash })
|
||||||
|
const result = await Process.run(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"-c",
|
||||||
|
"core.longpaths=true",
|
||||||
|
"-c",
|
||||||
|
"core.symlinks=true",
|
||||||
|
...args(git, ["checkout", item.hash, "--", file]),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: Instance.worktree,
|
||||||
|
nothrow: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (result.code !== 0) {
|
||||||
|
const relativePath = path.relative(Instance.worktree, file)
|
||||||
|
const checkTree = await Process.text(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"-c",
|
||||||
|
"core.longpaths=true",
|
||||||
|
"-c",
|
||||||
|
"core.symlinks=true",
|
||||||
|
...args(git, ["ls-tree", item.hash, "--", relativePath]),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: Instance.worktree,
|
||||||
|
nothrow: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (checkTree.code === 0 && checkTree.text.trim()) {
|
||||||
|
log.info("file existed in snapshot but checkout failed, keeping", {
|
||||||
|
file,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
log.info("file did not exist in snapshot, deleting", { file })
|
||||||
|
await fs.unlink(file).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files.add(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function diff(hash: string) {
|
||||||
|
const git = gitdir()
|
||||||
|
await add(git)
|
||||||
|
const result = await Process.text(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"-c",
|
||||||
|
"core.autocrlf=false",
|
||||||
|
"-c",
|
||||||
|
"core.longpaths=true",
|
||||||
|
"-c",
|
||||||
|
"core.symlinks=true",
|
||||||
|
"-c",
|
||||||
|
"core.quotepath=false",
|
||||||
|
...args(git, ["diff", "--no-ext-diff", hash, "--", "."]),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: Instance.worktree,
|
||||||
|
nothrow: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.code !== 0) {
|
||||||
|
log.warn("failed to get diff", {
|
||||||
|
hash,
|
||||||
|
exitCode: result.code,
|
||||||
|
stderr: result.stderr.toString(),
|
||||||
|
stdout: result.stdout.toString(),
|
||||||
|
})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.text.trim()
|
||||||
|
}
|
||||||
|
|
||||||
export const FileDiff = z
|
export const FileDiff = z
|
||||||
.object({
|
.object({
|
||||||
file: z.string(),
|
file: z.string(),
|
||||||
@@ -29,321 +265,152 @@ export namespace Snapshot {
|
|||||||
ref: "FileDiff",
|
ref: "FileDiff",
|
||||||
})
|
})
|
||||||
export type FileDiff = z.infer<typeof FileDiff>
|
export type FileDiff = z.infer<typeof FileDiff>
|
||||||
|
export async function diffFull(from: string, to: string): Promise<FileDiff[]> {
|
||||||
|
const git = gitdir()
|
||||||
|
const result: FileDiff[] = []
|
||||||
|
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||||
|
|
||||||
export async function cleanup() {
|
const statuses = await Process.text(
|
||||||
return runPromiseInstance(Service.use((svc) => svc.cleanup()))
|
[
|
||||||
|
"git",
|
||||||
|
"-c",
|
||||||
|
"core.autocrlf=false",
|
||||||
|
"-c",
|
||||||
|
"core.longpaths=true",
|
||||||
|
"-c",
|
||||||
|
"core.symlinks=true",
|
||||||
|
"-c",
|
||||||
|
"core.quotepath=false",
|
||||||
|
...args(git, ["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: Instance.directory,
|
||||||
|
nothrow: true,
|
||||||
|
},
|
||||||
|
).then((x) => x.text)
|
||||||
|
|
||||||
|
for (const line of statuses.trim().split("\n")) {
|
||||||
|
if (!line) continue
|
||||||
|
const [code, file] = line.split("\t")
|
||||||
|
if (!code || !file) continue
|
||||||
|
const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified"
|
||||||
|
status.set(file, kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of await Process.lines(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"-c",
|
||||||
|
"core.autocrlf=false",
|
||||||
|
"-c",
|
||||||
|
"core.longpaths=true",
|
||||||
|
"-c",
|
||||||
|
"core.symlinks=true",
|
||||||
|
"-c",
|
||||||
|
"core.quotepath=false",
|
||||||
|
...args(git, ["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."]),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: Instance.directory,
|
||||||
|
nothrow: true,
|
||||||
|
},
|
||||||
|
)) {
|
||||||
|
if (!line) continue
|
||||||
|
const [additions, deletions, file] = line.split("\t")
|
||||||
|
const isBinaryFile = additions === "-" && deletions === "-"
|
||||||
|
const before = isBinaryFile
|
||||||
|
? ""
|
||||||
|
: await Process.text(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"-c",
|
||||||
|
"core.autocrlf=false",
|
||||||
|
"-c",
|
||||||
|
"core.longpaths=true",
|
||||||
|
"-c",
|
||||||
|
"core.symlinks=true",
|
||||||
|
...args(git, ["show", `${from}:${file}`]),
|
||||||
|
],
|
||||||
|
{ nothrow: true },
|
||||||
|
).then((x) => x.text)
|
||||||
|
const after = isBinaryFile
|
||||||
|
? ""
|
||||||
|
: await Process.text(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"-c",
|
||||||
|
"core.autocrlf=false",
|
||||||
|
"-c",
|
||||||
|
"core.longpaths=true",
|
||||||
|
"-c",
|
||||||
|
"core.symlinks=true",
|
||||||
|
...args(git, ["show", `${to}:${file}`]),
|
||||||
|
],
|
||||||
|
{ nothrow: true },
|
||||||
|
).then((x) => x.text)
|
||||||
|
const added = isBinaryFile ? 0 : parseInt(additions)
|
||||||
|
const deleted = isBinaryFile ? 0 : parseInt(deletions)
|
||||||
|
result.push({
|
||||||
|
file,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
additions: Number.isFinite(added) ? added : 0,
|
||||||
|
deletions: Number.isFinite(deleted) ? deleted : 0,
|
||||||
|
status: status.get(file) ?? "modified",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function track() {
|
function gitdir() {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.track()))
|
const project = Instance.project
|
||||||
|
return path.join(Global.Path.data, "snapshot", project.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function patch(hash: string) {
|
async function add(git: string) {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.patch(hash)))
|
await syncExclude(git)
|
||||||
|
await Process.run(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"-c",
|
||||||
|
"core.autocrlf=false",
|
||||||
|
"-c",
|
||||||
|
"core.longpaths=true",
|
||||||
|
"-c",
|
||||||
|
"core.symlinks=true",
|
||||||
|
...args(git, ["add", "."]),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: Instance.directory,
|
||||||
|
nothrow: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restore(snapshot: string) {
|
async function syncExclude(git: string) {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.restore(snapshot)))
|
const file = await excludes()
|
||||||
|
const target = path.join(git, "info", "exclude")
|
||||||
|
await fs.mkdir(path.join(git, "info"), { recursive: true })
|
||||||
|
if (!file) {
|
||||||
|
await Filesystem.write(target, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = await Filesystem.readText(file).catch(() => "")
|
||||||
|
|
||||||
|
await Filesystem.write(target, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function revert(patches: Patch[]) {
|
async function excludes() {
|
||||||
return runPromiseInstance(Service.use((svc) => svc.revert(patches)))
|
const file = await Process.text(["git", "rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
|
||||||
|
cwd: Instance.worktree,
|
||||||
|
nothrow: true,
|
||||||
|
}).then((x) => x.text)
|
||||||
|
if (!file.trim()) return
|
||||||
|
const exists = await fs
|
||||||
|
.stat(file.trim())
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (!exists) return
|
||||||
|
return file.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function diff(hash: string) {
|
|
||||||
return runPromiseInstance(Service.use((svc) => svc.diff(hash)))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function diffFull(from: string, to: string) {
|
|
||||||
return runPromiseInstance(Service.use((svc) => svc.diffFull(from, to)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const log = Log.create({ service: "snapshot" })
|
|
||||||
const prune = "7.days"
|
|
||||||
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
|
|
||||||
const cfg = ["-c", "core.autocrlf=false", ...core]
|
|
||||||
const quote = [...cfg, "-c", "core.quotepath=false"]
|
|
||||||
|
|
||||||
interface GitResult {
|
|
||||||
readonly code: ChildProcessSpawner.ExitCode
|
|
||||||
readonly text: string
|
|
||||||
readonly stderr: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly cleanup: () => Effect.Effect<void>
|
|
||||||
readonly track: () => Effect.Effect<string | undefined>
|
|
||||||
readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
|
|
||||||
readonly restore: (snapshot: string) => Effect.Effect<void>
|
|
||||||
readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>
|
|
||||||
readonly diff: (hash: string) => Effect.Effect<string>
|
|
||||||
readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
|
|
||||||
|
|
||||||
export const layer: Layer.Layer<
|
|
||||||
Service,
|
|
||||||
never,
|
|
||||||
InstanceContext | FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner
|
|
||||||
> = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const ctx = yield* InstanceContext
|
|
||||||
const fs = yield* FileSystem.FileSystem
|
|
||||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
||||||
const directory = ctx.directory
|
|
||||||
const worktree = ctx.worktree
|
|
||||||
const project = ctx.project
|
|
||||||
const gitdir = path.join(Global.Path.data, "snapshot", project.id)
|
|
||||||
|
|
||||||
const args = (cmd: string[]) => ["--git-dir", gitdir, "--work-tree", worktree, ...cmd]
|
|
||||||
|
|
||||||
const git = Effect.fnUntraced(
|
|
||||||
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
|
|
||||||
const proc = ChildProcess.make("git", cmd, {
|
|
||||||
cwd: opts?.cwd,
|
|
||||||
env: opts?.env,
|
|
||||||
extendEnv: true,
|
|
||||||
})
|
|
||||||
const handle = yield* spawner.spawn(proc)
|
|
||||||
const [text, stderr] = yield* Effect.all(
|
|
||||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
|
||||||
{ concurrency: 2 },
|
|
||||||
)
|
|
||||||
const code = yield* handle.exitCode
|
|
||||||
return { code, text, stderr } satisfies GitResult
|
|
||||||
},
|
|
||||||
Effect.scoped,
|
|
||||||
Effect.catch((err) =>
|
|
||||||
Effect.succeed({
|
|
||||||
code: ChildProcessSpawner.ExitCode(1),
|
|
||||||
text: "",
|
|
||||||
stderr: String(err),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
|
|
||||||
const mkdir = (dir: string) => fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
|
|
||||||
const write = (file: string, text: string) => fs.writeFileString(file, text).pipe(Effect.orDie)
|
|
||||||
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
|
|
||||||
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
|
|
||||||
|
|
||||||
const enabled = Effect.fnUntraced(function* () {
|
|
||||||
if (project.vcs !== "git") return false
|
|
||||||
return (yield* Effect.promise(() => Config.get())).snapshot !== false
|
|
||||||
})
|
|
||||||
|
|
||||||
const excludes = Effect.fnUntraced(function* () {
|
|
||||||
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
|
|
||||||
cwd: worktree,
|
|
||||||
})
|
|
||||||
const file = result.text.trim()
|
|
||||||
if (!file) return
|
|
||||||
if (!(yield* exists(file))) return
|
|
||||||
return file
|
|
||||||
})
|
|
||||||
|
|
||||||
const sync = Effect.fnUntraced(function* () {
|
|
||||||
const file = yield* excludes()
|
|
||||||
const target = path.join(gitdir, "info", "exclude")
|
|
||||||
yield* mkdir(path.join(gitdir, "info"))
|
|
||||||
if (!file) {
|
|
||||||
yield* write(target, "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
yield* write(target, yield* read(file))
|
|
||||||
})
|
|
||||||
|
|
||||||
const add = Effect.fnUntraced(function* () {
|
|
||||||
yield* sync()
|
|
||||||
yield* git([...cfg, ...args(["add", "."])], { cwd: directory })
|
|
||||||
})
|
|
||||||
|
|
||||||
const cleanup = Effect.fn("Snapshot.cleanup")(function* () {
|
|
||||||
if (!(yield* enabled())) return
|
|
||||||
if (!(yield* exists(gitdir))) return
|
|
||||||
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: directory })
|
|
||||||
if (result.code !== 0) {
|
|
||||||
log.warn("cleanup failed", {
|
|
||||||
exitCode: result.code,
|
|
||||||
stderr: result.stderr,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.info("cleanup", { prune })
|
|
||||||
})
|
|
||||||
|
|
||||||
const track = Effect.fn("Snapshot.track")(function* () {
|
|
||||||
if (!(yield* enabled())) return
|
|
||||||
const existed = yield* exists(gitdir)
|
|
||||||
yield* mkdir(gitdir)
|
|
||||||
if (!existed) {
|
|
||||||
yield* git(["init"], {
|
|
||||||
env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree },
|
|
||||||
})
|
|
||||||
yield* git(["--git-dir", gitdir, "config", "core.autocrlf", "false"])
|
|
||||||
yield* git(["--git-dir", gitdir, "config", "core.longpaths", "true"])
|
|
||||||
yield* git(["--git-dir", gitdir, "config", "core.symlinks", "true"])
|
|
||||||
yield* git(["--git-dir", gitdir, "config", "core.fsmonitor", "false"])
|
|
||||||
log.info("initialized")
|
|
||||||
}
|
|
||||||
yield* add()
|
|
||||||
const result = yield* git(args(["write-tree"]), { cwd: directory })
|
|
||||||
const hash = result.text.trim()
|
|
||||||
log.info("tracking", { hash, cwd: directory, git: gitdir })
|
|
||||||
return hash
|
|
||||||
})
|
|
||||||
|
|
||||||
const patch = Effect.fn("Snapshot.patch")(function* (hash: string) {
|
|
||||||
yield* add()
|
|
||||||
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], {
|
|
||||||
cwd: directory,
|
|
||||||
})
|
|
||||||
if (result.code !== 0) {
|
|
||||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
|
||||||
return { hash, files: [] }
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
hash,
|
|
||||||
files: result.text
|
|
||||||
.trim()
|
|
||||||
.split("\n")
|
|
||||||
.map((x) => x.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((x) => path.join(worktree, x).replaceAll("\\", "/")),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const restore = Effect.fn("Snapshot.restore")(function* (snapshot: string) {
|
|
||||||
log.info("restore", { commit: snapshot })
|
|
||||||
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: worktree })
|
|
||||||
if (result.code === 0) {
|
|
||||||
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: worktree })
|
|
||||||
if (checkout.code === 0) return
|
|
||||||
log.error("failed to restore snapshot", {
|
|
||||||
snapshot,
|
|
||||||
exitCode: checkout.code,
|
|
||||||
stderr: checkout.stderr,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.error("failed to restore snapshot", {
|
|
||||||
snapshot,
|
|
||||||
exitCode: result.code,
|
|
||||||
stderr: result.stderr,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const revert = Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
|
|
||||||
const map = new Map(patches.flatMap((patch) => patch.files.map((file) => [file, patch] as const)))
|
|
||||||
const seen = new Set<string>()
|
|
||||||
for (const file of patches.flatMap((patch) => patch.files)) {
|
|
||||||
if (seen.has(file)) continue
|
|
||||||
const patch = map.get(file)
|
|
||||||
if (!patch) continue
|
|
||||||
log.info("reverting", { file, hash: patch.hash })
|
|
||||||
const result = yield* git([...core, ...args(["checkout", patch.hash, "--", file])], { cwd: worktree })
|
|
||||||
if (result.code !== 0) {
|
|
||||||
const rel = path.relative(worktree, file)
|
|
||||||
const tree = yield* git([...core, ...args(["ls-tree", patch.hash, "--", rel])], { cwd: worktree })
|
|
||||||
if (tree.code === 0 && tree.text.trim()) {
|
|
||||||
log.info("file existed in snapshot but checkout failed, keeping", { file })
|
|
||||||
} else {
|
|
||||||
log.info("file did not exist in snapshot, deleting", { file })
|
|
||||||
yield* remove(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
seen.add(file)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const diff = Effect.fn("Snapshot.diff")(function* (hash: string) {
|
|
||||||
yield* add()
|
|
||||||
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
|
|
||||||
cwd: worktree,
|
|
||||||
})
|
|
||||||
if (result.code !== 0) {
|
|
||||||
log.warn("failed to get diff", {
|
|
||||||
hash,
|
|
||||||
exitCode: result.code,
|
|
||||||
stderr: result.stderr,
|
|
||||||
})
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return result.text.trim()
|
|
||||||
})
|
|
||||||
|
|
||||||
const diffFull = Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
|
|
||||||
const result: Snapshot.FileDiff[] = []
|
|
||||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
|
||||||
|
|
||||||
const statuses = yield* git(
|
|
||||||
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
|
|
||||||
{ cwd: directory },
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const line of statuses.text.trim().split("\n")) {
|
|
||||||
if (!line) continue
|
|
||||||
const [code, file] = line.split("\t")
|
|
||||||
if (!code || !file) continue
|
|
||||||
status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
|
|
||||||
}
|
|
||||||
|
|
||||||
const numstat = yield* git(
|
|
||||||
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
|
|
||||||
{
|
|
||||||
cwd: directory,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const line of numstat.text.trim().split("\n")) {
|
|
||||||
if (!line) continue
|
|
||||||
const [adds, dels, file] = line.split("\t")
|
|
||||||
if (!file) continue
|
|
||||||
const binary = adds === "-" && dels === "-"
|
|
||||||
const [before, after] = binary
|
|
||||||
? ["", ""]
|
|
||||||
: yield* Effect.all(
|
|
||||||
[
|
|
||||||
git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
|
||||||
git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
|
||||||
],
|
|
||||||
{ concurrency: 2 },
|
|
||||||
)
|
|
||||||
const additions = binary ? 0 : parseInt(adds)
|
|
||||||
const deletions = binary ? 0 : parseInt(dels)
|
|
||||||
result.push({
|
|
||||||
file,
|
|
||||||
before,
|
|
||||||
after,
|
|
||||||
additions: Number.isFinite(additions) ? additions : 0,
|
|
||||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
|
||||||
status: status.get(file) ?? "modified",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* cleanup().pipe(
|
|
||||||
Effect.catchCause((cause) => {
|
|
||||||
log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
|
|
||||||
return Effect.void
|
|
||||||
}),
|
|
||||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
|
||||||
Effect.delay(Duration.minutes(1)),
|
|
||||||
Effect.forkScoped,
|
|
||||||
)
|
|
||||||
|
|
||||||
return Service.of({ cleanup, track, patch, restore, revert, diff, diffFull })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
|
||||||
Layer.provide(NodeChildProcessSpawner.layer),
|
|
||||||
Layer.provide(NodeFileSystem.layer),
|
|
||||||
Layer.provide(NodePath.layer),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { Flag } from "@/flag/flag.ts"
|
|||||||
import { Shell } from "@/shell/shell"
|
import { Shell } from "@/shell/shell"
|
||||||
|
|
||||||
import { BashArity } from "@/permission/arity"
|
import { BashArity } from "@/permission/arity"
|
||||||
import { Truncate } from "./truncate"
|
import { Truncate } from "./truncation"
|
||||||
import { Plugin } from "@/plugin"
|
import { Plugin } from "@/plugin"
|
||||||
|
|
||||||
const MAX_METADATA_LENGTH = 30_000
|
const MAX_METADATA_LENGTH = 30_000
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { CodeSearchTool } from "./codesearch"
|
|||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import { LspTool } from "./lsp"
|
import { LspTool } from "./lsp"
|
||||||
import { Truncate } from "./truncate"
|
import { Truncate } from "./truncation"
|
||||||
|
|
||||||
import { ApplyPatchTool } from "./apply_patch"
|
import { ApplyPatchTool } from "./apply_patch"
|
||||||
import { Glob } from "../util/glob"
|
import { Glob } from "../util/glob"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { SessionPrompt } from "../session/prompt"
|
|||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
import { defer } from "@/util/defer"
|
import { defer } from "@/util/defer"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { PermissionNext } from "@/permission"
|
import { PermissionNext } from "@/permission/next"
|
||||||
|
|
||||||
const parameters = z.object({
|
const parameters = z.object({
|
||||||
description: z.string().describe("A short (3-5 words) description of the task"),
|
description: z.string().describe("A short (3-5 words) description of the task"),
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import type { MessageV2 } from "../session/message-v2"
|
import type { MessageV2 } from "../session/message-v2"
|
||||||
import type { Agent } from "../agent/agent"
|
import type { Agent } from "../agent/agent"
|
||||||
import type { PermissionNext } from "../permission"
|
import type { PermissionNext } from "../permission/next"
|
||||||
import type { SessionID, MessageID } from "../session/schema"
|
import type { SessionID, MessageID } from "../session/schema"
|
||||||
import { Truncate } from "./truncate"
|
import { Truncate } from "./truncation"
|
||||||
|
|
||||||
export namespace Tool {
|
export namespace Tool {
|
||||||
interface Metadata {
|
interface Metadata {
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
|
||||||
import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap } from "effect"
|
|
||||||
import path from "path"
|
|
||||||
import type { Agent } from "../agent/agent"
|
|
||||||
import { PermissionEffect } from "../permission/effect"
|
|
||||||
import { Identifier } from "../id/id"
|
|
||||||
import { Log } from "../util/log"
|
|
||||||
import { ToolID } from "./schema"
|
|
||||||
import { TRUNCATION_DIR } from "./truncation-dir"
|
|
||||||
|
|
||||||
export namespace TruncateEffect {
|
|
||||||
const log = Log.create({ service: "truncation" })
|
|
||||||
const RETENTION = Duration.days(7)
|
|
||||||
|
|
||||||
export const MAX_LINES = 2000
|
|
||||||
export const MAX_BYTES = 50 * 1024
|
|
||||||
export const DIR = TRUNCATION_DIR
|
|
||||||
export const GLOB = path.join(TRUNCATION_DIR, "*")
|
|
||||||
|
|
||||||
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
|
|
||||||
|
|
||||||
export interface Options {
|
|
||||||
maxLines?: number
|
|
||||||
maxBytes?: number
|
|
||||||
direction?: "head" | "tail"
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasTaskTool(agent?: Agent.Info) {
|
|
||||||
if (!agent?.permission) return false
|
|
||||||
return PermissionEffect.evaluate("task", "*", agent.permission).action !== "deny"
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly cleanup: () => Effect.Effect<void>
|
|
||||||
/**
|
|
||||||
* Returns output unchanged when it fits within the limits, otherwise writes the full text
|
|
||||||
* to the truncation directory and returns a preview plus a hint to inspect the saved file.
|
|
||||||
*/
|
|
||||||
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Truncate") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const fs = yield* FileSystem.FileSystem
|
|
||||||
|
|
||||||
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
|
|
||||||
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
|
|
||||||
const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
|
|
||||||
Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
|
|
||||||
Effect.catch(() => Effect.succeed([])),
|
|
||||||
)
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (Identifier.timestamp(entry) >= cutoff) continue
|
|
||||||
yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const output = Effect.fn("Truncate.output")(function* (
|
|
||||||
text: string,
|
|
||||||
options: Options = {},
|
|
||||||
agent?: Agent.Info,
|
|
||||||
) {
|
|
||||||
const maxLines = options.maxLines ?? MAX_LINES
|
|
||||||
const maxBytes = options.maxBytes ?? MAX_BYTES
|
|
||||||
const direction = options.direction ?? "head"
|
|
||||||
const lines = text.split("\n")
|
|
||||||
const totalBytes = Buffer.byteLength(text, "utf-8")
|
|
||||||
|
|
||||||
if (lines.length <= maxLines && totalBytes <= maxBytes) {
|
|
||||||
return { content: text, truncated: false } as const
|
|
||||||
}
|
|
||||||
|
|
||||||
const out: string[] = []
|
|
||||||
let i = 0
|
|
||||||
let bytes = 0
|
|
||||||
let hitBytes = false
|
|
||||||
|
|
||||||
if (direction === "head") {
|
|
||||||
for (i = 0; i < lines.length && i < maxLines; i++) {
|
|
||||||
const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
|
|
||||||
if (bytes + size > maxBytes) {
|
|
||||||
hitBytes = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
out.push(lines[i])
|
|
||||||
bytes += size
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
|
|
||||||
const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
|
|
||||||
if (bytes + size > maxBytes) {
|
|
||||||
hitBytes = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
out.unshift(lines[i])
|
|
||||||
bytes += size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
|
|
||||||
const unit = hitBytes ? "bytes" : "lines"
|
|
||||||
const preview = out.join("\n")
|
|
||||||
const file = path.join(TRUNCATION_DIR, ToolID.ascending())
|
|
||||||
|
|
||||||
yield* fs.makeDirectory(TRUNCATION_DIR, { recursive: true }).pipe(Effect.orDie)
|
|
||||||
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
|
|
||||||
|
|
||||||
const hint = hasTaskTool(agent)
|
|
||||||
? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
|
|
||||||
: `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
|
|
||||||
|
|
||||||
return {
|
|
||||||
content:
|
|
||||||
direction === "head"
|
|
||||||
? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
|
|
||||||
: `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
|
|
||||||
truncated: true,
|
|
||||||
outputPath: file,
|
|
||||||
} as const
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* cleanup().pipe(
|
|
||||||
Effect.catchCause((cause) => {
|
|
||||||
log.error("truncation cleanup failed", { cause: Cause.pretty(cause) })
|
|
||||||
return Effect.void
|
|
||||||
}),
|
|
||||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
|
||||||
Effect.delay(Duration.minutes(1)),
|
|
||||||
Effect.forkScoped,
|
|
||||||
)
|
|
||||||
|
|
||||||
return Service.of({ cleanup, output })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import type { Agent } from "../agent/agent"
|
|
||||||
import { runtime } from "@/effect/runtime"
|
|
||||||
import { TruncateEffect as S } from "./truncate-effect"
|
|
||||||
|
|
||||||
|
|
||||||
export namespace Truncate {
|
|
||||||
export const MAX_LINES = S.MAX_LINES
|
|
||||||
export const MAX_BYTES = S.MAX_BYTES
|
|
||||||
export const DIR = S.DIR
|
|
||||||
export const GLOB = S.GLOB
|
|
||||||
|
|
||||||
export type Result = S.Result
|
|
||||||
|
|
||||||
export type Options = S.Options
|
|
||||||
|
|
||||||
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
|
|
||||||
return runtime.runPromise(S.Service.use((s) => s.output(text, options, agent)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import path from "path"
|
|
||||||
import { Global } from "../global"
|
|
||||||
|
|
||||||
export const TRUNCATION_DIR = path.join(Global.Path.data, "tool-output")
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { Global } from "../global"
|
||||||
|
import { Identifier } from "../id/id"
|
||||||
|
import { PermissionNext } from "../permission/next"
|
||||||
|
import type { Agent } from "../agent/agent"
|
||||||
|
import { Scheduler } from "../scheduler"
|
||||||
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
import { Glob } from "../util/glob"
|
||||||
|
import { ToolID } from "./schema"
|
||||||
|
|
||||||
|
export namespace Truncate {
|
||||||
|
export const MAX_LINES = 2000
|
||||||
|
export const MAX_BYTES = 50 * 1024
|
||||||
|
export const DIR = path.join(Global.Path.data, "tool-output")
|
||||||
|
export const GLOB = path.join(DIR, "*")
|
||||||
|
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||||
|
const HOUR_MS = 60 * 60 * 1000
|
||||||
|
|
||||||
|
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
|
||||||
|
|
||||||
|
export interface Options {
|
||||||
|
maxLines?: number
|
||||||
|
maxBytes?: number
|
||||||
|
direction?: "head" | "tail"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function init() {
|
||||||
|
Scheduler.register({
|
||||||
|
id: "tool.truncation.cleanup",
|
||||||
|
interval: HOUR_MS,
|
||||||
|
run: cleanup,
|
||||||
|
scope: "global",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanup() {
|
||||||
|
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - RETENTION_MS))
|
||||||
|
const entries = await Glob.scan("tool_*", { cwd: DIR, include: "file" }).catch(() => [] as string[])
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (Identifier.timestamp(entry) >= cutoff) continue
|
||||||
|
await fs.unlink(path.join(DIR, entry)).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTaskTool(agent?: Agent.Info): boolean {
|
||||||
|
if (!agent?.permission) return false
|
||||||
|
const rule = PermissionNext.evaluate("task", "*", agent.permission)
|
||||||
|
return rule.action !== "deny"
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
|
||||||
|
const maxLines = options.maxLines ?? MAX_LINES
|
||||||
|
const maxBytes = options.maxBytes ?? MAX_BYTES
|
||||||
|
const direction = options.direction ?? "head"
|
||||||
|
const lines = text.split("\n")
|
||||||
|
const totalBytes = Buffer.byteLength(text, "utf-8")
|
||||||
|
|
||||||
|
if (lines.length <= maxLines && totalBytes <= maxBytes) {
|
||||||
|
return { content: text, truncated: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: string[] = []
|
||||||
|
let i = 0
|
||||||
|
let bytes = 0
|
||||||
|
let hitBytes = false
|
||||||
|
|
||||||
|
if (direction === "head") {
|
||||||
|
for (i = 0; i < lines.length && i < maxLines; i++) {
|
||||||
|
const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
|
||||||
|
if (bytes + size > maxBytes) {
|
||||||
|
hitBytes = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out.push(lines[i])
|
||||||
|
bytes += size
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
|
||||||
|
const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
|
||||||
|
if (bytes + size > maxBytes) {
|
||||||
|
hitBytes = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out.unshift(lines[i])
|
||||||
|
bytes += size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
|
||||||
|
const unit = hitBytes ? "bytes" : "lines"
|
||||||
|
const preview = out.join("\n")
|
||||||
|
|
||||||
|
const id = ToolID.ascending()
|
||||||
|
const filepath = path.join(DIR, id)
|
||||||
|
await Filesystem.write(filepath, text)
|
||||||
|
|
||||||
|
const hint = hasTaskTool(agent)
|
||||||
|
? `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
|
||||||
|
: `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
|
||||||
|
const message =
|
||||||
|
direction === "head"
|
||||||
|
? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
|
||||||
|
: `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`
|
||||||
|
|
||||||
|
return { content: message, truncated: true, outputPath: filepath }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import { buffer } from "node:stream/consumers"
|
|||||||
|
|
||||||
export namespace Process {
|
export namespace Process {
|
||||||
export type Stdio = "inherit" | "pipe" | "ignore"
|
export type Stdio = "inherit" | "pipe" | "ignore"
|
||||||
export type Shell = boolean | string
|
|
||||||
|
|
||||||
export interface Options {
|
export interface Options {
|
||||||
cwd?: string
|
cwd?: string
|
||||||
@@ -11,7 +10,6 @@ export namespace Process {
|
|||||||
stdin?: Stdio
|
stdin?: Stdio
|
||||||
stdout?: Stdio
|
stdout?: Stdio
|
||||||
stderr?: Stdio
|
stderr?: Stdio
|
||||||
shell?: Shell
|
|
||||||
abort?: AbortSignal
|
abort?: AbortSignal
|
||||||
kill?: NodeJS.Signals | number
|
kill?: NodeJS.Signals | number
|
||||||
timeout?: number
|
timeout?: number
|
||||||
@@ -62,7 +60,6 @@ export namespace Process {
|
|||||||
cwd: opts.cwd,
|
cwd: opts.cwd,
|
||||||
env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
|
env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
|
||||||
stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
|
stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
|
||||||
shell: opts.shell,
|
|
||||||
windowsHide: process.platform === "win32",
|
windowsHide: process.platform === "win32",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Effect, Layer, Option } from "effect"
|
|||||||
import { AccountRepo } from "../../src/account/repo"
|
import { AccountRepo } from "../../src/account/repo"
|
||||||
import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema"
|
import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema"
|
||||||
import { Database } from "../../src/storage/db"
|
import { Database } from "../../src/storage/db"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../fixture/effect"
|
||||||
|
|
||||||
const truncate = Layer.effectDiscard(
|
const truncate = Layer.effectDiscard(
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
@@ -16,21 +16,24 @@ const truncate = Layer.effectDiscard(
|
|||||||
|
|
||||||
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
|
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
|
||||||
|
|
||||||
it.effect("list returns empty when no accounts exist", () =>
|
it.effect(
|
||||||
|
"list returns empty when no accounts exist",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const accounts = yield* AccountRepo.use((r) => r.list())
|
const accounts = yield* AccountRepo.use((r) => r.list())
|
||||||
expect(accounts).toEqual([])
|
expect(accounts).toEqual([])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("active returns none when no accounts exist", () =>
|
it.effect(
|
||||||
|
"active returns none when no accounts exist",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const active = yield* AccountRepo.use((r) => r.active())
|
const active = yield* AccountRepo.use((r) => r.active())
|
||||||
expect(Option.isNone(active)).toBe(true)
|
expect(Option.isNone(active)).toBe(true)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("persistAccount inserts and getRow retrieves", () =>
|
it.effect(
|
||||||
|
"persistAccount inserts and getRow retrieves",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id = AccountID.make("user-1")
|
const id = AccountID.make("user-1")
|
||||||
yield* AccountRepo.use((r) =>
|
yield* AccountRepo.use((r) =>
|
||||||
@@ -56,7 +59,8 @@ it.effect("persistAccount inserts and getRow retrieves", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("persistAccount sets the active account and org", () =>
|
it.effect(
|
||||||
|
"persistAccount sets the active account and org",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id1 = AccountID.make("user-1")
|
const id1 = AccountID.make("user-1")
|
||||||
const id2 = AccountID.make("user-2")
|
const id2 = AccountID.make("user-2")
|
||||||
@@ -93,7 +97,8 @@ it.effect("persistAccount sets the active account and org", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("list returns all accounts", () =>
|
it.effect(
|
||||||
|
"list returns all accounts",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id1 = AccountID.make("user-1")
|
const id1 = AccountID.make("user-1")
|
||||||
const id2 = AccountID.make("user-2")
|
const id2 = AccountID.make("user-2")
|
||||||
@@ -128,7 +133,8 @@ it.effect("list returns all accounts", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("remove deletes an account", () =>
|
it.effect(
|
||||||
|
"remove deletes an account",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id = AccountID.make("user-1")
|
const id = AccountID.make("user-1")
|
||||||
|
|
||||||
@@ -151,7 +157,8 @@ it.effect("remove deletes an account", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("use stores the selected org and marks the account active", () =>
|
it.effect(
|
||||||
|
"use stores the selected org and marks the account active",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id1 = AccountID.make("user-1")
|
const id1 = AccountID.make("user-1")
|
||||||
const id2 = AccountID.make("user-2")
|
const id2 = AccountID.make("user-2")
|
||||||
@@ -191,7 +198,8 @@ it.effect("use stores the selected org and marks the account active", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("persistToken updates token fields", () =>
|
it.effect(
|
||||||
|
"persistToken updates token fields",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id = AccountID.make("user-1")
|
const id = AccountID.make("user-1")
|
||||||
|
|
||||||
@@ -225,7 +233,8 @@ it.effect("persistToken updates token fields", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("persistToken with no expiry sets token_expiry to null", () =>
|
it.effect(
|
||||||
|
"persistToken with no expiry sets token_expiry to null",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id = AccountID.make("user-1")
|
const id = AccountID.make("user-1")
|
||||||
|
|
||||||
@@ -255,7 +264,8 @@ it.effect("persistToken with no expiry sets token_expiry to null", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("persistAccount upserts on conflict", () =>
|
it.effect(
|
||||||
|
"persistAccount upserts on conflict",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id = AccountID.make("user-1")
|
const id = AccountID.make("user-1")
|
||||||
|
|
||||||
@@ -295,7 +305,8 @@ it.effect("persistAccount upserts on conflict", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("remove clears active state when deleting the active account", () =>
|
it.effect(
|
||||||
|
"remove clears active state when deleting the active account",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id = AccountID.make("user-1")
|
const id = AccountID.make("user-1")
|
||||||
|
|
||||||
@@ -318,7 +329,8 @@ it.effect("remove clears active state when deleting the active account", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("getRow returns none for nonexistent account", () =>
|
it.effect(
|
||||||
|
"getRow returns none for nonexistent account",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope")))
|
const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope")))
|
||||||
expect(Option.isNone(row)).toBe(true)
|
expect(Option.isNone(row)).toBe(true)
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { expect } from "bun:test"
|
import { expect } from "bun:test"
|
||||||
import { Duration, Effect, Layer, Option, Schema } from "effect"
|
import { Duration, Effect, Layer, Option, Ref, Schema } from "effect"
|
||||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||||
|
|
||||||
import { AccountRepo } from "../../src/account/repo"
|
import { AccountRepo } from "../../src/account/repo"
|
||||||
import { AccountEffect } from "../../src/account/effect"
|
import { AccountService } from "../../src/account/service"
|
||||||
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
|
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
|
||||||
import { Database } from "../../src/storage/db"
|
import { Database } from "../../src/storage/db"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../fixture/effect"
|
||||||
|
|
||||||
const truncate = Layer.effectDiscard(
|
const truncate = Layer.effectDiscard(
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
@@ -19,7 +19,7 @@ const truncate = Layer.effectDiscard(
|
|||||||
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
|
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
|
||||||
|
|
||||||
const live = (client: HttpClient.HttpClient) =>
|
const live = (client: HttpClient.HttpClient) =>
|
||||||
AccountEffect.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
|
AccountService.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
|
||||||
|
|
||||||
const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
|
const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
|
||||||
HttpClientResponse.fromWeb(
|
HttpClientResponse.fromWeb(
|
||||||
@@ -34,7 +34,8 @@ const encodeOrg = Schema.encodeSync(Org)
|
|||||||
|
|
||||||
const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name }))
|
const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name }))
|
||||||
|
|
||||||
it.effect("orgsByAccount groups orgs per account", () =>
|
it.effect(
|
||||||
|
"orgsByAccount groups orgs per account",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* AccountRepo.use((r) =>
|
yield* AccountRepo.use((r) =>
|
||||||
r.persistAccount({
|
r.persistAccount({
|
||||||
@@ -60,10 +61,10 @@ it.effect("orgsByAccount groups orgs per account", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const seen: Array<string> = []
|
const seen = yield* Ref.make<string[]>([])
|
||||||
const client = HttpClient.make((req) =>
|
const client = HttpClient.make((req) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
seen.push(`${req.method} ${req.url}`)
|
yield* Ref.update(seen, (xs) => [...xs, `${req.method} ${req.url}`])
|
||||||
|
|
||||||
if (req.url === "https://one.example.com/api/orgs") {
|
if (req.url === "https://one.example.com/api/orgs") {
|
||||||
return json(req, [org("org-1", "One")])
|
return json(req, [org("org-1", "One")])
|
||||||
@@ -77,20 +78,21 @@ it.effect("orgsByAccount groups orgs per account", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const rows = yield* AccountEffect.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
|
const rows = yield* AccountService.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
|
||||||
|
|
||||||
expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
|
expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
|
||||||
[AccountID.make("user-1"), [OrgID.make("org-1")]],
|
[AccountID.make("user-1"), [OrgID.make("org-1")]],
|
||||||
[AccountID.make("user-2"), [OrgID.make("org-2"), OrgID.make("org-3")]],
|
[AccountID.make("user-2"), [OrgID.make("org-2"), OrgID.make("org-3")]],
|
||||||
])
|
])
|
||||||
expect(seen).toEqual([
|
expect(yield* Ref.get(seen)).toEqual([
|
||||||
"GET https://one.example.com/api/orgs",
|
"GET https://one.example.com/api/orgs",
|
||||||
"GET https://two.example.com/api/orgs",
|
"GET https://two.example.com/api/orgs",
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("token refresh persists the new token", () =>
|
it.effect(
|
||||||
|
"token refresh persists the new token",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id = AccountID.make("user-1")
|
const id = AccountID.make("user-1")
|
||||||
|
|
||||||
@@ -118,7 +120,7 @@ it.effect("token refresh persists the new token", () =>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const token = yield* AccountEffect.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
|
const token = yield* AccountService.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
|
||||||
|
|
||||||
expect(Option.getOrThrow(token)).toBeDefined()
|
expect(Option.getOrThrow(token)).toBeDefined()
|
||||||
expect(String(Option.getOrThrow(token))).toBe("at_new")
|
expect(String(Option.getOrThrow(token))).toBe("at_new")
|
||||||
@@ -131,7 +133,8 @@ it.effect("token refresh persists the new token", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("config sends the selected org header", () =>
|
it.effect(
|
||||||
|
"config sends the selected org header",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id = AccountID.make("user-1")
|
const id = AccountID.make("user-1")
|
||||||
|
|
||||||
@@ -147,11 +150,13 @@ it.effect("config sends the selected org header", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const seen: { auth?: string; org?: string } = {}
|
const seen = yield* Ref.make<{ auth?: string; org?: string }>({})
|
||||||
const client = HttpClient.make((req) =>
|
const client = HttpClient.make((req) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
seen.auth = req.headers.authorization
|
yield* Ref.set(seen, {
|
||||||
seen.org = req.headers["x-org-id"]
|
auth: req.headers.authorization,
|
||||||
|
org: req.headers["x-org-id"],
|
||||||
|
})
|
||||||
|
|
||||||
if (req.url === "https://one.example.com/api/config") {
|
if (req.url === "https://one.example.com/api/config") {
|
||||||
return json(req, { config: { theme: "light", seats: 5 } })
|
return json(req, { config: { theme: "light", seats: 5 } })
|
||||||
@@ -161,17 +166,18 @@ it.effect("config sends the selected org header", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const cfg = yield* AccountEffect.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
|
const cfg = yield* AccountService.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
|
||||||
|
|
||||||
expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
|
expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
|
||||||
expect(seen).toEqual({
|
expect(yield* Ref.get(seen)).toEqual({
|
||||||
auth: "Bearer at_1",
|
auth: "Bearer at_1",
|
||||||
org: "org-9",
|
org: "org-9",
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("poll stores the account and first org on success", () =>
|
it.effect(
|
||||||
|
"poll stores the account and first org on success",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const login = new Login({
|
const login = new Login({
|
||||||
code: DeviceCode.make("device-code"),
|
code: DeviceCode.make("device-code"),
|
||||||
@@ -199,7 +205,7 @@ it.effect("poll stores the account and first org on success", () =>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const res = yield* AccountEffect.Service.use((s) => s.poll(login)).pipe(Effect.provide(live(client)))
|
const res = yield* AccountService.use((s) => s.poll(login)).pipe(Effect.provide(live(client)))
|
||||||
|
|
||||||
expect(res._tag).toBe("PollSuccess")
|
expect(res._tag).toBe("PollSuccess")
|
||||||
if (res._tag === "PollSuccess") {
|
if (res._tag === "PollSuccess") {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import path from "path"
|
|||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { Agent } from "../../src/agent/agent"
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { PermissionNext } from "../../src/permission"
|
import { PermissionNext } from "../../src/permission/next"
|
||||||
|
|
||||||
// Helper to evaluate permission for a tool with wildcard pattern
|
// Helper to evaluate permission for a tool with wildcard pattern
|
||||||
function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined {
|
function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined {
|
||||||
@@ -76,7 +76,7 @@ test("explore agent denies edit and write", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("explore agent asks for external directories and allows Truncate.GLOB", async () => {
|
test("explore agent asks for external directories and allows Truncate.GLOB", async () => {
|
||||||
const { Truncate } = await import("../../src/tool/truncate")
|
const { Truncate } = await import("../../src/tool/truncation")
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
@@ -463,7 +463,7 @@ test("legacy tools config maps write/edit/patch/multiedit to edit permission", a
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("Truncate.GLOB is allowed even when user denies external_directory globally", async () => {
|
test("Truncate.GLOB is allowed even when user denies external_directory globally", async () => {
|
||||||
const { Truncate } = await import("../../src/tool/truncate")
|
const { Truncate } = await import("../../src/tool/truncation")
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
config: {
|
config: {
|
||||||
permission: {
|
permission: {
|
||||||
@@ -483,7 +483,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => {
|
test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => {
|
||||||
const { Truncate } = await import("../../src/tool/truncate")
|
const { Truncate } = await import("../../src/tool/truncation")
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
config: {
|
config: {
|
||||||
agent: {
|
agent: {
|
||||||
@@ -507,7 +507,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("explicit Truncate.GLOB deny is respected", async () => {
|
test("explicit Truncate.GLOB deny is respected", async () => {
|
||||||
const { Truncate } = await import("../../src/tool/truncate")
|
const { Truncate } = await import("../../src/tool/truncation")
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
config: {
|
config: {
|
||||||
permission: {
|
permission: {
|
||||||
|
|||||||
@@ -681,7 +681,9 @@ describe("file/index Filesystem patterns", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await File.init()
|
File.init()
|
||||||
|
// Give the background scan time to populate
|
||||||
|
await new Promise((r) => setTimeout(r, 500))
|
||||||
|
|
||||||
const result = await File.search({ query: "", type: "file" })
|
const result = await File.search({ query: "", type: "file" })
|
||||||
expect(result.length).toBeGreaterThan(0)
|
expect(result.length).toBeGreaterThan(0)
|
||||||
@@ -695,7 +697,8 @@ describe("file/index Filesystem patterns", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await File.init()
|
File.init()
|
||||||
|
await new Promise((r) => setTimeout(r, 500))
|
||||||
|
|
||||||
const result = await File.search({ query: "", type: "directory" })
|
const result = await File.search({ query: "", type: "directory" })
|
||||||
expect(result.length).toBeGreaterThan(0)
|
expect(result.length).toBeGreaterThan(0)
|
||||||
@@ -715,7 +718,8 @@ describe("file/index Filesystem patterns", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await File.init()
|
File.init()
|
||||||
|
await new Promise((r) => setTimeout(r, 500))
|
||||||
|
|
||||||
const result = await File.search({ query: "main", type: "file" })
|
const result = await File.search({ query: "main", type: "file" })
|
||||||
expect(result.some((f) => f.includes("main"))).toBe(true)
|
expect(result.some((f) => f.includes("main"))).toBe(true)
|
||||||
@@ -729,7 +733,8 @@ describe("file/index Filesystem patterns", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await File.init()
|
File.init()
|
||||||
|
await new Promise((r) => setTimeout(r, 500))
|
||||||
|
|
||||||
const result = await File.search({ query: "", type: "file" })
|
const result = await File.search({ query: "", type: "file" })
|
||||||
// Files don't end with /
|
// Files don't end with /
|
||||||
@@ -746,7 +751,8 @@ describe("file/index Filesystem patterns", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await File.init()
|
File.init()
|
||||||
|
await new Promise((r) => setTimeout(r, 500))
|
||||||
|
|
||||||
const result = await File.search({ query: "", type: "directory" })
|
const result = await File.search({ query: "", type: "directory" })
|
||||||
// Directories end with /
|
// Directories end with /
|
||||||
@@ -763,7 +769,8 @@ describe("file/index Filesystem patterns", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await File.init()
|
File.init()
|
||||||
|
await new Promise((r) => setTimeout(r, 500))
|
||||||
|
|
||||||
const result = await File.search({ query: "", type: "file", limit: 2 })
|
const result = await File.search({ query: "", type: "file", limit: 2 })
|
||||||
expect(result.length).toBeLessThanOrEqual(2)
|
expect(result.length).toBeLessThanOrEqual(2)
|
||||||
@@ -777,7 +784,8 @@ describe("file/index Filesystem patterns", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await File.init()
|
File.init()
|
||||||
|
await new Promise((r) => setTimeout(r, 500))
|
||||||
|
|
||||||
const result = await File.search({ query: ".hidden", type: "directory" })
|
const result = await File.search({ query: ".hidden", type: "directory" })
|
||||||
expect(result.length).toBeGreaterThan(0)
|
expect(result.length).toBeGreaterThan(0)
|
||||||
|
|||||||
@@ -9,19 +9,6 @@ import { tmpdir } from "../fixture/fixture"
|
|||||||
|
|
||||||
afterEach(() => Instance.disposeAll())
|
afterEach(() => Instance.disposeAll())
|
||||||
|
|
||||||
async function touch(file: string, time: number) {
|
|
||||||
const date = new Date(time)
|
|
||||||
await fs.utimes(file, date, date)
|
|
||||||
}
|
|
||||||
|
|
||||||
function gate() {
|
|
||||||
let open!: () => void
|
|
||||||
const wait = new Promise<void>((resolve) => {
|
|
||||||
open = resolve
|
|
||||||
})
|
|
||||||
return { open, wait }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("file/time", () => {
|
describe("file/time", () => {
|
||||||
const sessionID = SessionID.make("ses_00000000000000000000000001")
|
const sessionID = SessionID.make("ses_00000000000000000000000001")
|
||||||
|
|
||||||
@@ -38,6 +25,7 @@ describe("file/time", () => {
|
|||||||
expect(before).toBeUndefined()
|
expect(before).toBeUndefined()
|
||||||
|
|
||||||
await FileTime.read(sessionID, filepath)
|
await FileTime.read(sessionID, filepath)
|
||||||
|
await Bun.sleep(10)
|
||||||
|
|
||||||
const after = await FileTime.get(sessionID, filepath)
|
const after = await FileTime.get(sessionID, filepath)
|
||||||
expect(after).toBeInstanceOf(Date)
|
expect(after).toBeInstanceOf(Date)
|
||||||
@@ -56,6 +44,7 @@ describe("file/time", () => {
|
|||||||
fn: async () => {
|
fn: async () => {
|
||||||
await FileTime.read(SessionID.make("ses_00000000000000000000000002"), filepath)
|
await FileTime.read(SessionID.make("ses_00000000000000000000000002"), filepath)
|
||||||
await FileTime.read(SessionID.make("ses_00000000000000000000000003"), filepath)
|
await FileTime.read(SessionID.make("ses_00000000000000000000000003"), filepath)
|
||||||
|
await Bun.sleep(10)
|
||||||
|
|
||||||
const time1 = await FileTime.get(SessionID.make("ses_00000000000000000000000002"), filepath)
|
const time1 = await FileTime.get(SessionID.make("ses_00000000000000000000000002"), filepath)
|
||||||
const time2 = await FileTime.get(SessionID.make("ses_00000000000000000000000003"), filepath)
|
const time2 = await FileTime.get(SessionID.make("ses_00000000000000000000000003"), filepath)
|
||||||
@@ -74,10 +63,14 @@ describe("file/time", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await FileTime.read(sessionID, filepath)
|
FileTime.read(sessionID, filepath)
|
||||||
|
await Bun.sleep(10)
|
||||||
const first = await FileTime.get(sessionID, filepath)
|
const first = await FileTime.get(sessionID, filepath)
|
||||||
|
|
||||||
await FileTime.read(sessionID, filepath)
|
await Bun.sleep(10)
|
||||||
|
|
||||||
|
FileTime.read(sessionID, filepath)
|
||||||
|
await Bun.sleep(10)
|
||||||
const second = await FileTime.get(sessionID, filepath)
|
const second = await FileTime.get(sessionID, filepath)
|
||||||
|
|
||||||
expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime())
|
expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime())
|
||||||
@@ -91,12 +84,12 @@ describe("file/time", () => {
|
|||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
await fs.writeFile(filepath, "content", "utf-8")
|
||||||
await touch(filepath, 1_000)
|
|
||||||
|
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await FileTime.read(sessionID, filepath)
|
FileTime.read(sessionID, filepath)
|
||||||
|
await Bun.sleep(10)
|
||||||
await FileTime.assert(sessionID, filepath)
|
await FileTime.assert(sessionID, filepath)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -119,14 +112,13 @@ describe("file/time", () => {
|
|||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
await fs.writeFile(filepath, "content", "utf-8")
|
||||||
await touch(filepath, 1_000)
|
|
||||||
|
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await FileTime.read(sessionID, filepath)
|
FileTime.read(sessionID, filepath)
|
||||||
|
await Bun.sleep(100)
|
||||||
await fs.writeFile(filepath, "modified content", "utf-8")
|
await fs.writeFile(filepath, "modified content", "utf-8")
|
||||||
await touch(filepath, 2_000)
|
|
||||||
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read")
|
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -136,14 +128,13 @@ describe("file/time", () => {
|
|||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
await fs.writeFile(filepath, "content", "utf-8")
|
||||||
await touch(filepath, 1_000)
|
|
||||||
|
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await FileTime.read(sessionID, filepath)
|
await FileTime.read(sessionID, filepath)
|
||||||
|
await Bun.sleep(100)
|
||||||
await fs.writeFile(filepath, "modified", "utf-8")
|
await fs.writeFile(filepath, "modified", "utf-8")
|
||||||
await touch(filepath, 2_000)
|
|
||||||
|
|
||||||
let error: Error | undefined
|
let error: Error | undefined
|
||||||
try {
|
try {
|
||||||
@@ -200,25 +191,18 @@ describe("file/time", () => {
|
|||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const order: number[] = []
|
const order: number[] = []
|
||||||
const hold = gate()
|
|
||||||
const ready = gate()
|
|
||||||
|
|
||||||
const op1 = FileTime.withLock(filepath, async () => {
|
const op1 = FileTime.withLock(filepath, async () => {
|
||||||
order.push(1)
|
order.push(1)
|
||||||
ready.open()
|
await Bun.sleep(50)
|
||||||
await hold.wait
|
|
||||||
order.push(2)
|
order.push(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
await ready.wait
|
|
||||||
|
|
||||||
const op2 = FileTime.withLock(filepath, async () => {
|
const op2 = FileTime.withLock(filepath, async () => {
|
||||||
order.push(3)
|
order.push(3)
|
||||||
order.push(4)
|
order.push(4)
|
||||||
})
|
})
|
||||||
|
|
||||||
hold.open()
|
|
||||||
|
|
||||||
await Promise.all([op1, op2])
|
await Promise.all([op1, op2])
|
||||||
expect(order).toEqual([1, 2, 3, 4])
|
expect(order).toEqual([1, 2, 3, 4])
|
||||||
},
|
},
|
||||||
@@ -235,21 +219,15 @@ describe("file/time", () => {
|
|||||||
fn: async () => {
|
fn: async () => {
|
||||||
let started1 = false
|
let started1 = false
|
||||||
let started2 = false
|
let started2 = false
|
||||||
const hold = gate()
|
|
||||||
const ready = gate()
|
|
||||||
|
|
||||||
const op1 = FileTime.withLock(filepath1, async () => {
|
const op1 = FileTime.withLock(filepath1, async () => {
|
||||||
started1 = true
|
started1 = true
|
||||||
ready.open()
|
await Bun.sleep(50)
|
||||||
await hold.wait
|
|
||||||
expect(started2).toBe(true)
|
expect(started2).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
await ready.wait
|
|
||||||
|
|
||||||
const op2 = FileTime.withLock(filepath2, async () => {
|
const op2 = FileTime.withLock(filepath2, async () => {
|
||||||
started2 = true
|
started2 = true
|
||||||
hold.open()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await Promise.all([op1, op2])
|
await Promise.all([op1, op2])
|
||||||
@@ -287,12 +265,12 @@ describe("file/time", () => {
|
|||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
await fs.writeFile(filepath, "content", "utf-8")
|
||||||
await touch(filepath, 1_000)
|
|
||||||
|
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await FileTime.read(sessionID, filepath)
|
FileTime.read(sessionID, filepath)
|
||||||
|
await Bun.sleep(10)
|
||||||
|
|
||||||
const stats = Filesystem.stat(filepath)
|
const stats = Filesystem.stat(filepath)
|
||||||
expect(stats?.mtime).toBeInstanceOf(Date)
|
expect(stats?.mtime).toBeInstanceOf(Date)
|
||||||
@@ -307,17 +285,17 @@ describe("file/time", () => {
|
|||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
await fs.writeFile(filepath, "original", "utf-8")
|
await fs.writeFile(filepath, "original", "utf-8")
|
||||||
await touch(filepath, 1_000)
|
|
||||||
|
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await FileTime.read(sessionID, filepath)
|
FileTime.read(sessionID, filepath)
|
||||||
|
await Bun.sleep(10)
|
||||||
|
|
||||||
const originalStat = Filesystem.stat(filepath)
|
const originalStat = Filesystem.stat(filepath)
|
||||||
|
|
||||||
|
await Bun.sleep(100)
|
||||||
await fs.writeFile(filepath, "modified", "utf-8")
|
await fs.writeFile(filepath, "modified", "utf-8")
|
||||||
await touch(filepath, 2_000)
|
|
||||||
|
|
||||||
const newStat = Filesystem.stat(filepath)
|
const newStat = Filesystem.stat(filepath)
|
||||||
expect(newStat!.mtime.getTime()).toBeGreaterThan(originalStat!.mtime.getTime())
|
expect(newStat!.mtime.getTime()).toBeGreaterThan(originalStat!.mtime.getTime())
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import path from "path"
|
|||||||
import { Deferred, Effect, Fiber, Option } from "effect"
|
import { Deferred, Effect, Fiber, Option } from "effect"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { watcherConfigLayer, withServices } from "../fixture/instance"
|
import { watcherConfigLayer, withServices } from "../fixture/instance"
|
||||||
import { FileWatcher } from "../../src/file/watcher"
|
import { FileWatcher, FileWatcherService } from "../../src/file/watcher"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { GlobalBus } from "../../src/bus/global"
|
import { GlobalBus } from "../../src/bus/global"
|
||||||
|
|
||||||
@@ -19,13 +19,13 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
|
|||||||
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
|
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
|
||||||
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
||||||
|
|
||||||
/** Run `body` with a live FileWatcher service. */
|
/** Run `body` with a live FileWatcherService. */
|
||||||
function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
|
function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
|
||||||
return withServices(
|
return withServices(
|
||||||
directory,
|
directory,
|
||||||
FileWatcher.layer,
|
FileWatcherService.layer,
|
||||||
async (rt) => {
|
async (rt) => {
|
||||||
await rt.runPromise(FileWatcher.Service.use(() => Effect.void))
|
await rt.runPromise(FileWatcherService.use((s) => s.init()))
|
||||||
await Effect.runPromise(ready(directory))
|
await Effect.runPromise(ready(directory))
|
||||||
await Effect.runPromise(body)
|
await Effect.runPromise(body)
|
||||||
},
|
},
|
||||||
@@ -138,7 +138,7 @@ function ready(directory: string) {
|
|||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describeWatcher("FileWatcher", () => {
|
describeWatcher("FileWatcherService", () => {
|
||||||
afterEach(() => Instance.disposeAll())
|
afterEach(() => Instance.disposeAll())
|
||||||
|
|
||||||
test("publishes root create, update, and delete events", async () => {
|
test("publishes root create, update, and delete events", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { test } from "bun:test"
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
|
||||||
|
export const testEffect = <R, E>(layer: Layer.Layer<R, E, never>) => ({
|
||||||
|
effect: <A, E2>(name: string, value: Effect.Effect<A, E2, R>) =>
|
||||||
|
test(name, () => Effect.runPromise(value.pipe(Effect.provide(layer)))),
|
||||||
|
})
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import { ConfigProvider, Layer, ManagedRuntime } from "effect";
|
import { ConfigProvider, Layer, ManagedRuntime } from "effect"
|
||||||
import { InstanceContext } from "../../src/effect/instance-context";
|
import { InstanceContext } from "../../src/effect/instance-context"
|
||||||
import { Instance } from "../../src/project/instance";
|
import { Instance } from "../../src/project/instance"
|
||||||
|
|
||||||
/** ConfigProvider that enables the experimental file watcher. */
|
/** ConfigProvider that enables the experimental file watcher. */
|
||||||
export const watcherConfigLayer = ConfigProvider.layer(
|
export const watcherConfigLayer = ConfigProvider.layer(
|
||||||
ConfigProvider.fromUnknown({
|
ConfigProvider.fromUnknown({
|
||||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
|
||||||
}),
|
}),
|
||||||
);
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Boot an Instance with the given service layers and run `body` with
|
* Boot an Instance with the given service layers and run `body` with
|
||||||
@@ -19,35 +19,29 @@ export const watcherConfigLayer = ConfigProvider.layer(
|
|||||||
* Pass extra layers via `options.provide` (e.g. ConfigProvider.layer).
|
* Pass extra layers via `options.provide` (e.g. ConfigProvider.layer).
|
||||||
*/
|
*/
|
||||||
export function withServices<S>(
|
export function withServices<S>(
|
||||||
directory: string,
|
directory: string,
|
||||||
layer: Layer.Layer<S, any, InstanceContext>,
|
layer: Layer.Layer<S, any, InstanceContext>,
|
||||||
body: (rt: ManagedRuntime.ManagedRuntime<S, never>) => Promise<void>,
|
body: (rt: ManagedRuntime.ManagedRuntime<S, never>) => Promise<void>,
|
||||||
options?: { provide?: Layer.Layer<never>[] },
|
options?: { provide?: Layer.Layer<never>[] },
|
||||||
) {
|
) {
|
||||||
return Instance.provide({
|
return Instance.provide({
|
||||||
directory,
|
directory,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const ctx = Layer.sync(InstanceContext, () =>
|
const ctx = Layer.sync(InstanceContext, () =>
|
||||||
InstanceContext.of({
|
InstanceContext.of({ directory: Instance.directory, project: Instance.project }),
|
||||||
directory: Instance.directory,
|
)
|
||||||
worktree: Instance.worktree,
|
let resolved: Layer.Layer<S> = Layer.fresh(layer).pipe(Layer.provide(ctx)) as any
|
||||||
project: Instance.project,
|
if (options?.provide) {
|
||||||
}),
|
for (const l of options.provide) {
|
||||||
);
|
resolved = resolved.pipe(Layer.provide(l)) as any
|
||||||
let resolved: Layer.Layer<S> = Layer.fresh(layer).pipe(
|
}
|
||||||
Layer.provide(ctx),
|
}
|
||||||
) as any;
|
const rt = ManagedRuntime.make(resolved)
|
||||||
if (options?.provide) {
|
try {
|
||||||
for (const l of options.provide) {
|
await body(rt)
|
||||||
resolved = resolved.pipe(Layer.provide(l)) as any;
|
} finally {
|
||||||
}
|
await rt.dispose()
|
||||||
}
|
}
|
||||||
const rt = ManagedRuntime.make(resolved);
|
},
|
||||||
try {
|
})
|
||||||
await body(rt);
|
|
||||||
} finally {
|
|
||||||
await rt.dispose();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import { Effect } from "effect"
|
|
||||||
import { afterEach, describe, expect, test } from "bun:test"
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { withServices } from "../fixture/instance"
|
import { withServices } from "../fixture/instance"
|
||||||
import { Format } from "../../src/format"
|
import { FormatService } from "../../src/format"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
|
|
||||||
describe("Format", () => {
|
describe("FormatService", () => {
|
||||||
afterEach(() => Instance.disposeAll())
|
afterEach(() => Instance.disposeAll())
|
||||||
|
|
||||||
test("status() returns built-in formatters when no config overrides", async () => {
|
test("status() returns built-in formatters when no config overrides", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
|
|
||||||
await withServices(tmp.path, Format.layer, async (rt) => {
|
await withServices(tmp.path, FormatService.layer, async (rt) => {
|
||||||
const statuses = await rt.runPromise(Format.Service.use((s) => s.status()))
|
const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
|
||||||
expect(Array.isArray(statuses)).toBe(true)
|
expect(Array.isArray(statuses)).toBe(true)
|
||||||
expect(statuses.length).toBeGreaterThan(0)
|
expect(statuses.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
@@ -33,8 +32,8 @@ describe("Format", () => {
|
|||||||
config: { formatter: false },
|
config: { formatter: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
await withServices(tmp.path, Format.layer, async (rt) => {
|
await withServices(tmp.path, FormatService.layer, async (rt) => {
|
||||||
const statuses = await rt.runPromise(Format.Service.use((s) => s.status()))
|
const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
|
||||||
expect(statuses).toEqual([])
|
expect(statuses).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -48,18 +47,18 @@ describe("Format", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await withServices(tmp.path, Format.layer, async (rt) => {
|
await withServices(tmp.path, FormatService.layer, async (rt) => {
|
||||||
const statuses = await rt.runPromise(Format.Service.use((s) => s.status()))
|
const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
|
||||||
const gofmt = statuses.find((s) => s.name === "gofmt")
|
const gofmt = statuses.find((s) => s.name === "gofmt")
|
||||||
expect(gofmt).toBeUndefined()
|
expect(gofmt).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("service initializes without error", async () => {
|
test("init() completes without error", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
|
|
||||||
await withServices(tmp.path, Format.layer, async (rt) => {
|
await withServices(tmp.path, FormatService.layer, async (rt) => {
|
||||||
await rt.runPromise(Format.Service.use(() => Effect.void))
|
await rt.runPromise(FormatService.use((s) => s.init()))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { test, type TestOptions } from "bun:test"
|
|
||||||
import { Cause, Effect, Exit, Layer } from "effect"
|
|
||||||
import type * as Scope from "effect/Scope"
|
|
||||||
import * as TestConsole from "effect/testing/TestConsole"
|
|
||||||
|
|
||||||
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
|
|
||||||
const env = TestConsole.layer
|
|
||||||
|
|
||||||
const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
|
|
||||||
|
|
||||||
const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2, never>) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit)
|
|
||||||
if (Exit.isFailure(exit)) {
|
|
||||||
for (const err of Cause.prettyErrors(exit.cause)) {
|
|
||||||
yield* Effect.logError(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return yield* exit
|
|
||||||
}).pipe(Effect.runPromise)
|
|
||||||
|
|
||||||
const make = <R, E>(layer: Layer.Layer<R, E, never>) => {
|
|
||||||
const effect = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
|
||||||
test(name, () => run(value, layer), opts)
|
|
||||||
|
|
||||||
effect.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
|
||||||
test.only(name, () => run(value, layer), opts)
|
|
||||||
|
|
||||||
effect.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
|
||||||
test.skip(name, () => run(value, layer), opts)
|
|
||||||
|
|
||||||
return { effect }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const it = make(env)
|
|
||||||
|
|
||||||
export const testEffect = <R, E>(layer: Layer.Layer<R, E, never>) => make(Layer.provideMerge(layer, env))
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import path from "path"
|
|
||||||
import { Effect, FileSystem } from "effect"
|
|
||||||
|
|
||||||
export const writeFileStringScoped = Effect.fn("test.writeFileStringScoped")(function* (file: string, text: string) {
|
|
||||||
const fs = yield* FileSystem.FileSystem
|
|
||||||
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
|
|
||||||
yield* fs.writeFileString(file, text)
|
|
||||||
yield* Effect.addFinalizer(() => fs.remove(file, { force: true }).pipe(Effect.orDie))
|
|
||||||
return file
|
|
||||||
})
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test, expect } from "bun:test"
|
import { describe, test, expect } from "bun:test"
|
||||||
import { PermissionNext } from "../src/permission"
|
import { PermissionNext } from "../src/permission/next"
|
||||||
import { Config } from "../src/config/config"
|
import { Config } from "../src/config/config"
|
||||||
import { Instance } from "../src/project/instance"
|
import { Instance } from "../src/project/instance"
|
||||||
import { tmpdir } from "./fixture/fixture"
|
import { tmpdir } from "./fixture/fixture"
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { Effect } from "effect"
|
|||||||
import { Bus } from "../../src/bus"
|
import { Bus } from "../../src/bus"
|
||||||
import { runtime } from "../../src/effect/runtime"
|
import { runtime } from "../../src/effect/runtime"
|
||||||
import { Instances } from "../../src/effect/instances"
|
import { Instances } from "../../src/effect/instances"
|
||||||
import { PermissionNext } from "../../src/permission"
|
import { PermissionNext } from "../../src/permission/next"
|
||||||
import * as S from "../../src/permission/effect"
|
import * as S from "../../src/permission/service"
|
||||||
import { PermissionID } from "../../src/permission/schema"
|
import { PermissionID } from "../../src/permission/schema"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
@@ -1005,7 +1005,7 @@ test("ask - abort should clear pending request", async () => {
|
|||||||
fn: async () => {
|
fn: async () => {
|
||||||
const ctl = new AbortController()
|
const ctl = new AbortController()
|
||||||
const ask = runtime.runPromise(
|
const ask = runtime.runPromise(
|
||||||
S.PermissionEffect.Service.use((svc) =>
|
S.PermissionService.use((svc) =>
|
||||||
svc.ask({
|
svc.ask({
|
||||||
sessionID: SessionID.make("session_test"),
|
sessionID: SessionID.make("session_test"),
|
||||||
permission: "bash",
|
permission: "bash",
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ process.env["OPENCODE_TEST_HOME"] = testHome
|
|||||||
// Set test managed config directory to isolate tests from system managed settings
|
// Set test managed config directory to isolate tests from system managed settings
|
||||||
const testManagedConfigDir = path.join(dir, "managed")
|
const testManagedConfigDir = path.join(dir, "managed")
|
||||||
process.env["OPENCODE_TEST_MANAGED_CONFIG_DIR"] = testManagedConfigDir
|
process.env["OPENCODE_TEST_MANAGED_CONFIG_DIR"] = testManagedConfigDir
|
||||||
process.env["OPENCODE_DISABLE_DEFAULT_PLUGINS"] = "true"
|
|
||||||
|
|
||||||
// Write the cache version file to prevent global/index.ts from clearing the cache
|
// Write the cache version file to prevent global/index.ts from clearing the cache
|
||||||
const cacheDir = path.join(dir, "cache", "opencode")
|
const cacheDir = path.join(dir, "cache", "opencode")
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { $ } from "bun"
|
|||||||
import { afterEach, describe, expect, test } from "bun:test"
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
import { Layer, ManagedRuntime } from "effect"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { watcherConfigLayer, withServices } from "../fixture/instance"
|
import { watcherConfigLayer, withServices } from "../fixture/instance"
|
||||||
import { FileWatcher } from "../../src/file/watcher"
|
import { FileWatcher, FileWatcherService } from "../../src/file/watcher"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { GlobalBus } from "../../src/bus/global"
|
import { GlobalBus } from "../../src/bus/global"
|
||||||
import { Vcs } from "../../src/project/vcs"
|
import { Vcs, VcsService } from "../../src/project/vcs"
|
||||||
|
|
||||||
// Skip in CI — native @parcel/watcher binding needed
|
// Skip in CI — native @parcel/watcher binding needed
|
||||||
const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
||||||
@@ -19,14 +19,14 @@ const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe
|
|||||||
|
|
||||||
function withVcs(
|
function withVcs(
|
||||||
directory: string,
|
directory: string,
|
||||||
body: (rt: ManagedRuntime.ManagedRuntime<FileWatcher.Service | Vcs.Service, never>) => Promise<void>,
|
body: (rt: ManagedRuntime.ManagedRuntime<FileWatcherService | VcsService, never>) => Promise<void>,
|
||||||
) {
|
) {
|
||||||
return withServices(
|
return withServices(
|
||||||
directory,
|
directory,
|
||||||
Layer.merge(FileWatcher.layer, Vcs.layer),
|
Layer.merge(FileWatcherService.layer, VcsService.layer),
|
||||||
async (rt) => {
|
async (rt) => {
|
||||||
await rt.runPromise(FileWatcher.Service.use(() => Effect.void))
|
await rt.runPromise(FileWatcherService.use((s) => s.init()))
|
||||||
await rt.runPromise(Vcs.Service.use(() => Effect.void))
|
await rt.runPromise(VcsService.use((s) => s.init()))
|
||||||
await Bun.sleep(200)
|
await Bun.sleep(200)
|
||||||
await body(rt)
|
await body(rt)
|
||||||
},
|
},
|
||||||
@@ -67,7 +67,7 @@ describeVcs("Vcs", () => {
|
|||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
|
||||||
await withVcs(tmp.path, async (rt) => {
|
await withVcs(tmp.path, async (rt) => {
|
||||||
const branch = await rt.runPromise(Vcs.Service.use((s) => s.branch()))
|
const branch = await rt.runPromise(VcsService.use((s) => s.branch()))
|
||||||
expect(branch).toBeDefined()
|
expect(branch).toBeDefined()
|
||||||
expect(typeof branch).toBe("string")
|
expect(typeof branch).toBe("string")
|
||||||
})
|
})
|
||||||
@@ -77,7 +77,7 @@ describeVcs("Vcs", () => {
|
|||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
|
|
||||||
await withVcs(tmp.path, async (rt) => {
|
await withVcs(tmp.path, async (rt) => {
|
||||||
const branch = await rt.runPromise(Vcs.Service.use((s) => s.branch()))
|
const branch = await rt.runPromise(VcsService.use((s) => s.branch()))
|
||||||
expect(branch).toBeUndefined()
|
expect(branch).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -110,7 +110,7 @@ describeVcs("Vcs", () => {
|
|||||||
await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
|
await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
|
||||||
|
|
||||||
await pending
|
await pending
|
||||||
const current = await rt.runPromise(Vcs.Service.use((s) => s.branch()))
|
const current = await rt.runPromise(VcsService.use((s) => s.branch()))
|
||||||
expect(current).toBe(branch)
|
expect(current).toBe(branch)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { Scheduler } from "../src/scheduler"
|
||||||
|
import { Instance } from "../src/project/instance"
|
||||||
|
import { tmpdir } from "./fixture/fixture"
|
||||||
|
|
||||||
|
describe("Scheduler.register", () => {
|
||||||
|
const hour = 60 * 60 * 1000
|
||||||
|
|
||||||
|
test("defaults to instance scope per directory", async () => {
|
||||||
|
await using one = await tmpdir({ git: true })
|
||||||
|
await using two = await tmpdir({ git: true })
|
||||||
|
const runs = { count: 0 }
|
||||||
|
const id = "scheduler.instance." + Math.random().toString(36).slice(2)
|
||||||
|
const task = {
|
||||||
|
id,
|
||||||
|
interval: hour,
|
||||||
|
run: async () => {
|
||||||
|
runs.count += 1
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: one.path,
|
||||||
|
fn: async () => {
|
||||||
|
Scheduler.register(task)
|
||||||
|
await Instance.dispose()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(runs.count).toBe(1)
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: two.path,
|
||||||
|
fn: async () => {
|
||||||
|
Scheduler.register(task)
|
||||||
|
await Instance.dispose()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(runs.count).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("global scope runs once across instances", async () => {
|
||||||
|
await using one = await tmpdir({ git: true })
|
||||||
|
await using two = await tmpdir({ git: true })
|
||||||
|
const runs = { count: 0 }
|
||||||
|
const id = "scheduler.global." + Math.random().toString(36).slice(2)
|
||||||
|
const task = {
|
||||||
|
id,
|
||||||
|
interval: hour,
|
||||||
|
run: async () => {
|
||||||
|
runs.count += 1
|
||||||
|
},
|
||||||
|
scope: "global" as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: one.path,
|
||||||
|
fn: async () => {
|
||||||
|
Scheduler.register(task)
|
||||||
|
await Instance.dispose()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(runs.count).toBe(1)
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: two.path,
|
||||||
|
fn: async () => {
|
||||||
|
Scheduler.register(task)
|
||||||
|
await Instance.dispose()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(runs.count).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, test, expect, beforeAll, afterAll } from "bun:test"
|
import { describe, test, expect, beforeAll, afterAll } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Discovery } from "../../src/skill/discovery"
|
import { DiscoveryService } from "../../src/skill/discovery"
|
||||||
import { Global } from "../../src/global"
|
import { Global } from "../../src/global"
|
||||||
import { Filesystem } from "../../src/util/filesystem"
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
import { rm } from "fs/promises"
|
import { rm } from "fs/promises"
|
||||||
@@ -48,7 +48,7 @@ afterAll(async () => {
|
|||||||
|
|
||||||
describe("Discovery.pull", () => {
|
describe("Discovery.pull", () => {
|
||||||
const pull = (url: string) =>
|
const pull = (url: string) =>
|
||||||
Effect.runPromise(Discovery.Service.use((s) => s.pull(url)).pipe(Effect.provide(Discovery.defaultLayer)))
|
Effect.runPromise(DiscoveryService.use((s) => s.pull(url)).pipe(Effect.provide(DiscoveryService.defaultLayer)))
|
||||||
|
|
||||||
test("downloads skills from cloudflare url", async () => {
|
test("downloads skills from cloudflare url", async () => {
|
||||||
const dirs = await pull(CLOUDFLARE_SKILLS_URL)
|
const dirs = await pull(CLOUDFLARE_SKILLS_URL)
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { BashTool } from "../../src/tool/bash"
|
|||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { Filesystem } from "../../src/util/filesystem"
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import type { PermissionNext } from "../../src/permission"
|
import type { PermissionNext } from "../../src/permission/next"
|
||||||
import { Truncate } from "../../src/tool/truncate"
|
import { Truncate } from "../../src/tool/truncation"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
|
|||||||
@@ -18,11 +18,6 @@ const ctx = {
|
|||||||
ask: async () => {},
|
ask: async () => {},
|
||||||
}
|
}
|
||||||
|
|
||||||
async function touch(file: string, time: number) {
|
|
||||||
const date = new Date(time)
|
|
||||||
await fs.utimes(file, date, date)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("tool.edit", () => {
|
describe("tool.edit", () => {
|
||||||
describe("creating new files", () => {
|
describe("creating new files", () => {
|
||||||
test("creates new file when oldString is empty", async () => {
|
test("creates new file when oldString is empty", async () => {
|
||||||
@@ -116,7 +111,7 @@ describe("tool.edit", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await FileTime.read(ctx.sessionID, filepath)
|
FileTime.read(ctx.sessionID, filepath)
|
||||||
|
|
||||||
const edit = await EditTool.init()
|
const edit = await EditTool.init()
|
||||||
const result = await edit.execute(
|
const result = await edit.execute(
|
||||||
@@ -143,7 +138,7 @@ describe("tool.edit", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await FileTime.read(ctx.sessionID, filepath)
|
FileTime.read(ctx.sessionID, filepath)
|
||||||
|
|
||||||
const edit = await EditTool.init()
|
const edit = await EditTool.init()
|
||||||
await expect(
|
await expect(
|
||||||
@@ -191,7 +186,7 @@ describe("tool.edit", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await FileTime.read(ctx.sessionID, filepath)
|
FileTime.read(ctx.sessionID, filepath)
|
||||||
|
|
||||||
const edit = await EditTool.init()
|
const edit = await EditTool.init()
|
||||||
await expect(
|
await expect(
|
||||||
@@ -235,17 +230,18 @@ describe("tool.edit", () => {
|
|||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
await fs.writeFile(filepath, "original content", "utf-8")
|
await fs.writeFile(filepath, "original content", "utf-8")
|
||||||
await touch(filepath, 1_000)
|
|
||||||
|
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
// Read first
|
// Read first
|
||||||
await FileTime.read(ctx.sessionID, filepath)
|
FileTime.read(ctx.sessionID, filepath)
|
||||||
|
|
||||||
|
// Wait a bit to ensure different timestamps
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
|
||||||
// Simulate external modification
|
// Simulate external modification
|
||||||
await fs.writeFile(filepath, "modified externally", "utf-8")
|
await fs.writeFile(filepath, "modified externally", "utf-8")
|
||||||
await touch(filepath, 2_000)
|
|
||||||
|
|
||||||
// Try to edit with the new content
|
// Try to edit with the new content
|
||||||
const edit = await EditTool.init()
|
const edit = await EditTool.init()
|
||||||
@@ -271,7 +267,7 @@ describe("tool.edit", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await FileTime.read(ctx.sessionID, filepath)
|
FileTime.read(ctx.sessionID, filepath)
|
||||||
|
|
||||||
const edit = await EditTool.init()
|
const edit = await EditTool.init()
|
||||||
await edit.execute(
|
await edit.execute(
|
||||||
@@ -298,7 +294,7 @@ describe("tool.edit", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await FileTime.read(ctx.sessionID, filepath)
|
FileTime.read(ctx.sessionID, filepath)
|
||||||
|
|
||||||
const { Bus } = await import("../../src/bus")
|
const { Bus } = await import("../../src/bus")
|
||||||
const { File } = await import("../../src/file")
|
const { File } = await import("../../src/file")
|
||||||
@@ -336,7 +332,7 @@ describe("tool.edit", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await FileTime.read(ctx.sessionID, filepath)
|
FileTime.read(ctx.sessionID, filepath)
|
||||||
|
|
||||||
const edit = await EditTool.init()
|
const edit = await EditTool.init()
|
||||||
await edit.execute(
|
await edit.execute(
|
||||||
@@ -362,7 +358,7 @@ describe("tool.edit", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await FileTime.read(ctx.sessionID, filepath)
|
FileTime.read(ctx.sessionID, filepath)
|
||||||
|
|
||||||
const edit = await EditTool.init()
|
const edit = await EditTool.init()
|
||||||
await edit.execute(
|
await edit.execute(
|
||||||
@@ -411,7 +407,7 @@ describe("tool.edit", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await FileTime.read(ctx.sessionID, dirpath)
|
FileTime.read(ctx.sessionID, dirpath)
|
||||||
|
|
||||||
const edit = await EditTool.init()
|
const edit = await EditTool.init()
|
||||||
await expect(
|
await expect(
|
||||||
@@ -436,7 +432,7 @@ describe("tool.edit", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await FileTime.read(ctx.sessionID, filepath)
|
FileTime.read(ctx.sessionID, filepath)
|
||||||
|
|
||||||
const edit = await EditTool.init()
|
const edit = await EditTool.init()
|
||||||
const result = await edit.execute(
|
const result = await edit.execute(
|
||||||
@@ -507,7 +503,7 @@ describe("tool.edit", () => {
|
|||||||
fn: async () => {
|
fn: async () => {
|
||||||
const edit = await EditTool.init()
|
const edit = await EditTool.init()
|
||||||
const filePath = path.join(tmp.path, "test.txt")
|
const filePath = path.join(tmp.path, "test.txt")
|
||||||
await FileTime.read(ctx.sessionID, filePath)
|
FileTime.read(ctx.sessionID, filePath)
|
||||||
await edit.execute(
|
await edit.execute(
|
||||||
{
|
{
|
||||||
filePath,
|
filePath,
|
||||||
@@ -648,7 +644,7 @@ describe("tool.edit", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await FileTime.read(ctx.sessionID, filepath)
|
FileTime.read(ctx.sessionID, filepath)
|
||||||
|
|
||||||
const edit = await EditTool.init()
|
const edit = await EditTool.init()
|
||||||
|
|
||||||
@@ -663,7 +659,7 @@ describe("tool.edit", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Need to read again since FileTime tracks per-session
|
// Need to read again since FileTime tracks per-session
|
||||||
await FileTime.read(ctx.sessionID, filepath)
|
FileTime.read(ctx.sessionID, filepath)
|
||||||
|
|
||||||
const promise2 = edit.execute(
|
const promise2 = edit.execute(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import path from "path"
|
|||||||
import type { Tool } from "../../src/tool/tool"
|
import type { Tool } from "../../src/tool/tool"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { assertExternalDirectory } from "../../src/tool/external-directory"
|
import { assertExternalDirectory } from "../../src/tool/external-directory"
|
||||||
import type { PermissionNext } from "../../src/permission"
|
import type { PermissionNext } from "../../src/permission/next"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
|
|
||||||
const baseCtx: Omit<Tool.Context, "ask"> = {
|
const baseCtx: Omit<Tool.Context, "ask"> = {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ReadTool } from "../../src/tool/read"
|
|||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { Filesystem } from "../../src/util/filesystem"
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { PermissionNext } from "../../src/permission"
|
import { PermissionNext } from "../../src/permission/next"
|
||||||
import { Agent } from "../../src/agent/agent"
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import type { PermissionNext } from "../../src/permission"
|
import type { PermissionNext } from "../../src/permission/next"
|
||||||
import type { Tool } from "../../src/tool/tool"
|
import type { Tool } from "../../src/tool/tool"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { SkillTool } from "../../src/tool/skill"
|
import { SkillTool } from "../../src/tool/skill"
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { describe, test, expect } from "bun:test"
|
import { describe, test, expect, afterAll } from "bun:test"
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { Truncate } from "../../src/tool/truncation"
|
||||||
import { Effect, FileSystem, Layer } from "effect"
|
|
||||||
import { Truncate } from "../../src/tool/truncate"
|
|
||||||
import { TruncateEffect } from "../../src/tool/truncate-effect"
|
|
||||||
import { Identifier } from "../../src/id/id"
|
import { Identifier } from "../../src/id/id"
|
||||||
import { Filesystem } from "../../src/util/filesystem"
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { testEffect } from "../lib/effect"
|
|
||||||
import { writeFileStringScoped } from "../lib/filesystem"
|
|
||||||
|
|
||||||
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
||||||
|
|
||||||
@@ -129,24 +125,36 @@ describe("Truncate", () => {
|
|||||||
|
|
||||||
describe("cleanup", () => {
|
describe("cleanup", () => {
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
const it = testEffect(Layer.mergeAll(TruncateEffect.defaultLayer, NodeFileSystem.layer))
|
let oldFile: string
|
||||||
|
let recentFile: string
|
||||||
|
|
||||||
it.effect("deletes files older than 7 days and preserves recent files", () =>
|
afterAll(async () => {
|
||||||
Effect.gen(function* () {
|
await fs.unlink(oldFile).catch(() => {})
|
||||||
const fs = yield* FileSystem.FileSystem
|
await fs.unlink(recentFile).catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
yield* fs.makeDirectory(Truncate.DIR, { recursive: true })
|
test("deletes files older than 7 days and preserves recent files", async () => {
|
||||||
|
await fs.mkdir(Truncate.DIR, { recursive: true })
|
||||||
|
|
||||||
const old = path.join(Truncate.DIR, Identifier.create("tool", false, Date.now() - 10 * DAY_MS))
|
// Create an old file (10 days ago)
|
||||||
const recent = path.join(Truncate.DIR, Identifier.create("tool", false, Date.now() - 3 * DAY_MS))
|
const oldTimestamp = Date.now() - 10 * DAY_MS
|
||||||
|
const oldId = Identifier.create("tool", false, oldTimestamp)
|
||||||
|
oldFile = path.join(Truncate.DIR, oldId)
|
||||||
|
await Filesystem.write(oldFile, "old content")
|
||||||
|
|
||||||
yield* writeFileStringScoped(old, "old content")
|
// Create a recent file (3 days ago)
|
||||||
yield* writeFileStringScoped(recent, "recent content")
|
const recentTimestamp = Date.now() - 3 * DAY_MS
|
||||||
yield* TruncateEffect.Service.use((s) => s.cleanup())
|
const recentId = Identifier.create("tool", false, recentTimestamp)
|
||||||
|
recentFile = path.join(Truncate.DIR, recentId)
|
||||||
|
await Filesystem.write(recentFile, "recent content")
|
||||||
|
|
||||||
expect(yield* fs.exists(old)).toBe(false)
|
await Truncate.cleanup()
|
||||||
expect(yield* fs.exists(recent)).toBe(true)
|
|
||||||
}),
|
// Old file should be deleted
|
||||||
)
|
expect(await Filesystem.exists(oldFile)).toBe(false)
|
||||||
|
|
||||||
|
// Recent file should still exist
|
||||||
|
expect(await Filesystem.exists(recentFile)).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ describe("tool.write", () => {
|
|||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const { FileTime } = await import("../../src/file/time")
|
const { FileTime } = await import("../../src/file/time")
|
||||||
await FileTime.read(ctx.sessionID, filepath)
|
FileTime.read(ctx.sessionID, filepath)
|
||||||
|
|
||||||
const write = await WriteTool.init()
|
const write = await WriteTool.init()
|
||||||
const result = await write.execute(
|
const result = await write.execute(
|
||||||
@@ -128,7 +128,7 @@ describe("tool.write", () => {
|
|||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const { FileTime } = await import("../../src/file/time")
|
const { FileTime } = await import("../../src/file/time")
|
||||||
await FileTime.read(ctx.sessionID, filepath)
|
FileTime.read(ctx.sessionID, filepath)
|
||||||
|
|
||||||
const write = await WriteTool.init()
|
const write = await WriteTool.init()
|
||||||
const result = await write.execute(
|
const result = await write.execute(
|
||||||
@@ -306,7 +306,7 @@ describe("tool.write", () => {
|
|||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const { FileTime } = await import("../../src/file/time")
|
const { FileTime } = await import("../../src/file/time")
|
||||||
await FileTime.read(ctx.sessionID, readonlyPath)
|
FileTime.read(ctx.sessionID, readonlyPath)
|
||||||
|
|
||||||
const write = await WriteTool.init()
|
const write = await WriteTool.init()
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -1343,9 +1343,6 @@ export type Config = {
|
|||||||
ignore?: Array<string>
|
ignore?: Array<string>
|
||||||
}
|
}
|
||||||
plugin?: Array<string>
|
plugin?: Array<string>
|
||||||
/**
|
|
||||||
* Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.
|
|
||||||
*/
|
|
||||||
snapshot?: boolean
|
snapshot?: boolean
|
||||||
/**
|
/**
|
||||||
* Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing
|
* Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing
|
||||||
|
|||||||
@@ -10405,7 +10405,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"snapshot": {
|
"snapshot": {
|
||||||
"description": "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
|
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"share": {
|
"share": {
|
||||||
|
|||||||
@@ -424,23 +424,6 @@ Customize keybinds in `tui.json`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Snapshot
|
|
||||||
|
|
||||||
OpenCode uses snapshots to track file changes during agent operations, enabling you to undo and revert changes within a session. Snapshots are enabled by default.
|
|
||||||
|
|
||||||
For large repositories or projects with many submodules, the snapshot system can cause slow indexing and significant disk usage as it tracks all changes using an internal git repository. You can disable snapshots using the `snapshot` option.
|
|
||||||
|
|
||||||
```json title="opencode.json"
|
|
||||||
{
|
|
||||||
"$schema": "https://opencode.ai/config.json",
|
|
||||||
"snapshot": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Note that disabling snapshots means changes made by the agent cannot be rolled back through the UI.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Autoupdate
|
### Autoupdate
|
||||||
|
|
||||||
OpenCode will automatically download any new updates when it starts up. You can disable this with the `autoupdate` option.
|
OpenCode will automatically download any new updates when it starts up. You can disable this with the `autoupdate` option.
|
||||||
|
|||||||
Reference in New Issue
Block a user