Compare commits
88
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3eeeec359a | ||
|
|
65e786258a | ||
|
|
cbc40a5981 | ||
|
|
b9b210a864 | ||
|
|
d473b7e971 | ||
|
|
f5783c4313 | ||
|
|
9439a5647e | ||
|
|
2bfe81ee5c | ||
|
|
fcf1bb010c | ||
|
|
08b6d9c6dc | ||
|
|
0293a8bb80 | ||
|
|
850dbb93eb | ||
|
|
48e867ee20 | ||
|
|
b5ebc541b9 | ||
|
|
bd7a4cec90 | ||
|
|
63af295a17 | ||
|
|
04954a9620 | ||
|
|
fb63fd79a3 | ||
|
|
2e04b66eab | ||
|
|
f0b7c8c374 | ||
|
|
be6f59035a | ||
|
|
27ab51f490 | ||
|
|
bca723e8fe | ||
|
|
1ac39718d8 | ||
|
|
190319fb56 | ||
|
|
3154f0a61c | ||
|
|
0b686b8178 | ||
|
|
4cba56171b | ||
|
|
66342acd31 | ||
|
|
88dae67549 | ||
|
|
0ec42582f3 | ||
|
|
4f82248a68 | ||
|
|
5e069aab97 | ||
|
|
5325b2ec99 | ||
|
|
2a98920922 | ||
|
|
5ea92ea6cb | ||
|
|
a18528a7ee | ||
|
|
ced125a974 | ||
|
|
655fe20beb | ||
|
|
dd0c258e23 | ||
|
|
791e27d289 | ||
|
|
fac0aec69f | ||
|
|
ca26e639f6 | ||
|
|
0b5d54f2cb | ||
|
|
1b408cf06b | ||
|
|
8e102d19ed | ||
|
|
721b2406e9 | ||
|
|
4a6a18cd79 | ||
|
|
c10b5880cc | ||
|
|
e6bf83084c | ||
|
|
6722ee22ee | ||
|
|
870a5731ac | ||
|
|
7910ce5d36 | ||
|
|
6ad171dba9 | ||
|
|
cb5674edc7 | ||
|
|
b99de4118e | ||
|
|
040700dbc4 | ||
|
|
4d5da9697e | ||
|
|
a28648f530 | ||
|
|
4d81e2d4d9 | ||
|
|
21e72cbf42 | ||
|
|
5f277d1e62 | ||
|
|
d67e877e28 | ||
|
|
d4e51e04b3 | ||
|
|
070c1679e4 | ||
|
|
406d216cd2 | ||
|
|
5dc8b4ef29 | ||
|
|
2f41d89163 | ||
|
|
b2eae867a1 | ||
|
|
3c2fda4d91 | ||
|
|
2678ceb45e | ||
|
|
58a4cd00b6 | ||
|
|
0faa191b6d | ||
|
|
58cf092105 | ||
|
|
0ff8bfe1d9 | ||
|
|
ceb79c786a | ||
|
|
b1a15d559b | ||
|
|
124a8abf9b | ||
|
|
85c2bb342b | ||
|
|
4c57e39466 | ||
|
|
0cdd4e4e16 | ||
|
|
a9b01be0c2 | ||
|
|
528daf5490 | ||
|
|
0e176d3ac3 | ||
|
|
27f359852e | ||
|
|
173128d431 | ||
|
|
e8ee1e239f | ||
|
|
656fa191c1 |
+1
-3
@@ -10,7 +10,6 @@
|
|||||||
adamdotdevin
|
adamdotdevin
|
||||||
-agusbasari29 AI PR slop
|
-agusbasari29 AI PR slop
|
||||||
ariane-emory
|
ariane-emory
|
||||||
-danieljoshuanazareth
|
|
||||||
edemaine
|
edemaine
|
||||||
-florianleibert
|
-florianleibert
|
||||||
fwang
|
fwang
|
||||||
@@ -18,9 +17,8 @@ iamdavidhill
|
|||||||
jayair
|
jayair
|
||||||
kitlangton
|
kitlangton
|
||||||
kommander
|
kommander
|
||||||
-opencode2026
|
|
||||||
r44vc0rp
|
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
|
||||||
-danieljoshuanazareth
|
-OpenCode2026
|
||||||
|
|||||||
@@ -50,17 +50,20 @@ jobs:
|
|||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
name: e2e (${{ matrix.settings.name }})
|
name: e2e (${{ matrix.settings.name }})
|
||||||
|
needs: unit
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
settings:
|
settings:
|
||||||
- name: linux
|
- name: linux
|
||||||
host: blacksmith-4vcpu-ubuntu-2404
|
host: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
playwright: bunx playwright install --with-deps
|
||||||
- name: windows
|
- name: windows
|
||||||
host: blacksmith-4vcpu-windows-2025
|
host: blacksmith-4vcpu-windows-2025
|
||||||
|
playwright: bunx playwright install
|
||||||
runs-on: ${{ matrix.settings.host }}
|
runs-on: ${{ matrix.settings.host }}
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
|
PLAYWRIGHT_BROWSERS_PATH: 0
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -73,28 +76,9 @@ jobs:
|
|||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: ./.github/actions/setup-bun
|
uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
- name: Read Playwright version
|
|
||||||
id: playwright-version
|
|
||||||
run: |
|
|
||||||
version=$(node -e 'console.log(require("./packages/app/package.json").devDependencies["@playwright/test"])')
|
|
||||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Cache Playwright browsers
|
|
||||||
id: playwright-cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ${{ github.workspace }}/.playwright-browsers
|
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
|
|
||||||
|
|
||||||
- name: Install Playwright system dependencies
|
|
||||||
if: runner.os == 'Linux'
|
|
||||||
working-directory: packages/app
|
|
||||||
run: bunx playwright install-deps chromium
|
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
|
||||||
working-directory: packages/app
|
working-directory: packages/app
|
||||||
run: bunx playwright install chromium
|
run: ${{ matrix.settings.playwright }}
|
||||||
|
|
||||||
- name: Run app e2e tests
|
- name: Run app e2e tests
|
||||||
run: bun --cwd packages/app test:e2e:local
|
run: bun --cwd packages/app test:e2e:local
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-WXQ4b1hHFk2vDWz41fJmj+z0twee6r0YR0JGH0iw0ZI=",
|
"x86_64-linux": "sha256-yfA50QKqylmaioxi+6d++W8Xv4Wix1hl3hEF6Zz7Ue0=",
|
||||||
"aarch64-linux": "sha256-YIdnfkHGLfUq3cZkycvL7DQ8BvC5X+VDia7UTLgJBx8=",
|
"aarch64-linux": "sha256-b5sO7V+/zzJClHHKjkSz+9AUBYC8cb7S3m5ab1kpAyk=",
|
||||||
"aarch64-darwin": "sha256-bMUeI1LcBYgKBwG92WazTgxNryZF2Gv9iQgK46Pd+3A=",
|
"aarch64-darwin": "sha256-V66nmRX6kAjrc41ARVeuTElWK7KD8qG/DVk9K7Fu+J8=",
|
||||||
"x86_64-darwin": "sha256-fJbEd1j8ObZ2OMykYVU6v0uI1gy2eoCFIZ9ovuiNeLY="
|
"x86_64-darwin": "sha256-cFyh60WESiqZ5XWZi1+g3F/beSDL1+UPG8KhRivhK8w="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-3
@@ -112,8 +112,6 @@
|
|||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
|
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch"
|
||||||
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
|
|
||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-145
@@ -9,7 +9,6 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
|||||||
import {
|
import {
|
||||||
dropdownMenuTriggerSelector,
|
dropdownMenuTriggerSelector,
|
||||||
dropdownMenuContentSelector,
|
dropdownMenuContentSelector,
|
||||||
projectSwitchSelector,
|
|
||||||
projectMenuTriggerSelector,
|
projectMenuTriggerSelector,
|
||||||
projectCloseMenuSelector,
|
projectCloseMenuSelector,
|
||||||
projectWorkspacesToggleSelector,
|
projectWorkspacesToggleSelector,
|
||||||
@@ -24,16 +23,6 @@ import {
|
|||||||
workspaceMenuTriggerSelector,
|
workspaceMenuTriggerSelector,
|
||||||
} from "./selectors"
|
} from "./selectors"
|
||||||
|
|
||||||
const phase = new WeakMap<Page, "test" | "cleanup">()
|
|
||||||
|
|
||||||
export function setHealthPhase(page: Page, value: "test" | "cleanup") {
|
|
||||||
phase.set(page, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function healthPhase(page: Page) {
|
|
||||||
return phase.get(page) ?? "test"
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function defocus(page: Page) {
|
export async function defocus(page: Page) {
|
||||||
await page
|
await page
|
||||||
.evaluate(() => {
|
.evaluate(() => {
|
||||||
@@ -207,49 +196,9 @@ export async function closeDialog(page: Page, dialog: Locator) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function isSidebarClosed(page: Page) {
|
export async function isSidebarClosed(page: Page) {
|
||||||
const button = await waitSidebarButton(page, "isSidebarClosed")
|
|
||||||
return (await button.getAttribute("aria-expanded")) !== "true"
|
|
||||||
}
|
|
||||||
|
|
||||||
async function errorBoundaryText(page: Page) {
|
|
||||||
const title = page.getByRole("heading", { name: /something went wrong/i }).first()
|
|
||||||
if (!(await title.isVisible().catch(() => false))) return
|
|
||||||
|
|
||||||
const description = await page
|
|
||||||
.getByText(/an error occurred while loading the application\./i)
|
|
||||||
.first()
|
|
||||||
.textContent()
|
|
||||||
.catch(() => "")
|
|
||||||
const detail = await page
|
|
||||||
.getByRole("textbox", { name: /error details/i })
|
|
||||||
.first()
|
|
||||||
.inputValue()
|
|
||||||
.catch(async () =>
|
|
||||||
(
|
|
||||||
(await page
|
|
||||||
.getByRole("textbox", { name: /error details/i })
|
|
||||||
.first()
|
|
||||||
.textContent()
|
|
||||||
.catch(() => "")) ?? ""
|
|
||||||
).trim(),
|
|
||||||
)
|
|
||||||
|
|
||||||
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function assertHealthy(page: Page, context: string) {
|
|
||||||
const text = await errorBoundaryText(page)
|
|
||||||
if (!text) return
|
|
||||||
console.log(`[e2e:error-boundary][${context}]\n${text}`)
|
|
||||||
throw new Error(`Error boundary during ${context}\n${text}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitSidebarButton(page: Page, context: string) {
|
|
||||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||||
const boundary = page.getByRole("heading", { name: /something went wrong/i }).first()
|
await expect(button).toBeVisible()
|
||||||
await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 })
|
return (await button.getAttribute("aria-expanded")) !== "true"
|
||||||
await assertHealthy(page, context)
|
|
||||||
return button
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleSidebar(page: Page) {
|
export async function toggleSidebar(page: Page) {
|
||||||
@@ -260,7 +209,7 @@ export async function toggleSidebar(page: Page) {
|
|||||||
export async function openSidebar(page: Page) {
|
export async function openSidebar(page: Page) {
|
||||||
if (!(await isSidebarClosed(page))) return
|
if (!(await isSidebarClosed(page))) return
|
||||||
|
|
||||||
const button = await waitSidebarButton(page, "openSidebar")
|
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||||
await button.click()
|
await button.click()
|
||||||
|
|
||||||
const opened = await expect(button)
|
const opened = await expect(button)
|
||||||
@@ -277,7 +226,7 @@ export async function openSidebar(page: Page) {
|
|||||||
export async function closeSidebar(page: Page) {
|
export async function closeSidebar(page: Page) {
|
||||||
if (await isSidebarClosed(page)) return
|
if (await isSidebarClosed(page)) return
|
||||||
|
|
||||||
const button = await waitSidebarButton(page, "closeSidebar")
|
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||||
await button.click()
|
await button.click()
|
||||||
|
|
||||||
const closed = await expect(button)
|
const closed = await expect(button)
|
||||||
@@ -292,7 +241,6 @@ export async function closeSidebar(page: Page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function openSettings(page: Page) {
|
export async function openSettings(page: Page) {
|
||||||
await assertHealthy(page, "openSettings")
|
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
const dialog = page.getByRole("dialog")
|
||||||
@@ -305,8 +253,6 @@ export async function openSettings(page: Page) {
|
|||||||
|
|
||||||
if (opened) return dialog
|
if (opened) return dialog
|
||||||
|
|
||||||
await assertHealthy(page, "openSettings")
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Settings" }).first().click()
|
await page.getByRole("button", { name: "Settings" }).first().click()
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
return dialog
|
return dialog
|
||||||
@@ -368,12 +314,10 @@ export async function seedProjects(page: Page, input: { directory: string; extra
|
|||||||
|
|
||||||
export async function createTestProject() {
|
export async function createTestProject() {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
||||||
const id = `e2e-${path.basename(root)}`
|
|
||||||
|
|
||||||
await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`)
|
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
|
||||||
|
|
||||||
execSync("git init", { cwd: root, stdio: "ignore" })
|
execSync("git init", { cwd: root, stdio: "ignore" })
|
||||||
await fs.writeFile(path.join(root, ".git", "opencode"), id)
|
|
||||||
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
|
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
|
||||||
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
||||||
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
||||||
@@ -395,24 +339,12 @@ export function slugFromUrl(url: string) {
|
|||||||
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
|
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
async function probeSession(page: Page) {
|
|
||||||
return page
|
|
||||||
.evaluate(() => {
|
|
||||||
const win = window as E2EWindow
|
|
||||||
const current = win.__opencode_e2e?.model?.current
|
|
||||||
if (!current) return null
|
|
||||||
return { dir: current.dir, sessionID: current.sessionID }
|
|
||||||
})
|
|
||||||
.catch(() => null as { dir?: string; sessionID?: string } | null)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function waitSlug(page: Page, skip: string[] = []) {
|
export async function waitSlug(page: Page, skip: string[] = []) {
|
||||||
let prev = ""
|
let prev = ""
|
||||||
let next = ""
|
let next = ""
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
() => {
|
||||||
await assertHealthy(page, "waitSlug")
|
|
||||||
const slug = slugFromUrl(page.url())
|
const slug = slugFromUrl(page.url())
|
||||||
if (!slug) return ""
|
if (!slug) return ""
|
||||||
if (skip.includes(slug)) return ""
|
if (skip.includes(slug)) return ""
|
||||||
@@ -442,7 +374,6 @@ export async function waitDir(page: Page, directory: string) {
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
await assertHealthy(page, "waitDir")
|
|
||||||
const slug = slugFromUrl(page.url())
|
const slug = slugFromUrl(page.url())
|
||||||
if (!slug) return ""
|
if (!slug) return ""
|
||||||
return resolveSlug(slug)
|
return resolveSlug(slug)
|
||||||
@@ -455,69 +386,6 @@ export async function waitDir(page: Page, directory: string) {
|
|||||||
return { directory: target, slug: base64Encode(target) }
|
return { directory: target, slug: base64Encode(target) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
|
|
||||||
const target = await resolveDirectory(input.directory)
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
await assertHealthy(page, "waitSession")
|
|
||||||
const slug = slugFromUrl(page.url())
|
|
||||||
if (!slug) return false
|
|
||||||
const resolved = await resolveSlug(slug).catch(() => undefined)
|
|
||||||
if (!resolved || resolved.directory !== target) return false
|
|
||||||
if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false
|
|
||||||
|
|
||||||
const state = await probeSession(page)
|
|
||||||
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
|
||||||
if (state?.dir) {
|
|
||||||
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
|
|
||||||
if (dir !== target) return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return page
|
|
||||||
.locator(promptSelector)
|
|
||||||
.first()
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false)
|
|
||||||
},
|
|
||||||
{ timeout: 45_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
return { directory: target, slug: base64Encode(target) }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
|
|
||||||
const sdk = createSdk(directory)
|
|
||||||
const target = await resolveDirectory(directory)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const data = await sdk.session
|
|
||||||
.get({ sessionID })
|
|
||||||
.then((x) => x.data)
|
|
||||||
.catch(() => undefined)
|
|
||||||
if (!data?.directory) return ""
|
|
||||||
return resolveDirectory(data.directory).catch(() => data.directory)
|
|
||||||
},
|
|
||||||
{ timeout },
|
|
||||||
)
|
|
||||||
.toBe(target)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const items = await sdk.session
|
|
||||||
.messages({ sessionID, limit: 20 })
|
|
||||||
.then((x) => x.data ?? [])
|
|
||||||
.catch(() => [])
|
|
||||||
return items.some((item) => item.info.role === "user")
|
|
||||||
},
|
|
||||||
{ timeout },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sessionIDFromUrl(url: string) {
|
export function sessionIDFromUrl(url: string) {
|
||||||
const match = /\/session\/([^/?#]+)/.exec(url)
|
const match = /\/session\/([^/?#]+)/.exec(url)
|
||||||
return match?.[1]
|
return match?.[1]
|
||||||
@@ -929,14 +797,8 @@ export async function openStatusPopover(page: Page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function openProjectMenu(page: Page, projectSlug: string) {
|
export async function openProjectMenu(page: Page, projectSlug: string) {
|
||||||
await openSidebar(page)
|
|
||||||
const item = page.locator(projectSwitchSelector(projectSlug)).first()
|
|
||||||
await expect(item).toBeVisible()
|
|
||||||
await item.hover()
|
|
||||||
|
|
||||||
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
|
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
|
||||||
await expect(trigger).toHaveCount(1)
|
await expect(trigger).toHaveCount(1)
|
||||||
await expect(trigger).toBeVisible()
|
|
||||||
|
|
||||||
const menu = page
|
const menu = page
|
||||||
.locator(dropdownMenuContentSelector)
|
.locator(dropdownMenuContentSelector)
|
||||||
@@ -945,7 +807,7 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
|
|||||||
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
|
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
|
||||||
|
|
||||||
const clicked = await trigger
|
const clicked = await trigger
|
||||||
.click({ force: true, timeout: 1500 })
|
.click({ timeout: 1500 })
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,7 @@
|
|||||||
import { test as base, expect, type Page } from "@playwright/test"
|
import { test as base, expect, type Page } from "@playwright/test"
|
||||||
import type { E2EWindow } from "../src/testing/terminal"
|
import type { E2EWindow } from "../src/testing/terminal"
|
||||||
import {
|
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
|
||||||
healthPhase,
|
import { promptSelector } from "./selectors"
|
||||||
cleanupSession,
|
|
||||||
cleanupTestProject,
|
|
||||||
createTestProject,
|
|
||||||
setHealthPhase,
|
|
||||||
seedProjects,
|
|
||||||
sessionIDFromUrl,
|
|
||||||
waitSlug,
|
|
||||||
waitSession,
|
|
||||||
} from "./actions"
|
|
||||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||||
|
|
||||||
export const settingsKey = "settings.v3"
|
export const settingsKey = "settings.v3"
|
||||||
@@ -36,29 +27,6 @@ type WorkerFixtures = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||||
page: async ({ page }, use) => {
|
|
||||||
let boundary: string | undefined
|
|
||||||
setHealthPhase(page, "test")
|
|
||||||
const consoleHandler = (msg: { text(): string }) => {
|
|
||||||
const text = msg.text()
|
|
||||||
if (!text.includes("[e2e:error-boundary]")) return
|
|
||||||
if (healthPhase(page) === "cleanup") {
|
|
||||||
console.warn(`[e2e:error-boundary][cleanup-warning]\n${text}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
boundary ||= text
|
|
||||||
console.log(text)
|
|
||||||
}
|
|
||||||
const pageErrorHandler = (err: Error) => {
|
|
||||||
console.log(`[e2e:pageerror] ${err.stack || err.message}`)
|
|
||||||
}
|
|
||||||
page.on("console", consoleHandler)
|
|
||||||
page.on("pageerror", pageErrorHandler)
|
|
||||||
await use(page)
|
|
||||||
page.off("console", consoleHandler)
|
|
||||||
page.off("pageerror", pageErrorHandler)
|
|
||||||
if (boundary) throw new Error(boundary)
|
|
||||||
},
|
|
||||||
directory: [
|
directory: [
|
||||||
async ({}, use) => {
|
async ({}, use) => {
|
||||||
const directory = await getWorktree()
|
const directory = await getWorktree()
|
||||||
@@ -80,20 +48,21 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
|
|
||||||
const gotoSession = async (sessionID?: string) => {
|
const gotoSession = async (sessionID?: string) => {
|
||||||
await page.goto(sessionPath(directory, sessionID))
|
await page.goto(sessionPath(directory, sessionID))
|
||||||
await waitSession(page, { directory, sessionID })
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
}
|
}
|
||||||
await use(gotoSession)
|
await use(gotoSession)
|
||||||
},
|
},
|
||||||
withProject: async ({ page }, use) => {
|
withProject: async ({ page }, use) => {
|
||||||
await use(async (callback, options) => {
|
await use(async (callback, options) => {
|
||||||
const root = await createTestProject()
|
const root = await createTestProject()
|
||||||
|
const slug = dirSlug(root)
|
||||||
const sessions = new Map<string, string>()
|
const sessions = new Map<string, string>()
|
||||||
const dirs = new Set<string>()
|
const dirs = new Set<string>()
|
||||||
await seedStorage(page, { directory: root, extra: options?.extra })
|
await seedStorage(page, { directory: root, extra: options?.extra })
|
||||||
|
|
||||||
const gotoSession = async (sessionID?: string) => {
|
const gotoSession = async (sessionID?: string) => {
|
||||||
await page.goto(sessionPath(root, sessionID))
|
await page.goto(sessionPath(root, sessionID))
|
||||||
await waitSession(page, { directory: root, sessionID })
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
const current = sessionIDFromUrl(page.url())
|
const current = sessionIDFromUrl(page.url())
|
||||||
if (current) trackSession(current)
|
if (current) trackSession(current)
|
||||||
}
|
}
|
||||||
@@ -108,16 +77,13 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
const slug = await waitSlug(page)
|
|
||||||
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
|
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
|
||||||
} finally {
|
} finally {
|
||||||
setHealthPhase(page, "cleanup")
|
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
|
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
|
||||||
)
|
)
|
||||||
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
||||||
await cleanupTestProject(root)
|
await cleanupTestProject(root)
|
||||||
setHealthPhase(page, "test")
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
|
import type { Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import {
|
import {
|
||||||
defocus,
|
defocus,
|
||||||
@@ -6,14 +7,43 @@ import {
|
|||||||
cleanupTestProject,
|
cleanupTestProject,
|
||||||
openSidebar,
|
openSidebar,
|
||||||
sessionIDFromUrl,
|
sessionIDFromUrl,
|
||||||
setWorkspacesEnabled,
|
waitDir,
|
||||||
waitSession,
|
|
||||||
waitSessionSaved,
|
|
||||||
waitSlug,
|
waitSlug,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
import { dirSlug, resolveDirectory } from "../utils"
|
import { dirSlug, resolveDirectory } from "../utils"
|
||||||
|
|
||||||
|
async function workspaces(page: Page, directory: string, enabled: boolean) {
|
||||||
|
await page.evaluate(
|
||||||
|
({ directory, enabled }: { directory: string; enabled: boolean }) => {
|
||||||
|
const key = "opencode.global.dat:layout"
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
const data = raw ? JSON.parse(raw) : {}
|
||||||
|
const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
|
||||||
|
const current =
|
||||||
|
sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
|
||||||
|
? sidebar.workspaces
|
||||||
|
: {}
|
||||||
|
const next = { ...current }
|
||||||
|
|
||||||
|
if (enabled) next[directory] = true
|
||||||
|
if (!enabled) delete next[directory]
|
||||||
|
|
||||||
|
localStorage.setItem(
|
||||||
|
key,
|
||||||
|
JSON.stringify({
|
||||||
|
...data,
|
||||||
|
sidebar: {
|
||||||
|
...sidebar,
|
||||||
|
workspaces: next,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ directory, enabled },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
@@ -54,7 +84,9 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
await withProject(
|
await withProject(
|
||||||
async ({ directory, slug, trackSession, trackDirectory }) => {
|
async ({ directory, slug, trackSession, trackDirectory }) => {
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
await setWorkspacesEnabled(page, slug, true)
|
await workspaces(page, directory, true)
|
||||||
|
await page.reload()
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
|
|
||||||
@@ -76,7 +108,8 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
await expect(btn).toBeVisible()
|
await expect(btn).toBeVisible()
|
||||||
await btn.click({ force: true })
|
await btn.click({ force: true })
|
||||||
|
|
||||||
await waitSession(page, { directory: space })
|
await waitSlug(page)
|
||||||
|
await waitDir(page, space)
|
||||||
|
|
||||||
// Create a session by sending a prompt
|
// Create a session by sending a prompt
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
@@ -90,7 +123,6 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
const created = sessionIDFromUrl(page.url())
|
const created = sessionIDFromUrl(page.url())
|
||||||
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
||||||
trackSession(created, space)
|
trackSession(created, space)
|
||||||
await waitSessionSaved(space, created)
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
||||||
|
|
||||||
@@ -98,14 +130,15 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
|
|
||||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
await expect(otherButton).toBeVisible()
|
await expect(otherButton).toBeVisible()
|
||||||
await otherButton.click({ force: true })
|
await otherButton.click()
|
||||||
await waitSession(page, { directory: other })
|
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||||
|
|
||||||
const rootButton = page.locator(projectSwitchSelector(slug)).first()
|
const rootButton = page.locator(projectSwitchSelector(slug)).first()
|
||||||
await expect(rootButton).toBeVisible()
|
await expect(rootButton).toBeVisible()
|
||||||
await rootButton.click({ force: true })
|
await rootButton.click()
|
||||||
|
|
||||||
await waitSession(page, { directory: space, sessionID: created })
|
await waitDir(page, space)
|
||||||
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
|
||||||
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
||||||
},
|
},
|
||||||
{ extra: [other] },
|
{ extra: [other] },
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import {
|
import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitDir, waitSlug } from "../actions"
|
||||||
openSidebar,
|
|
||||||
resolveSlug,
|
|
||||||
sessionIDFromUrl,
|
|
||||||
setWorkspacesEnabled,
|
|
||||||
waitDir,
|
|
||||||
waitSession,
|
|
||||||
waitSessionSaved,
|
|
||||||
waitSlug,
|
|
||||||
} from "../actions"
|
|
||||||
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
import { createSdk } from "../utils"
|
import { createSdk } from "../utils"
|
||||||
|
|
||||||
@@ -23,7 +14,20 @@ function button(space: { slug: string; raw: string }) {
|
|||||||
|
|
||||||
async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
|
async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await expect(page.locator(item(space)).first()).toBeVisible({ timeout: 60_000 })
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const row = page.locator(item(space)).first()
|
||||||
|
try {
|
||||||
|
await row.hover({ timeout: 500 })
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
||||||
@@ -45,8 +49,7 @@ async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: s
|
|||||||
await expect(next).toBeVisible()
|
await expect(next).toBeVisible()
|
||||||
await next.click({ force: true })
|
await next.click({ force: true })
|
||||||
|
|
||||||
await waitSession(page, { directory: space.directory })
|
return waitDir(page, space.directory)
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe("")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSessionFromWorkspace(
|
async function createSessionFromWorkspace(
|
||||||
@@ -54,28 +57,39 @@ async function createSessionFromWorkspace(
|
|||||||
space: { slug: string; raw: string; directory: string },
|
space: { slug: string; raw: string; directory: string },
|
||||||
text: string,
|
text: string,
|
||||||
) {
|
) {
|
||||||
await openWorkspaceNewSession(page, space)
|
const next = await openWorkspaceNewSession(page, space)
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
await expect(prompt).toBeVisible()
|
await expect(prompt).toBeVisible()
|
||||||
|
await expect(prompt).toBeEditable()
|
||||||
|
await prompt.click()
|
||||||
|
await expect(prompt).toBeFocused()
|
||||||
await prompt.fill(text)
|
await prompt.fill(text)
|
||||||
await page.keyboard.press("Enter")
|
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
|
||||||
|
await prompt.press("Enter")
|
||||||
|
|
||||||
|
await waitDir(page, next.directory)
|
||||||
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
||||||
|
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
|
|
||||||
const sessionID = sessionIDFromUrl(page.url())
|
const sessionID = sessionIDFromUrl(page.url())
|
||||||
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
|
||||||
|
return { sessionID, slug: next.slug }
|
||||||
|
}
|
||||||
|
|
||||||
await waitSessionSaved(space.directory, sessionID)
|
async function sessionDirectory(directory: string, sessionID: string) {
|
||||||
await createSdk(space.directory)
|
const info = await createSdk(directory)
|
||||||
.session.abort({ sessionID })
|
.session.get({ sessionID })
|
||||||
|
.then((x) => x.data)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
return sessionID
|
if (!info) return ""
|
||||||
|
return info.directory
|
||||||
}
|
}
|
||||||
|
|
||||||
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
|
await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await setWorkspacesEnabled(page, root, true)
|
await setWorkspacesEnabled(page, root, true)
|
||||||
|
|
||||||
@@ -87,8 +101,17 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a
|
|||||||
trackDirectory(second.directory)
|
trackDirectory(second.directory)
|
||||||
await waitWorkspaceReady(page, second)
|
await waitWorkspaceReady(page, second)
|
||||||
|
|
||||||
trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory)
|
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
||||||
trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory)
|
trackSession(firstSession.sessionID, first.directory)
|
||||||
trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
|
|
||||||
|
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
|
||||||
|
trackSession(secondSession.sessionID, second.directory)
|
||||||
|
|
||||||
|
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
|
||||||
|
trackSession(thirdSession.sessionID, first.directory)
|
||||||
|
|
||||||
|
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
|
||||||
|
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
|
||||||
|
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
import type { Locator, Page } from "@playwright/test"
|
import type { Locator, Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import {
|
import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions"
|
||||||
openSidebar,
|
|
||||||
resolveSlug,
|
|
||||||
sessionIDFromUrl,
|
|
||||||
setWorkspacesEnabled,
|
|
||||||
waitSession,
|
|
||||||
waitSessionIdle,
|
|
||||||
waitSlug,
|
|
||||||
} from "../actions"
|
|
||||||
import {
|
import {
|
||||||
promptAgentSelector,
|
promptAgentSelector,
|
||||||
promptModelSelector,
|
promptModelSelector,
|
||||||
@@ -37,6 +29,8 @@ const text = async (locator: Locator) => ((await locator.textContent()) ?? "").t
|
|||||||
|
|
||||||
const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null)
|
const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null)
|
||||||
|
|
||||||
|
const dirKey = (state: Probe | null) => state?.dir ?? ""
|
||||||
|
|
||||||
async function probe(page: Page): Promise<Probe | null> {
|
async function probe(page: Page): Promise<Probe | null> {
|
||||||
return page.evaluate(() => {
|
return page.evaluate(() => {
|
||||||
const win = window as Window & {
|
const win = window as Window & {
|
||||||
@@ -50,6 +44,21 @@ async function probe(page: Page): Promise<Probe | null> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function currentDir(page: Page) {
|
||||||
|
let hit = ""
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const next = dirKey(await probe(page))
|
||||||
|
if (next) hit = next
|
||||||
|
return next
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.not.toBe("")
|
||||||
|
return hit
|
||||||
|
}
|
||||||
|
|
||||||
async function read(page: Page): Promise<Footer> {
|
async function read(page: Page): Promise<Footer> {
|
||||||
return {
|
return {
|
||||||
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
|
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
|
||||||
@@ -178,7 +187,8 @@ async function chooseOtherModel(page: Page): Promise<Footer> {
|
|||||||
|
|
||||||
async function goto(page: Page, directory: string, sessionID?: string) {
|
async function goto(page: Page, directory: string, sessionID?: string) {
|
||||||
await page.goto(sessionPath(directory, sessionID))
|
await page.goto(sessionPath(directory, sessionID))
|
||||||
await waitSession(page, { directory, sessionID })
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
await expect.poll(async () => dirKey(await probe(page)), { timeout: 30_000 }).toBe(directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit(page: Page, value: string) {
|
async function submit(page: Page, value: string) {
|
||||||
@@ -214,7 +224,7 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
|
|||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
||||||
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
|
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
|
||||||
await waitSession(page, { directory: next.directory })
|
await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +256,9 @@ async function newWorkspaceSession(page: Page, slug: string) {
|
|||||||
await button.click({ force: true })
|
await button.click({ force: true })
|
||||||
|
|
||||||
const next = await resolveSlug(await waitSlug(page))
|
const next = await resolveSlug(await waitSlug(page))
|
||||||
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
|
await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
return currentDir(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("session model and variant restore per session without leaking into new sessions", async ({
|
test("session model and variant restore per session without leaking into new sessions", async ({
|
||||||
@@ -265,7 +277,7 @@ test("session model and variant restore per session without leaking into new ses
|
|||||||
await waitUser(directory, first)
|
await waitUser(directory, first)
|
||||||
|
|
||||||
await page.reload()
|
await page.reload()
|
||||||
await waitSession(page, { directory, sessionID: first })
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
await waitFooter(page, firstState)
|
await waitFooter(page, firstState)
|
||||||
|
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|||||||
@@ -169,70 +169,6 @@ async function overflow(page: Parameters<typeof test>[0]["page"], file: string)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openReviewFile(page: Parameters<typeof test>[0]["page"], file: string) {
|
|
||||||
const row = page.locator(`[data-file="${file}"]`).first()
|
|
||||||
await expect(row).toBeVisible()
|
|
||||||
await row.hover()
|
|
||||||
|
|
||||||
const open = row.getByRole("button", { name: /^Open file$/i }).first()
|
|
||||||
await expect(open).toBeVisible()
|
|
||||||
await open.click()
|
|
||||||
|
|
||||||
const tab = page.getByRole("tab", { name: file }).first()
|
|
||||||
await expect(tab).toBeVisible()
|
|
||||||
await tab.click()
|
|
||||||
|
|
||||||
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
|
||||||
await expect(viewer).toBeVisible()
|
|
||||||
return viewer
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fileComment(page: Parameters<typeof test>[0]["page"], note: string) {
|
|
||||||
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
|
||||||
await expect(viewer).toBeVisible()
|
|
||||||
|
|
||||||
const line = viewer.locator('diffs-container [data-line="2"]').first()
|
|
||||||
await expect(line).toBeVisible()
|
|
||||||
await line.hover()
|
|
||||||
|
|
||||||
const add = viewer.getByRole("button", { name: /^Comment$/ }).first()
|
|
||||||
await expect(add).toBeVisible()
|
|
||||||
await add.click()
|
|
||||||
|
|
||||||
const area = viewer.locator('[data-slot="line-comment-textarea"]').first()
|
|
||||||
await expect(area).toBeVisible()
|
|
||||||
await area.fill(note)
|
|
||||||
|
|
||||||
const submit = viewer.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
|
|
||||||
await expect(submit).toBeEnabled()
|
|
||||||
await submit.click()
|
|
||||||
|
|
||||||
await expect(viewer.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
|
|
||||||
await expect(viewer.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
|
|
||||||
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
|
||||||
const view = page.locator('[role="tabpanel"] .scroll-view__viewport').first()
|
|
||||||
const pop = viewer.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
|
|
||||||
const tools = viewer.locator('[data-slot="line-comment-tools"]').first()
|
|
||||||
|
|
||||||
const [width, viewBox, popBox, toolsBox] = await Promise.all([
|
|
||||||
view.evaluate((el) => el.scrollWidth - el.clientWidth),
|
|
||||||
view.boundingBox(),
|
|
||||||
pop.boundingBox(),
|
|
||||||
tools.boundingBox(),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (!viewBox || !popBox || !toolsBox) return null
|
|
||||||
|
|
||||||
return {
|
|
||||||
width,
|
|
||||||
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
|
|
||||||
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
|
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
|
||||||
test.setTimeout(180_000)
|
test.setTimeout(180_000)
|
||||||
|
|
||||||
@@ -282,56 +218,6 @@ test("review applies inline comment clicks without horizontal overflow", async (
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
|
|
||||||
test.setTimeout(180_000)
|
|
||||||
|
|
||||||
const tag = `review-file-comment-${Date.now()}`
|
|
||||||
const file = `review-file-comment-${tag}.txt`
|
|
||||||
const note = `comment ${tag}`
|
|
||||||
|
|
||||||
await page.setViewportSize({ width: 1280, height: 900 })
|
|
||||||
|
|
||||||
await withProject(async (project) => {
|
|
||||||
const sdk = createSdk(project.directory)
|
|
||||||
|
|
||||||
await withSession(sdk, `e2e review file comment ${tag}`, async (session) => {
|
|
||||||
await patch(sdk, session.id, seed([{ file, mark: tag }]))
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
|
||||||
return diff.length
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(1)
|
|
||||||
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
await show(page)
|
|
||||||
|
|
||||||
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
|
||||||
await expect(tab).toBeVisible()
|
|
||||||
await tab.click()
|
|
||||||
|
|
||||||
await expand(page)
|
|
||||||
await waitMark(page, file, tag)
|
|
||||||
await openReviewFile(page, file)
|
|
||||||
await fileComment(page, note)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
|
||||||
.toBeLessThanOrEqual(1)
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
|
||||||
.toBeLessThanOrEqual(1)
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
|
||||||
.toBeLessThanOrEqual(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
|
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
|
||||||
test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
|
test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
|
||||||
test.setTimeout(180_000)
|
test.setTimeout(180_000)
|
||||||
|
|||||||
@@ -54,7 +54,6 @@
|
|||||||
"@solid-primitives/websocket": "1.3.1",
|
"@solid-primitives/websocket": "1.3.1",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
"@tanstack/solid-query": "5.91.4",
|
|
||||||
"@thisbeyond/solid-dnd": "0.7.5",
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { Splash } from "@opencode-ai/ui/logo"
|
|||||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
|
||||||
import { type Duration, Effect } from "effect"
|
import { type Duration, Effect } from "effect"
|
||||||
import {
|
import {
|
||||||
type Component,
|
type Component,
|
||||||
@@ -82,11 +81,6 @@ function MarkedProviderWithNativeParser(props: ParentProps) {
|
|||||||
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
|
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
function QueryProvider(props: ParentProps) {
|
|
||||||
const client = new QueryClient()
|
|
||||||
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
|
|
||||||
}
|
|
||||||
|
|
||||||
function AppShellProviders(props: ParentProps) {
|
function AppShellProviders(props: ParentProps) {
|
||||||
return (
|
return (
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
@@ -142,13 +136,11 @@ export function AppBaseProviders(props: ParentProps) {
|
|||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<UiI18nBridge>
|
<UiI18nBridge>
|
||||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||||
<QueryProvider>
|
<DialogProvider>
|
||||||
<DialogProvider>
|
<MarkedProviderWithNativeParser>
|
||||||
<MarkedProviderWithNativeParser>
|
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
</MarkedProviderWithNativeParser>
|
||||||
</MarkedProviderWithNativeParser>
|
</DialogProvider>
|
||||||
</DialogProvider>
|
|
||||||
</QueryProvider>
|
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</UiI18nBridge>
|
</UiI18nBridge>
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ import { showToast } from "@opencode-ai/ui/toast"
|
|||||||
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { DialogSelectModel } from "./dialog-select-model"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
|
|
||||||
export function DialogConnectProvider(props: { provider: string }) {
|
export function DialogConnectProvider(props: { provider: string }) {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export type FormState = {
|
|||||||
apiKey: string
|
apiKey: string
|
||||||
models: ModelRow[]
|
models: ModelRow[]
|
||||||
headers: HeaderRow[]
|
headers: HeaderRow[]
|
||||||
|
saving: boolean
|
||||||
err: {
|
err: {
|
||||||
providerID?: string
|
providerID?: string
|
||||||
name?: string
|
name?: string
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ describe("validateCustomProvider", () => {
|
|||||||
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} },
|
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} },
|
||||||
{ row: "h1", key: "", value: "", err: {} },
|
{ row: "h1", key: "", value: "", err: {} },
|
||||||
],
|
],
|
||||||
|
saving: false,
|
||||||
err: {},
|
err: {},
|
||||||
},
|
},
|
||||||
t,
|
t,
|
||||||
@@ -59,6 +60,7 @@ describe("validateCustomProvider", () => {
|
|||||||
{ row: "h0", key: "Authorization", value: "one", err: {} },
|
{ row: "h0", key: "Authorization", value: "one", err: {} },
|
||||||
{ row: "h1", key: "authorization", value: "two", err: {} },
|
{ row: "h1", key: "authorization", value: "two", err: {} },
|
||||||
],
|
],
|
||||||
|
saving: false,
|
||||||
err: {},
|
err: {},
|
||||||
},
|
},
|
||||||
t,
|
t,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { batch, For } from "solid-js"
|
import { batch, For } from "solid-js"
|
||||||
@@ -32,6 +31,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
apiKey: "",
|
apiKey: "",
|
||||||
models: [modelRow()],
|
models: [modelRow()],
|
||||||
headers: [headerRow()],
|
headers: [headerRow()],
|
||||||
|
saving: false,
|
||||||
err: {},
|
err: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -116,49 +116,48 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
return output.result
|
return output.result
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveMutation = useMutation(() => ({
|
const save = async (e: SubmitEvent) => {
|
||||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
e.preventDefault()
|
||||||
const disabledProviders = globalSync.data.config.disabled_providers ?? []
|
if (form.saving) return
|
||||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
|
||||||
|
|
||||||
if (result.key) {
|
const result = validate()
|
||||||
await globalSDK.client.auth.set({
|
if (!result) return
|
||||||
|
|
||||||
|
setForm("saving", true)
|
||||||
|
|
||||||
|
const disabledProviders = globalSync.data.config.disabled_providers ?? []
|
||||||
|
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||||
|
|
||||||
|
const auth = result.key
|
||||||
|
? globalSDK.client.auth.set({
|
||||||
providerID: result.providerID,
|
providerID: result.providerID,
|
||||||
auth: {
|
auth: {
|
||||||
type: "api",
|
type: "api",
|
||||||
key: result.key,
|
key: result.key,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
: Promise.resolve()
|
||||||
|
|
||||||
await globalSync.updateConfig({
|
auth
|
||||||
provider: { [result.providerID]: result.config },
|
.then(() =>
|
||||||
disabled_providers: nextDisabled,
|
globalSync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }),
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
dialog.close()
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
icon: "circle-check",
|
||||||
|
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
|
||||||
|
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
return result
|
.catch((err: unknown) => {
|
||||||
},
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
onSuccess: (result) => {
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
dialog.close()
|
})
|
||||||
showToast({
|
.finally(() => {
|
||||||
variant: "success",
|
setForm("saving", false)
|
||||||
icon: "circle-check",
|
|
||||||
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
|
|
||||||
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
|
|
||||||
})
|
})
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
|
||||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const save = (e: SubmitEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (saveMutation.isPending) return
|
|
||||||
|
|
||||||
const result = validate()
|
|
||||||
if (!result) return
|
|
||||||
saveMutation.mutate(result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -313,14 +312,8 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
|
||||||
class="w-auto self-start"
|
{form.saving ? language.t("common.saving") : language.t("common.submit")}
|
||||||
type="submit"
|
|
||||||
size="large"
|
|
||||||
variant="primary"
|
|
||||||
disabled={saveMutation.isPending}
|
|
||||||
>
|
|
||||||
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
|
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Button } from "@opencode-ai/ui/button"
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { createMemo, For, Show } from "solid-js"
|
import { createMemo, For, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
@@ -29,6 +28,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
color: props.project.icon?.color || "pink",
|
color: props.project.icon?.color || "pink",
|
||||||
iconUrl: props.project.icon?.override || "",
|
iconUrl: props.project.icon?.override || "",
|
||||||
startup: props.project.commands?.start ?? "",
|
startup: props.project.commands?.start ?? "",
|
||||||
|
saving: false,
|
||||||
dragOver: false,
|
dragOver: false,
|
||||||
iconHover: false,
|
iconHover: false,
|
||||||
})
|
})
|
||||||
@@ -71,37 +71,38 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
setStore("iconUrl", "")
|
setStore("iconUrl", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveMutation = useMutation(() => ({
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
mutationFn: async () => {
|
|
||||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
|
||||||
const start = store.startup.trim()
|
|
||||||
|
|
||||||
if (props.project.id && props.project.id !== "global") {
|
|
||||||
await globalSDK.client.project.update({
|
|
||||||
projectID: props.project.id,
|
|
||||||
directory: props.project.worktree,
|
|
||||||
name,
|
|
||||||
icon: { color: store.color, override: store.iconUrl },
|
|
||||||
commands: { start },
|
|
||||||
})
|
|
||||||
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
|
||||||
dialog.close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
globalSync.project.meta(props.project.worktree, {
|
|
||||||
name,
|
|
||||||
icon: { color: store.color, override: store.iconUrl || undefined },
|
|
||||||
commands: { start: start || undefined },
|
|
||||||
})
|
|
||||||
dialog.close()
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
function handleSubmit(e: SubmitEvent) {
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (saveMutation.isPending) return
|
|
||||||
saveMutation.mutate()
|
await Promise.resolve()
|
||||||
|
.then(async () => {
|
||||||
|
setStore("saving", true)
|
||||||
|
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||||
|
const start = store.startup.trim()
|
||||||
|
|
||||||
|
if (props.project.id && props.project.id !== "global") {
|
||||||
|
await globalSDK.client.project.update({
|
||||||
|
projectID: props.project.id,
|
||||||
|
directory: props.project.worktree,
|
||||||
|
name,
|
||||||
|
icon: { color: store.color, override: store.iconUrl },
|
||||||
|
commands: { start },
|
||||||
|
})
|
||||||
|
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
||||||
|
dialog.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
globalSync.project.meta(props.project.worktree, {
|
||||||
|
name,
|
||||||
|
icon: { color: store.color, override: store.iconUrl || undefined },
|
||||||
|
commands: { start: start || undefined },
|
||||||
|
})
|
||||||
|
dialog.close()
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setStore("saving", false)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -245,8 +246,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
|
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||||
{language.t("common.cancel")}
|
{language.t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
|
<Button type="submit" variant="primary" size="large" disabled={store.saving}>
|
||||||
{saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
|
{store.saving ? language.t("common.saving") : language.t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useMutation } from "@tanstack/solid-query"
|
import { Component, createMemo, createSignal, Show } from "solid-js"
|
||||||
import { Component, createMemo, Show } from "solid-js"
|
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
@@ -18,6 +17,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const [loading, setLoading] = createSignal<string | null>(null)
|
||||||
|
|
||||||
const items = createMemo(() =>
|
const items = createMemo(() =>
|
||||||
Object.entries(sync.data.mcp ?? {})
|
Object.entries(sync.data.mcp ?? {})
|
||||||
@@ -25,8 +25,10 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const toggle = useMutation(() => ({
|
const toggle = async (name: string) => {
|
||||||
mutationFn: async (name: string) => {
|
if (loading()) return
|
||||||
|
setLoading(name)
|
||||||
|
try {
|
||||||
const status = sync.data.mcp[name]
|
const status = sync.data.mcp[name]
|
||||||
if (status?.status === "connected") {
|
if (status?.status === "connected") {
|
||||||
await sdk.client.mcp.disconnect({ name })
|
await sdk.client.mcp.disconnect({ name })
|
||||||
@@ -36,8 +38,10 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
|
|
||||||
const result = await sdk.client.mcp.status()
|
const result = await sdk.client.mcp.status()
|
||||||
if (result.data) sync.set("mcp", result.data)
|
if (result.data) sync.set("mcp", result.data)
|
||||||
},
|
} finally {
|
||||||
}))
|
setLoading(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
||||||
const totalCount = createMemo(() => items().length)
|
const totalCount = createMemo(() => items().length)
|
||||||
@@ -55,8 +59,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
filterKeys={["name", "status"]}
|
filterKeys={["name", "status"]}
|
||||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (!x || toggle.isPending) return
|
if (x) toggle(x.name)
|
||||||
toggle.mutate(x.name)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => {
|
{(i) => {
|
||||||
@@ -80,7 +83,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
<Show when={statusLabel()}>
|
<Show when={statusLabel()}>
|
||||||
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
|
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={toggle.isPending && toggle.variables === i.name}>
|
<Show when={loading() === i.name}>
|
||||||
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
|
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,14 +92,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<Switch
|
<Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} />
|
||||||
checked={enabled()}
|
|
||||||
disabled={toggle.isPending && toggle.variables === i.name}
|
|
||||||
onChange={() => {
|
|
||||||
if (toggle.isPending) return
|
|
||||||
toggle.mutate(i.name)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Icon } from "@opencode-ai/ui/icon"
|
|||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
|
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
|
||||||
@@ -187,6 +186,7 @@ export function DialogSelectServer() {
|
|||||||
name: "",
|
name: "",
|
||||||
username: DEFAULT_USERNAME,
|
username: DEFAULT_USERNAME,
|
||||||
password: "",
|
password: "",
|
||||||
|
adding: false,
|
||||||
error: "",
|
error: "",
|
||||||
showForm: false,
|
showForm: false,
|
||||||
status: undefined as boolean | undefined,
|
status: undefined as boolean | undefined,
|
||||||
@@ -198,6 +198,7 @@ export function DialogSelectServer() {
|
|||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
error: "",
|
error: "",
|
||||||
|
busy: false,
|
||||||
status: undefined as boolean | undefined,
|
status: undefined as boolean | undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -208,6 +209,7 @@ export function DialogSelectServer() {
|
|||||||
name: "",
|
name: "",
|
||||||
username: DEFAULT_USERNAME,
|
username: DEFAULT_USERNAME,
|
||||||
password: "",
|
password: "",
|
||||||
|
adding: false,
|
||||||
error: "",
|
error: "",
|
||||||
showForm: false,
|
showForm: false,
|
||||||
status: undefined,
|
status: undefined,
|
||||||
@@ -222,78 +224,10 @@ export function DialogSelectServer() {
|
|||||||
password: "",
|
password: "",
|
||||||
error: "",
|
error: "",
|
||||||
status: undefined,
|
status: undefined,
|
||||||
|
busy: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const addMutation = useMutation(() => ({
|
|
||||||
mutationFn: async (value: string) => {
|
|
||||||
const normalized = normalizeServerUrl(value)
|
|
||||||
if (!normalized) {
|
|
||||||
resetAdd()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const conn: ServerConnection.Http = {
|
|
||||||
type: "http",
|
|
||||||
http: { url: normalized },
|
|
||||||
}
|
|
||||||
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
|
||||||
if (store.addServer.password) conn.http.password = store.addServer.password
|
|
||||||
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
|
||||||
const result = await checkServerHealth(conn.http)
|
|
||||||
if (!result.healthy) {
|
|
||||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resetAdd()
|
|
||||||
await select(conn, true)
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const editMutation = useMutation(() => ({
|
|
||||||
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
|
|
||||||
if (input.original.type !== "http") return
|
|
||||||
const normalized = normalizeServerUrl(input.value)
|
|
||||||
if (!normalized) {
|
|
||||||
resetEdit()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = store.editServer.name.trim() || undefined
|
|
||||||
const username = store.editServer.username || undefined
|
|
||||||
const password = store.editServer.password || undefined
|
|
||||||
const existingName = input.original.displayName
|
|
||||||
if (
|
|
||||||
normalized === input.original.http.url &&
|
|
||||||
name === existingName &&
|
|
||||||
username === input.original.http.username &&
|
|
||||||
password === input.original.http.password
|
|
||||||
) {
|
|
||||||
resetEdit()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const conn: ServerConnection.Http = {
|
|
||||||
type: "http",
|
|
||||||
displayName: name,
|
|
||||||
http: { url: normalized, username, password },
|
|
||||||
}
|
|
||||||
const result = await checkServerHealth(conn.http)
|
|
||||||
if (!result.healthy) {
|
|
||||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (normalized === input.original.http.url) {
|
|
||||||
server.add(conn)
|
|
||||||
} else {
|
|
||||||
replaceServer(input.original, conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
resetEdit()
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
||||||
const active = server.key
|
const active = server.key
|
||||||
const newConn = server.add(next)
|
const newConn = server.add(next)
|
||||||
@@ -362,7 +296,7 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAddChange = (value: string) => {
|
const handleAddChange = (value: string) => {
|
||||||
if (addMutation.isPending) return
|
if (store.addServer.adding) return
|
||||||
setStore("addServer", { url: value, error: "" })
|
setStore("addServer", { url: value, error: "" })
|
||||||
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
|
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
|
||||||
setStore("addServer", { status: next }),
|
setStore("addServer", { status: next }),
|
||||||
@@ -370,12 +304,12 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAddNameChange = (value: string) => {
|
const handleAddNameChange = (value: string) => {
|
||||||
if (addMutation.isPending) return
|
if (store.addServer.adding) return
|
||||||
setStore("addServer", { name: value, error: "" })
|
setStore("addServer", { name: value, error: "" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddUsernameChange = (value: string) => {
|
const handleAddUsernameChange = (value: string) => {
|
||||||
if (addMutation.isPending) return
|
if (store.addServer.adding) return
|
||||||
setStore("addServer", { username: value, error: "" })
|
setStore("addServer", { username: value, error: "" })
|
||||||
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
|
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
|
||||||
setStore("addServer", { status: next }),
|
setStore("addServer", { status: next }),
|
||||||
@@ -383,7 +317,7 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAddPasswordChange = (value: string) => {
|
const handleAddPasswordChange = (value: string) => {
|
||||||
if (addMutation.isPending) return
|
if (store.addServer.adding) return
|
||||||
setStore("addServer", { password: value, error: "" })
|
setStore("addServer", { password: value, error: "" })
|
||||||
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
|
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
|
||||||
setStore("addServer", { status: next }),
|
setStore("addServer", { status: next }),
|
||||||
@@ -391,7 +325,7 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleEditChange = (value: string) => {
|
const handleEditChange = (value: string) => {
|
||||||
if (editMutation.isPending) return
|
if (store.editServer.busy) return
|
||||||
setStore("editServer", { value, error: "" })
|
setStore("editServer", { value, error: "" })
|
||||||
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
|
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
|
||||||
setStore("editServer", { status: next }),
|
setStore("editServer", { status: next }),
|
||||||
@@ -399,12 +333,12 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleEditNameChange = (value: string) => {
|
const handleEditNameChange = (value: string) => {
|
||||||
if (editMutation.isPending) return
|
if (store.editServer.busy) return
|
||||||
setStore("editServer", { name: value, error: "" })
|
setStore("editServer", { name: value, error: "" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditUsernameChange = (value: string) => {
|
const handleEditUsernameChange = (value: string) => {
|
||||||
if (editMutation.isPending) return
|
if (store.editServer.busy) return
|
||||||
setStore("editServer", { username: value, error: "" })
|
setStore("editServer", { username: value, error: "" })
|
||||||
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
|
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
|
||||||
setStore("editServer", { status: next }),
|
setStore("editServer", { status: next }),
|
||||||
@@ -412,13 +346,85 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleEditPasswordChange = (value: string) => {
|
const handleEditPasswordChange = (value: string) => {
|
||||||
if (editMutation.isPending) return
|
if (store.editServer.busy) return
|
||||||
setStore("editServer", { password: value, error: "" })
|
setStore("editServer", { password: value, error: "" })
|
||||||
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
|
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
|
||||||
setStore("editServer", { status: next }),
|
setStore("editServer", { status: next }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAdd(value: string) {
|
||||||
|
if (store.addServer.adding) return
|
||||||
|
const normalized = normalizeServerUrl(value)
|
||||||
|
if (!normalized) {
|
||||||
|
resetAdd()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setStore("addServer", { adding: true, error: "" })
|
||||||
|
|
||||||
|
const conn: ServerConnection.Http = {
|
||||||
|
type: "http",
|
||||||
|
http: { url: normalized },
|
||||||
|
}
|
||||||
|
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
||||||
|
if (store.addServer.password) conn.http.password = store.addServer.password
|
||||||
|
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
||||||
|
const result = await checkServerHealth(conn.http)
|
||||||
|
setStore("addServer", { adding: false })
|
||||||
|
if (!result.healthy) {
|
||||||
|
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resetAdd()
|
||||||
|
await select(conn, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEdit(original: ServerConnection.Any, value: string) {
|
||||||
|
if (store.editServer.busy || original.type !== "http") return
|
||||||
|
const normalized = normalizeServerUrl(value)
|
||||||
|
if (!normalized) {
|
||||||
|
resetEdit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = store.editServer.name.trim() || undefined
|
||||||
|
const username = store.editServer.username || undefined
|
||||||
|
const password = store.editServer.password || undefined
|
||||||
|
const existingName = original.displayName
|
||||||
|
if (
|
||||||
|
normalized === original.http.url &&
|
||||||
|
name === existingName &&
|
||||||
|
username === original.http.username &&
|
||||||
|
password === original.http.password
|
||||||
|
) {
|
||||||
|
resetEdit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setStore("editServer", { busy: true, error: "" })
|
||||||
|
|
||||||
|
const conn: ServerConnection.Http = {
|
||||||
|
type: "http",
|
||||||
|
displayName: name,
|
||||||
|
http: { url: normalized, username, password },
|
||||||
|
}
|
||||||
|
const result = await checkServerHealth(conn.http)
|
||||||
|
setStore("editServer", { busy: false })
|
||||||
|
if (!result.healthy) {
|
||||||
|
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (normalized === original.http.url) {
|
||||||
|
server.add(conn)
|
||||||
|
} else {
|
||||||
|
replaceServer(original, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetEdit()
|
||||||
|
}
|
||||||
|
|
||||||
const mode = createMemo<"list" | "add" | "edit">(() => {
|
const mode = createMemo<"list" | "add" | "edit">(() => {
|
||||||
if (store.editServer.id) return "edit"
|
if (store.editServer.id) return "edit"
|
||||||
if (store.addServer.showForm) return "add"
|
if (store.addServer.showForm) return "add"
|
||||||
@@ -458,26 +464,23 @@ export function DialogSelectServer() {
|
|||||||
password: conn.http.password ?? "",
|
password: conn.http.password ?? "",
|
||||||
error: "",
|
error: "",
|
||||||
status: store.status[ServerConnection.key(conn)]?.healthy,
|
status: store.status[ServerConnection.key(conn)]?.healthy,
|
||||||
|
busy: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitForm = () => {
|
const submitForm = () => {
|
||||||
if (mode() === "add") {
|
if (mode() === "add") {
|
||||||
if (addMutation.isPending) return
|
void handleAdd(store.addServer.url)
|
||||||
setStore("addServer", { error: "" })
|
|
||||||
addMutation.mutate(store.addServer.url)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const original = editing()
|
const original = editing()
|
||||||
if (!original) return
|
if (!original) return
|
||||||
if (editMutation.isPending) return
|
void handleEdit(original, store.editServer.value)
|
||||||
setStore("editServer", { error: "" })
|
|
||||||
editMutation.mutate({ original, value: store.editServer.value })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isFormMode = createMemo(() => mode() !== "list")
|
const isFormMode = createMemo(() => mode() !== "list")
|
||||||
const isAddMode = createMemo(() => mode() === "add")
|
const isAddMode = createMemo(() => mode() === "add")
|
||||||
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
|
const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy))
|
||||||
|
|
||||||
const formTitle = createMemo(() => {
|
const formTitle = createMemo(() => {
|
||||||
if (!isFormMode()) return language.t("dialog.server.title")
|
if (!isFormMode()) return language.t("dialog.server.title")
|
||||||
|
|||||||
@@ -1383,16 +1383,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
|
||||||
accept={ACCEPTED_FILE_TYPES.join(",")}
|
accept={ACCEPTED_FILE_TYPES.join(",")}
|
||||||
class="hidden"
|
class="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const list = e.currentTarget.files
|
const file = e.currentTarget.files?.[0]
|
||||||
if (list) {
|
if (file) void addAttachment(file)
|
||||||
for (const file of Array.from(list)) {
|
|
||||||
void addAttachment(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
e.currentTarget.value = ""
|
e.currentTarget.value = ""
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-picker"
|
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||||
|
|
||||||
export { ACCEPTED_FILE_TYPES }
|
|
||||||
|
|
||||||
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
|
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
|
||||||
const IMAGE_EXTS = new Map([
|
const IMAGE_EXTS = new Map([
|
||||||
@@ -20,6 +18,61 @@ const TEXT_MIMES = new Set([
|
|||||||
"application/yaml",
|
"application/yaml",
|
||||||
])
|
])
|
||||||
|
|
||||||
|
export const ACCEPTED_FILE_TYPES = [
|
||||||
|
...ACCEPTED_IMAGE_TYPES,
|
||||||
|
"application/pdf",
|
||||||
|
"text/*",
|
||||||
|
"application/json",
|
||||||
|
"application/ld+json",
|
||||||
|
"application/toml",
|
||||||
|
"application/x-toml",
|
||||||
|
"application/x-yaml",
|
||||||
|
"application/xml",
|
||||||
|
"application/yaml",
|
||||||
|
".c",
|
||||||
|
".cc",
|
||||||
|
".cjs",
|
||||||
|
".conf",
|
||||||
|
".cpp",
|
||||||
|
".css",
|
||||||
|
".csv",
|
||||||
|
".cts",
|
||||||
|
".env",
|
||||||
|
".go",
|
||||||
|
".gql",
|
||||||
|
".graphql",
|
||||||
|
".h",
|
||||||
|
".hh",
|
||||||
|
".hpp",
|
||||||
|
".htm",
|
||||||
|
".html",
|
||||||
|
".ini",
|
||||||
|
".java",
|
||||||
|
".js",
|
||||||
|
".json",
|
||||||
|
".jsx",
|
||||||
|
".log",
|
||||||
|
".md",
|
||||||
|
".mdx",
|
||||||
|
".mjs",
|
||||||
|
".mts",
|
||||||
|
".py",
|
||||||
|
".rb",
|
||||||
|
".rs",
|
||||||
|
".sass",
|
||||||
|
".scss",
|
||||||
|
".sh",
|
||||||
|
".sql",
|
||||||
|
".toml",
|
||||||
|
".ts",
|
||||||
|
".tsx",
|
||||||
|
".txt",
|
||||||
|
".xml",
|
||||||
|
".yaml",
|
||||||
|
".yml",
|
||||||
|
".zsh",
|
||||||
|
]
|
||||||
|
|
||||||
const SAMPLE = 4096
|
const SAMPLE = 4096
|
||||||
|
|
||||||
function kind(type: string) {
|
function kind(type: string) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Icon } from "@opencode-ai/ui/icon"
|
|||||||
import { Popover } from "@opencode-ai/ui/popover"
|
import { Popover } from "@opencode-ai/ui/popover"
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
|
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||||
@@ -131,30 +130,41 @@ const useDefaultServerKey = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const useMcpToggleMutation = () => {
|
const useMcpToggle = (input: {
|
||||||
const sync = useSync()
|
sync: ReturnType<typeof useSync>
|
||||||
const sdk = useSDK()
|
sdk: ReturnType<typeof useSDK>
|
||||||
const language = useLanguage()
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}) => {
|
||||||
|
const [loading, setLoading] = createSignal<string | null>(null)
|
||||||
|
|
||||||
return useMutation(() => ({
|
const toggle = async (name: string) => {
|
||||||
mutationFn: async (name: string) => {
|
if (loading()) return
|
||||||
const status = sync.data.mcp[name]
|
setLoading(name)
|
||||||
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
|
|
||||||
const result = await sdk.client.mcp.status()
|
try {
|
||||||
if (result.data) sync.set("mcp", result.data)
|
const status = input.sync.data.mcp[name]
|
||||||
},
|
await (status?.status === "connected"
|
||||||
onError: (err) => {
|
? input.sdk.client.mcp.disconnect({ name })
|
||||||
|
: input.sdk.client.mcp.connect({ name }))
|
||||||
|
const result = await input.sdk.client.mcp.status()
|
||||||
|
if (result.data) input.sync.set("mcp", result.data)
|
||||||
|
} catch (err) {
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
title: language.t("common.requestFailed"),
|
title: input.language.t("common.requestFailed"),
|
||||||
description: err instanceof Error ? err.message : String(err),
|
description: err instanceof Error ? err.message : String(err),
|
||||||
})
|
})
|
||||||
},
|
} finally {
|
||||||
}))
|
setLoading(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { loading, toggle }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusPopover() {
|
export function StatusPopover() {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const sdk = useSDK()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
@@ -171,7 +181,7 @@ export function StatusPopover() {
|
|||||||
})
|
})
|
||||||
const health = useServerHealth(servers)
|
const health = useServerHealth(servers)
|
||||||
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
||||||
const toggleMcp = useMcpToggleMutation()
|
const mcp = useMcpToggle({ sync, sdk, language })
|
||||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||||
@@ -327,11 +337,8 @@ export function StatusPopover() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||||
onClick={() => {
|
onClick={() => mcp.toggle(name)}
|
||||||
if (toggleMcp.isPending) return
|
disabled={mcp.loading() === name}
|
||||||
toggleMcp.mutate(name)
|
|
||||||
}}
|
|
||||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
@@ -347,11 +354,8 @@ export function StatusPopover() {
|
|||||||
<div onClick={(event) => event.stopPropagation()}>
|
<div onClick={(event) => event.stopPropagation()}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={enabled()}
|
checked={enabled()}
|
||||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
disabled={mcp.loading() === name}
|
||||||
onChange={() => {
|
onChange={() => mcp.toggle(name)}
|
||||||
if (toggleMcp.isPending) return
|
|
||||||
toggleMcp.mutate(name)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
|
||||||
|
|
||||||
export const ACCEPTED_FILE_TYPES = [
|
|
||||||
...ACCEPTED_IMAGE_TYPES,
|
|
||||||
"application/pdf",
|
|
||||||
"text/*",
|
|
||||||
"application/json",
|
|
||||||
"application/ld+json",
|
|
||||||
"application/toml",
|
|
||||||
"application/x-toml",
|
|
||||||
"application/x-yaml",
|
|
||||||
"application/xml",
|
|
||||||
"application/yaml",
|
|
||||||
".c",
|
|
||||||
".cc",
|
|
||||||
".cjs",
|
|
||||||
".conf",
|
|
||||||
".cpp",
|
|
||||||
".css",
|
|
||||||
".csv",
|
|
||||||
".cts",
|
|
||||||
".env",
|
|
||||||
".go",
|
|
||||||
".gql",
|
|
||||||
".graphql",
|
|
||||||
".h",
|
|
||||||
".hh",
|
|
||||||
".hpp",
|
|
||||||
".htm",
|
|
||||||
".html",
|
|
||||||
".ini",
|
|
||||||
".java",
|
|
||||||
".js",
|
|
||||||
".json",
|
|
||||||
".jsx",
|
|
||||||
".log",
|
|
||||||
".md",
|
|
||||||
".mdx",
|
|
||||||
".mjs",
|
|
||||||
".mts",
|
|
||||||
".py",
|
|
||||||
".rb",
|
|
||||||
".rs",
|
|
||||||
".sass",
|
|
||||||
".scss",
|
|
||||||
".sh",
|
|
||||||
".sql",
|
|
||||||
".toml",
|
|
||||||
".ts",
|
|
||||||
".tsx",
|
|
||||||
".txt",
|
|
||||||
".xml",
|
|
||||||
".yaml",
|
|
||||||
".yml",
|
|
||||||
".zsh",
|
|
||||||
]
|
|
||||||
|
|
||||||
const MIME_EXT = new Map([
|
|
||||||
["image/png", "png"],
|
|
||||||
["image/jpeg", "jpg"],
|
|
||||||
["image/gif", "gif"],
|
|
||||||
["image/webp", "webp"],
|
|
||||||
["application/pdf", "pdf"],
|
|
||||||
["application/json", "json"],
|
|
||||||
["application/ld+json", "jsonld"],
|
|
||||||
["application/toml", "toml"],
|
|
||||||
["application/x-toml", "toml"],
|
|
||||||
["application/x-yaml", "yaml"],
|
|
||||||
["application/xml", "xml"],
|
|
||||||
["application/yaml", "yaml"],
|
|
||||||
])
|
|
||||||
|
|
||||||
const TEXT_EXT = ["txt", "text", "md", "markdown", "log", "csv"]
|
|
||||||
|
|
||||||
export const ACCEPTED_FILE_EXTENSIONS = Array.from(
|
|
||||||
new Set(
|
|
||||||
ACCEPTED_FILE_TYPES.flatMap((item) => {
|
|
||||||
if (item.startsWith(".")) return [item.slice(1)]
|
|
||||||
if (item === "text/*") return TEXT_EXT
|
|
||||||
const out = MIME_EXT.get(item)
|
|
||||||
return out ? [out] : []
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).sort()
|
|
||||||
|
|
||||||
export function filePickerFilters(ext?: string[]) {
|
|
||||||
if (!ext || ext.length === 0) return undefined
|
|
||||||
return [{ name: "Files", extensions: ext }]
|
|
||||||
}
|
|
||||||
@@ -378,7 +378,6 @@ function createGlobalSync() {
|
|||||||
return globalStore.error
|
return globalStore.error
|
||||||
},
|
},
|
||||||
child: children.child,
|
child: children.child,
|
||||||
peek: children.peek,
|
|
||||||
bootstrap,
|
bootstrap,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
project: projectApi,
|
project: projectApi,
|
||||||
|
|||||||
@@ -226,15 +226,6 @@ export function createChildStoreManager(input: {
|
|||||||
return childStore
|
return childStore
|
||||||
}
|
}
|
||||||
|
|
||||||
function peek(directory: string, options: ChildOptions = {}) {
|
|
||||||
const childStore = ensureChild(directory)
|
|
||||||
const shouldBootstrap = options.bootstrap ?? true
|
|
||||||
if (shouldBootstrap && childStore[0].status === "loading") {
|
|
||||||
input.onBootstrap(directory)
|
|
||||||
}
|
|
||||||
return childStore
|
|
||||||
}
|
|
||||||
|
|
||||||
function projectMeta(directory: string, patch: ProjectMeta) {
|
function projectMeta(directory: string, patch: ProjectMeta) {
|
||||||
const [store, setStore] = ensureChild(directory)
|
const [store, setStore] = ensureChild(directory)
|
||||||
const cached = metaCache.get(directory)
|
const cached = metaCache.get(directory)
|
||||||
@@ -265,7 +256,6 @@ export function createChildStoreManager(input: {
|
|||||||
children,
|
children,
|
||||||
ensureChild,
|
ensureChild,
|
||||||
child,
|
child,
|
||||||
peek,
|
|
||||||
projectMeta,
|
projectMeta,
|
||||||
projectIcon,
|
projectIcon,
|
||||||
mark,
|
mark,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ServerConnection } from "./server"
|
|||||||
|
|
||||||
type PickerPaths = string | string[] | null
|
type PickerPaths = string | string[] | null
|
||||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||||
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
|
type OpenFilePickerOptions = { title?: string; multiple?: boolean }
|
||||||
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
|
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
|
||||||
type UpdateInfo = { updateAvailable: boolean; version?: string }
|
type UpdateInfo = { updateAvailable: boolean; version?: string }
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ export const dict = {
|
|||||||
|
|
||||||
"command.sidebar.toggle": "Toggle sidebar",
|
"command.sidebar.toggle": "Toggle sidebar",
|
||||||
"command.project.open": "Open project",
|
"command.project.open": "Open project",
|
||||||
"command.project.previous": "Previous project",
|
|
||||||
"command.project.next": "Next project",
|
|
||||||
"command.provider.connect": "Connect provider",
|
"command.provider.connect": "Connect provider",
|
||||||
"command.server.switch": "Switch server",
|
"command.server.switch": "Switch server",
|
||||||
"command.settings.open": "Open settings",
|
"command.settings.open": "Open settings",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export { AppBaseProviders, AppInterface } from "./app"
|
export { AppBaseProviders, AppInterface } from "./app"
|
||||||
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
|
|
||||||
export { useCommand } from "./context/command"
|
export { useCommand } from "./context/command"
|
||||||
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
|
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
|
||||||
export { ServerConnection } from "./context/server"
|
export { ServerConnection } from "./context/server"
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { Logo } from "@opencode-ai/ui/logo"
|
import { Logo } from "@opencode-ai/ui/logo"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Component, Show, onMount } from "solid-js"
|
import { Component, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import type { E2EWindow } from "@/testing/terminal"
|
|
||||||
|
|
||||||
export type InitError = {
|
export type InitError = {
|
||||||
name: string
|
name: string
|
||||||
@@ -227,13 +226,6 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
|||||||
actionError: undefined as string | undefined,
|
actionError: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const win = window as E2EWindow
|
|
||||||
if (!win.__opencode_e2e) return
|
|
||||||
const detail = formatError(props.error, language.t)
|
|
||||||
console.error(`[e2e:error-boundary] ${window.location.pathname}\n${detail}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
async function checkForUpdates() {
|
async function checkForUpdates() {
|
||||||
if (!platform.checkUpdate) return
|
if (!platform.checkUpdate) return
|
||||||
setStore("checking", true)
|
setStore("checking", true)
|
||||||
|
|||||||
@@ -129,16 +129,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const initialDirectory = decode64(params.dir)
|
const initialDirectory = decode64(params.dir)
|
||||||
const route = createMemo(() => {
|
|
||||||
const slug = params.dir
|
|
||||||
if (!slug) return { slug, dir: "" }
|
|
||||||
const dir = decode64(slug)
|
|
||||||
if (!dir) return { slug, dir: "" }
|
|
||||||
return {
|
|
||||||
slug,
|
|
||||||
dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
|
const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
|
||||||
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
|
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
|
||||||
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
|
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
|
||||||
@@ -147,7 +137,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
dark: "theme.scheme.dark",
|
dark: "theme.scheme.dark",
|
||||||
}
|
}
|
||||||
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
|
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
|
||||||
const currentDir = createMemo(() => route().dir)
|
const currentDir = createMemo(() => decode64(params.dir) ?? "")
|
||||||
|
|
||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
autoselect: !initialDirectory,
|
autoselect: !initialDirectory,
|
||||||
@@ -494,8 +484,8 @@ export default function Layout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentSession = params.id
|
const currentSession = params.id
|
||||||
if (workspaceKey(directory) === workspaceKey(currentDir()) && props.sessionID === currentSession) return
|
if (directory === currentDir() && props.sessionID === currentSession) return
|
||||||
if (workspaceKey(directory) === workspaceKey(currentDir()) && session?.parentID === currentSession) return
|
if (directory === currentDir() && session?.parentID === currentSession) return
|
||||||
|
|
||||||
dismissSessionAlert(sessionKey)
|
dismissSessionAlert(sessionKey)
|
||||||
|
|
||||||
@@ -630,7 +620,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
const activeDir = currentDir()
|
const activeDir = currentDir()
|
||||||
return workspaceIds(project).filter((directory) => {
|
return workspaceIds(project).filter((directory) => {
|
||||||
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
|
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
|
||||||
const active = workspaceKey(directory) === workspaceKey(activeDir)
|
const active = directory === activeDir
|
||||||
return expanded || active
|
return expanded || active
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -697,7 +687,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
seen: lru,
|
seen: lru,
|
||||||
keep: sessionID,
|
keep: sessionID,
|
||||||
limit: PREFETCH_MAX_SESSIONS_PER_DIR,
|
limit: PREFETCH_MAX_SESSIONS_PER_DIR,
|
||||||
preserve: params.id && workspaceKey(directory) === workspaceKey(currentDir()) ? [params.id] : undefined,
|
preserve: directory === params.dir && params.id ? [params.id] : undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -710,7 +700,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
route()
|
params.dir
|
||||||
globalSDK.url
|
globalSDK.url
|
||||||
|
|
||||||
prefetchToken.value += 1
|
prefetchToken.value += 1
|
||||||
@@ -936,26 +926,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
navigateToSession(session)
|
navigateToSession(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateProjectByOffset(offset: number) {
|
|
||||||
const projects = layout.projects.list()
|
|
||||||
if (projects.length === 0) return
|
|
||||||
|
|
||||||
const current = currentProject()?.worktree
|
|
||||||
const fallback = currentDir() ? projectRoot(currentDir()) : undefined
|
|
||||||
const active = current ?? fallback
|
|
||||||
const index = active ? projects.findIndex((project) => project.worktree === active) : -1
|
|
||||||
|
|
||||||
const target =
|
|
||||||
index === -1
|
|
||||||
? offset > 0
|
|
||||||
? projects[0]
|
|
||||||
: projects[projects.length - 1]
|
|
||||||
: projects[(index + offset + projects.length) % projects.length]
|
|
||||||
if (!target) return
|
|
||||||
|
|
||||||
openProject(target.worktree)
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateSessionByUnseen(offset: number) {
|
function navigateSessionByUnseen(offset: number) {
|
||||||
const sessions = currentSessions()
|
const sessions = currentSessions()
|
||||||
if (sessions.length === 0) return
|
if (sessions.length === 0) return
|
||||||
@@ -1022,20 +992,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
keybind: "mod+o",
|
keybind: "mod+o",
|
||||||
onSelect: () => chooseProject(),
|
onSelect: () => chooseProject(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "project.previous",
|
|
||||||
title: language.t("command.project.previous"),
|
|
||||||
category: language.t("command.category.project"),
|
|
||||||
keybind: "mod+alt+arrowup",
|
|
||||||
onSelect: () => navigateProjectByOffset(-1),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "project.next",
|
|
||||||
title: language.t("command.project.next"),
|
|
||||||
category: language.t("command.category.project"),
|
|
||||||
keybind: "mod+alt+arrowdown",
|
|
||||||
onSelect: () => navigateProjectByOffset(1),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "provider.connect",
|
id: "provider.connect",
|
||||||
title: language.t("command.provider.connect"),
|
title: language.t("command.provider.connect"),
|
||||||
@@ -1736,10 +1692,13 @@ export default function Layout(props: ParentProps) {
|
|||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => {
|
() => {
|
||||||
return [pageReady(), route().slug, params.id, currentProject()?.worktree, currentDir()] as const
|
const dir = params.dir
|
||||||
|
const directory = dir ? decode64(dir) : undefined
|
||||||
|
const resolved = directory ? globalSync.child(directory, { bootstrap: false })[0].path.directory : ""
|
||||||
|
return [pageReady(), dir, params.id, currentProject()?.worktree, directory, resolved] as const
|
||||||
},
|
},
|
||||||
([ready, slug, id, root, dir]) => {
|
([ready, dir, id, root, directory, resolved]) => {
|
||||||
if (!ready || !slug || !dir) {
|
if (!ready || !dir || !directory) {
|
||||||
activeRoute.session = ""
|
activeRoute.session = ""
|
||||||
activeRoute.sessionProject = ""
|
activeRoute.sessionProject = ""
|
||||||
activeRoute.directory = ""
|
activeRoute.directory = ""
|
||||||
@@ -1753,28 +1712,29 @@ export default function Layout(props: ParentProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = `${slug}/${id}`
|
const next = resolved || directory
|
||||||
|
const session = `${dir}/${id}`
|
||||||
|
|
||||||
if (!root) {
|
if (!root) {
|
||||||
activeRoute.session = session
|
activeRoute.session = session
|
||||||
activeRoute.directory = dir
|
activeRoute.directory = next
|
||||||
activeRoute.sessionProject = ""
|
activeRoute.sessionProject = ""
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (server.projects.last() !== root) server.projects.touch(root)
|
if (server.projects.last() !== root) server.projects.touch(root)
|
||||||
|
|
||||||
const changed = session !== activeRoute.session || dir !== activeRoute.directory
|
const changed = session !== activeRoute.session || next !== activeRoute.directory
|
||||||
if (changed) {
|
if (changed) {
|
||||||
activeRoute.session = session
|
activeRoute.session = session
|
||||||
activeRoute.directory = dir
|
activeRoute.directory = next
|
||||||
activeRoute.sessionProject = syncSessionRoute(dir, id, root)
|
activeRoute.sessionProject = syncSessionRoute(next, id, root)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (root === activeRoute.sessionProject) return
|
if (root === activeRoute.sessionProject) return
|
||||||
activeRoute.directory = dir
|
activeRoute.directory = next
|
||||||
activeRoute.sessionProject = rememberSessionRoute(dir, id, root)
|
activeRoute.sessionProject = rememberSessionRoute(next, id, root)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1967,7 +1927,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
const projectSidebarCtx: ProjectSidebarContext = {
|
const projectSidebarCtx: ProjectSidebarContext = {
|
||||||
currentDir,
|
currentDir,
|
||||||
currentProject,
|
|
||||||
sidebarOpened: () => layout.sidebar.opened(),
|
sidebarOpened: () => layout.sidebar.opened(),
|
||||||
sidebarHovering,
|
sidebarHovering,
|
||||||
hoverProject: () => state.hoverProject,
|
hoverProject: () => state.hoverProject,
|
||||||
|
|||||||
@@ -40,10 +40,10 @@ export const latestRootSession = (stores: SessionStore[], now: number) =>
|
|||||||
stores.flatMap(roots).sort(sortSessions(now))[0]
|
stores.flatMap(roots).sort(sortSessions(now))[0]
|
||||||
|
|
||||||
export function hasProjectPermissions<T>(
|
export function hasProjectPermissions<T>(
|
||||||
request: Record<string, T[] | undefined> | undefined,
|
request: Record<string, T[] | undefined>,
|
||||||
include: (item: T) => boolean = () => true,
|
include: (item: T) => boolean = () => true,
|
||||||
) {
|
) {
|
||||||
return Object.values(request ?? {}).some((list) => list?.some(include))
|
return Object.values(request).some((list) => list?.some(include))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const childMapByParent = (sessions: Session[] | undefined) => {
|
export const childMapByParent = (sessions: Session[] | undefined) => {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
|
|||||||
|
|
||||||
export type ProjectSidebarContext = {
|
export type ProjectSidebarContext = {
|
||||||
currentDir: Accessor<string>
|
currentDir: Accessor<string>
|
||||||
currentProject: Accessor<LocalProject | undefined>
|
|
||||||
sidebarOpened: Accessor<boolean>
|
sidebarOpened: Accessor<boolean>
|
||||||
sidebarHovering: Accessor<boolean>
|
sidebarHovering: Accessor<boolean>
|
||||||
hoverProject: Accessor<string | undefined>
|
hoverProject: Accessor<string | undefined>
|
||||||
@@ -279,7 +278,11 @@ export const SortableProject = (props: {
|
|||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const sortable = createSortable(props.project.worktree)
|
const sortable = createSortable(props.project.worktree)
|
||||||
const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree)
|
const selected = createMemo(
|
||||||
|
() =>
|
||||||
|
props.project.worktree === props.ctx.currentDir() ||
|
||||||
|
props.project.sandboxes?.includes(props.ctx.currentDir()) === true,
|
||||||
|
)
|
||||||
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
|
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
|
||||||
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
|
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
|
||||||
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
|
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { type LocalProject } from "@/context/layout"
|
|||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
|
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
|
||||||
import { childMapByParent, sortedRootSessions, workspaceKey } from "./helpers"
|
import { childMapByParent, sortedRootSessions } from "./helpers"
|
||||||
|
|
||||||
type InlineEditorComponent = (props: {
|
type InlineEditorComponent = (props: {
|
||||||
id: string
|
id: string
|
||||||
@@ -323,7 +323,7 @@ export const SortableWorkspace = (props: {
|
|||||||
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
|
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
|
||||||
const children = createMemo(() => childMapByParent(workspaceStore.session))
|
const children = createMemo(() => childMapByParent(workspaceStore.session))
|
||||||
const local = createMemo(() => props.directory === props.project.worktree)
|
const local = createMemo(() => props.directory === props.project.worktree)
|
||||||
const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory))
|
const active = createMemo(() => props.ctx.currentDir() === props.directory)
|
||||||
const workspaceValue = createMemo(() => {
|
const workspaceValue = createMemo(() => {
|
||||||
const branch = workspaceStore.vcs?.branch
|
const branch = workspaceStore.vcs?.branch
|
||||||
const name = branch ?? getFilename(props.directory)
|
const name = branch ?? getFilename(props.directory)
|
||||||
|
|||||||
+121
-129
@@ -1,6 +1,5 @@
|
|||||||
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
|
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
|
||||||
import {
|
import {
|
||||||
batch,
|
batch,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
@@ -328,7 +327,10 @@ export default function Page() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const [ui, setUi] = createStore({
|
const [ui, setUi] = createStore({
|
||||||
|
git: false,
|
||||||
pendingMessage: undefined as string | undefined,
|
pendingMessage: undefined as string | undefined,
|
||||||
|
restoring: undefined as string | undefined,
|
||||||
|
reverting: false,
|
||||||
reviewSnap: false,
|
reviewSnap: false,
|
||||||
scrollGesture: 0,
|
scrollGesture: 0,
|
||||||
scroll: {
|
scroll: {
|
||||||
@@ -504,6 +506,7 @@ export default function Page() {
|
|||||||
|
|
||||||
const [followup, setFollowup] = createStore({
|
const [followup, setFollowup] = createStore({
|
||||||
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
|
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
|
||||||
|
sending: {} as Record<string, string | undefined>,
|
||||||
failed: {} as Record<string, string | undefined>,
|
failed: {} as Record<string, string | undefined>,
|
||||||
paused: {} as Record<string, boolean | undefined>,
|
paused: {} as Record<string, boolean | undefined>,
|
||||||
edit: {} as Record<
|
edit: {} as Record<
|
||||||
@@ -641,24 +644,25 @@ export default function Page() {
|
|||||||
globalSync.set("project", [...list, next])
|
globalSync.set("project", [...list, next])
|
||||||
}
|
}
|
||||||
|
|
||||||
const gitMutation = useMutation(() => ({
|
|
||||||
mutationFn: () => sdk.client.project.initGit(),
|
|
||||||
onSuccess: (x) => {
|
|
||||||
if (!x.data) return
|
|
||||||
upsert(x.data)
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: formatServerError(err, language.t),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
function initGit() {
|
function initGit() {
|
||||||
if (gitMutation.isPending) return
|
if (ui.git) return
|
||||||
gitMutation.mutate()
|
setUi("git", true)
|
||||||
|
void sdk.client.project
|
||||||
|
.initGit()
|
||||||
|
.then((x) => {
|
||||||
|
if (!x.data) return
|
||||||
|
upsert(x.data)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: formatServerError(err, language.t),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setUi("git", false)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let inputRef!: HTMLDivElement
|
let inputRef!: HTMLDivElement
|
||||||
@@ -957,8 +961,8 @@ export default function Page() {
|
|||||||
{language.t("session.review.noVcs.createGit.description")}
|
{language.t("session.review.noVcs.createGit.description")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
|
<Button size="large" disabled={ui.git} onClick={initGit}>
|
||||||
{gitMutation.isPending
|
{ui.git
|
||||||
? language.t("session.review.noVcs.createGit.actionLoading")
|
? language.t("session.review.noVcs.createGit.actionLoading")
|
||||||
: language.t("session.review.noVcs.createGit.action")}
|
: language.t("session.review.noVcs.createGit.action")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1375,40 +1379,10 @@ export default function Page() {
|
|||||||
return followup.edit[id]
|
return followup.edit[id]
|
||||||
})
|
})
|
||||||
|
|
||||||
const followupMutation = useMutation(() => ({
|
|
||||||
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
|
|
||||||
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
|
|
||||||
if (!item) return
|
|
||||||
|
|
||||||
if (input.manual) setFollowup("paused", input.sessionID, undefined)
|
|
||||||
setFollowup("failed", input.sessionID, undefined)
|
|
||||||
|
|
||||||
const ok = await sendFollowupDraft({
|
|
||||||
client: sdk.client,
|
|
||||||
sync,
|
|
||||||
globalSync,
|
|
||||||
draft: item,
|
|
||||||
optimisticBusy: item.sessionDirectory === sdk.directory,
|
|
||||||
}).catch((err) => {
|
|
||||||
setFollowup("failed", input.sessionID, input.id)
|
|
||||||
fail(err)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
if (!ok) return
|
|
||||||
|
|
||||||
setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id))
|
|
||||||
if (input.manual) resumeScroll()
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const followupBusy = (sessionID: string) =>
|
|
||||||
followupMutation.isPending && followupMutation.variables?.sessionID === sessionID
|
|
||||||
|
|
||||||
const sendingFollowup = createMemo(() => {
|
const sendingFollowup = createMemo(() => {
|
||||||
const id = params.id
|
const id = params.id
|
||||||
if (!id) return
|
if (!id) return
|
||||||
if (!followupBusy(id)) return
|
return followup.sending[id]
|
||||||
return followupMutation.variables?.id
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const queueEnabled = createMemo(() => {
|
const queueEnabled = createMemo(() => {
|
||||||
@@ -1448,15 +1422,37 @@ export default function Page() {
|
|||||||
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
|
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
|
||||||
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
|
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
|
||||||
if (!item) return Promise.resolve()
|
if (!item) return Promise.resolve()
|
||||||
if (followupBusy(sessionID)) return Promise.resolve()
|
if (followup.sending[sessionID]) return Promise.resolve()
|
||||||
|
|
||||||
return followupMutation.mutateAsync({ sessionID, id, manual: opts?.manual })
|
if (opts?.manual) setFollowup("paused", sessionID, undefined)
|
||||||
|
setFollowup("sending", sessionID, id)
|
||||||
|
setFollowup("failed", sessionID, undefined)
|
||||||
|
|
||||||
|
return sendFollowupDraft({
|
||||||
|
client: sdk.client,
|
||||||
|
sync,
|
||||||
|
globalSync,
|
||||||
|
draft: item,
|
||||||
|
optimisticBusy: item.sessionDirectory === sdk.directory,
|
||||||
|
})
|
||||||
|
.then((ok) => {
|
||||||
|
if (ok === false) return
|
||||||
|
setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id))
|
||||||
|
if (opts?.manual) resumeScroll()
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setFollowup("failed", sessionID, id)
|
||||||
|
fail(err)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setFollowup("sending", sessionID, (value) => (value === id ? undefined : value))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const editFollowup = (id: string) => {
|
const editFollowup = (id: string) => {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
if (followupBusy(sessionID)) return
|
if (followup.sending[sessionID]) return
|
||||||
|
|
||||||
const item = queuedFollowups().find((entry) => entry.id === id)
|
const item = queuedFollowups().find((entry) => entry.id === id)
|
||||||
if (!item) return
|
if (!item) return
|
||||||
@@ -1479,74 +1475,6 @@ export default function Page() {
|
|||||||
const halt = (sessionID: string) =>
|
const halt = (sessionID: string) =>
|
||||||
busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve()
|
busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve()
|
||||||
|
|
||||||
const revertMutation = useMutation(() => ({
|
|
||||||
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
|
||||||
const prev = prompt.current().slice()
|
|
||||||
const last = info()?.revert
|
|
||||||
const value = draft(input.messageID)
|
|
||||||
batch(() => {
|
|
||||||
roll(input.sessionID, { messageID: input.messageID })
|
|
||||||
prompt.set(value)
|
|
||||||
})
|
|
||||||
await halt(input.sessionID)
|
|
||||||
.then(() => sdk.client.session.revert(input))
|
|
||||||
.then((result) => {
|
|
||||||
if (result.data) merge(result.data)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
batch(() => {
|
|
||||||
roll(input.sessionID, last)
|
|
||||||
prompt.set(prev)
|
|
||||||
})
|
|
||||||
fail(err)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const restoreMutation = useMutation(() => ({
|
|
||||||
mutationFn: async (id: string) => {
|
|
||||||
const sessionID = params.id
|
|
||||||
if (!sessionID) return
|
|
||||||
|
|
||||||
const next = userMessages().find((item) => item.id > id)
|
|
||||||
const prev = prompt.current().slice()
|
|
||||||
const last = info()?.revert
|
|
||||||
|
|
||||||
batch(() => {
|
|
||||||
roll(sessionID, next ? { messageID: next.id } : undefined)
|
|
||||||
if (next) {
|
|
||||||
prompt.set(draft(next.id))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
prompt.reset()
|
|
||||||
})
|
|
||||||
|
|
||||||
const task = !next
|
|
||||||
? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
|
|
||||||
: halt(sessionID).then(() =>
|
|
||||||
sdk.client.session.revert({
|
|
||||||
sessionID,
|
|
||||||
messageID: next.id,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
await task
|
|
||||||
.then((result) => {
|
|
||||||
if (result.data) merge(result.data)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
batch(() => {
|
|
||||||
roll(sessionID, last)
|
|
||||||
prompt.set(prev)
|
|
||||||
})
|
|
||||||
fail(err)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending)
|
|
||||||
const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined))
|
|
||||||
|
|
||||||
const fork = (input: { sessionID: string; messageID: string }) => {
|
const fork = (input: { sessionID: string; messageID: string }) => {
|
||||||
const value = draft(input.messageID)
|
const value = draft(input.messageID)
|
||||||
const dir = base64Encode(sdk.directory)
|
const dir = base64Encode(sdk.directory)
|
||||||
@@ -1568,13 +1496,77 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const revert = (input: { sessionID: string; messageID: string }) => {
|
const revert = (input: { sessionID: string; messageID: string }) => {
|
||||||
if (reverting()) return
|
if (ui.reverting || ui.restoring) return
|
||||||
return revertMutation.mutateAsync(input)
|
const prev = prompt.current().slice()
|
||||||
|
const last = info()?.revert
|
||||||
|
const value = draft(input.messageID)
|
||||||
|
batch(() => {
|
||||||
|
setUi("reverting", true)
|
||||||
|
roll(input.sessionID, { messageID: input.messageID })
|
||||||
|
prompt.set(value)
|
||||||
|
})
|
||||||
|
return halt(input.sessionID)
|
||||||
|
.then(() => sdk.client.session.revert(input))
|
||||||
|
.then((result) => {
|
||||||
|
if (result.data) merge(result.data)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
batch(() => {
|
||||||
|
roll(input.sessionID, last)
|
||||||
|
prompt.set(prev)
|
||||||
|
})
|
||||||
|
fail(err)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setUi("reverting", false)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const restore = (id: string) => {
|
const restore = (id: string) => {
|
||||||
if (!params.id || reverting()) return
|
const sessionID = params.id
|
||||||
return restoreMutation.mutateAsync(id)
|
if (!sessionID || ui.restoring || ui.reverting) return
|
||||||
|
|
||||||
|
const next = userMessages().find((item) => item.id > id)
|
||||||
|
const prev = prompt.current().slice()
|
||||||
|
const last = info()?.revert
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
setUi("restoring", id)
|
||||||
|
setUi("reverting", true)
|
||||||
|
roll(sessionID, next ? { messageID: next.id } : undefined)
|
||||||
|
if (next) {
|
||||||
|
prompt.set(draft(next.id))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prompt.reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
const task = !next
|
||||||
|
? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
|
||||||
|
: halt(sessionID).then(() =>
|
||||||
|
sdk.client.session.revert({
|
||||||
|
sessionID,
|
||||||
|
messageID: next.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return task
|
||||||
|
.then((result) => {
|
||||||
|
if (result.data) merge(result.data)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
batch(() => {
|
||||||
|
roll(sessionID, last)
|
||||||
|
prompt.set(prev)
|
||||||
|
})
|
||||||
|
fail(err)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
batch(() => {
|
||||||
|
setUi("restoring", (value) => (value === id ? undefined : value))
|
||||||
|
setUi("reverting", false)
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const rolled = createMemo(() => {
|
const rolled = createMemo(() => {
|
||||||
@@ -1593,7 +1585,7 @@ export default function Page() {
|
|||||||
|
|
||||||
const item = queuedFollowups()[0]
|
const item = queuedFollowups()[0]
|
||||||
if (!item) return
|
if (!item) return
|
||||||
if (followupBusy(sessionID)) return
|
if (followup.sending[sessionID]) return
|
||||||
if (followup.failed[sessionID] === item.id) return
|
if (followup.failed[sessionID] === item.id) return
|
||||||
if (followup.paused[sessionID]) return
|
if (followup.paused[sessionID]) return
|
||||||
if (composer.blocked()) return
|
if (composer.blocked()) return
|
||||||
@@ -1788,8 +1780,8 @@ export default function Page() {
|
|||||||
rolled().length > 0
|
rolled().length > 0
|
||||||
? {
|
? {
|
||||||
items: rolled(),
|
items: rolled(),
|
||||||
restoring: restoring(),
|
restoring: ui.restoring,
|
||||||
disabled: reverting(),
|
disabled: ui.reverting,
|
||||||
onRestore: restore,
|
onRestore: restore,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
|
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
@@ -25,6 +24,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
custom: cached?.custom ?? ([] as string[]),
|
custom: cached?.custom ?? ([] as string[]),
|
||||||
customOn: cached?.customOn ?? ([] as boolean[]),
|
customOn: cached?.customOn ?? ([] as boolean[]),
|
||||||
editing: false,
|
editing: false,
|
||||||
|
sending: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
let root: HTMLDivElement | undefined
|
let root: HTMLDivElement | undefined
|
||||||
@@ -126,40 +126,36 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
}
|
}
|
||||||
|
|
||||||
const replyMutation = useMutation(() => ({
|
|
||||||
mutationFn: (answers: QuestionAnswer[]) => sdk.client.question.reply({ requestID: props.request.id, answers }),
|
|
||||||
onMutate: () => {
|
|
||||||
props.onSubmit()
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
replied = true
|
|
||||||
cache.delete(props.request.id)
|
|
||||||
},
|
|
||||||
onError: fail,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const rejectMutation = useMutation(() => ({
|
|
||||||
mutationFn: () => sdk.client.question.reject({ requestID: props.request.id }),
|
|
||||||
onMutate: () => {
|
|
||||||
props.onSubmit()
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
replied = true
|
|
||||||
cache.delete(props.request.id)
|
|
||||||
},
|
|
||||||
onError: fail,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const sending = createMemo(() => replyMutation.isPending || rejectMutation.isPending)
|
|
||||||
|
|
||||||
const reply = async (answers: QuestionAnswer[]) => {
|
const reply = async (answers: QuestionAnswer[]) => {
|
||||||
if (sending()) return
|
if (store.sending) return
|
||||||
await replyMutation.mutateAsync(answers)
|
|
||||||
|
props.onSubmit()
|
||||||
|
setStore("sending", true)
|
||||||
|
try {
|
||||||
|
await sdk.client.question.reply({ requestID: props.request.id, answers })
|
||||||
|
replied = true
|
||||||
|
cache.delete(props.request.id)
|
||||||
|
} catch (err) {
|
||||||
|
fail(err)
|
||||||
|
} finally {
|
||||||
|
setStore("sending", false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reject = async () => {
|
const reject = async () => {
|
||||||
if (sending()) return
|
if (store.sending) return
|
||||||
await rejectMutation.mutateAsync()
|
|
||||||
|
props.onSubmit()
|
||||||
|
setStore("sending", true)
|
||||||
|
try {
|
||||||
|
await sdk.client.question.reject({ requestID: props.request.id })
|
||||||
|
replied = true
|
||||||
|
cache.delete(props.request.id)
|
||||||
|
} catch (err) {
|
||||||
|
fail(err)
|
||||||
|
} finally {
|
||||||
|
setStore("sending", false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
|
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
|
||||||
@@ -179,7 +175,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customToggle = () => {
|
const customToggle = () => {
|
||||||
if (sending()) return
|
if (store.sending) return
|
||||||
|
|
||||||
if (!multi()) {
|
if (!multi()) {
|
||||||
setStore("customOn", store.tab, true)
|
setStore("customOn", store.tab, true)
|
||||||
@@ -202,14 +198,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customOpen = () => {
|
const customOpen = () => {
|
||||||
if (sending()) return
|
if (store.sending) return
|
||||||
if (!on()) setStore("customOn", store.tab, true)
|
if (!on()) setStore("customOn", store.tab, true)
|
||||||
setStore("editing", true)
|
setStore("editing", true)
|
||||||
customUpdate(input(), true)
|
customUpdate(input(), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectOption = (optIndex: number) => {
|
const selectOption = (optIndex: number) => {
|
||||||
if (sending()) return
|
if (store.sending) return
|
||||||
|
|
||||||
if (optIndex === options().length) {
|
if (optIndex === options().length) {
|
||||||
customOpen()
|
customOpen()
|
||||||
@@ -231,7 +227,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
}
|
}
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
if (sending()) return
|
if (store.sending) return
|
||||||
if (store.editing) commitCustom()
|
if (store.editing) commitCustom()
|
||||||
|
|
||||||
if (store.tab >= total() - 1) {
|
if (store.tab >= total() - 1) {
|
||||||
@@ -244,14 +240,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
}
|
}
|
||||||
|
|
||||||
const back = () => {
|
const back = () => {
|
||||||
if (sending()) return
|
if (store.sending) return
|
||||||
if (store.tab <= 0) return
|
if (store.tab <= 0) return
|
||||||
setStore("tab", store.tab - 1)
|
setStore("tab", store.tab - 1)
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const jump = (tab: number) => {
|
const jump = (tab: number) => {
|
||||||
if (sending()) return
|
if (store.sending) return
|
||||||
setStore("tab", tab)
|
setStore("tab", tab)
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
}
|
}
|
||||||
@@ -274,7 +270,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
(store.answers[i()]?.length ?? 0) > 0 ||
|
(store.answers[i()]?.length ?? 0) > 0 ||
|
||||||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
|
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
|
||||||
}
|
}
|
||||||
disabled={sending()}
|
disabled={store.sending}
|
||||||
onClick={() => jump(i())}
|
onClick={() => jump(i())}
|
||||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||||
/>
|
/>
|
||||||
@@ -285,16 +281,16 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" size="large" disabled={sending()} onClick={reject}>
|
<Button variant="ghost" size="large" disabled={store.sending} onClick={reject}>
|
||||||
{language.t("ui.common.dismiss")}
|
{language.t("ui.common.dismiss")}
|
||||||
</Button>
|
</Button>
|
||||||
<div data-slot="question-footer-actions">
|
<div data-slot="question-footer-actions">
|
||||||
<Show when={store.tab > 0}>
|
<Show when={store.tab > 0}>
|
||||||
<Button variant="secondary" size="large" disabled={sending()} onClick={back}>
|
<Button variant="secondary" size="large" disabled={store.sending} onClick={back}>
|
||||||
{language.t("ui.common.back")}
|
{language.t("ui.common.back")}
|
||||||
</Button>
|
</Button>
|
||||||
</Show>
|
</Show>
|
||||||
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={sending()} onClick={next}>
|
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}>
|
||||||
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -315,7 +311,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
data-picked={picked()}
|
data-picked={picked()}
|
||||||
role={multi() ? "checkbox" : "radio"}
|
role={multi() ? "checkbox" : "radio"}
|
||||||
aria-checked={picked()}
|
aria-checked={picked()}
|
||||||
disabled={sending()}
|
disabled={store.sending}
|
||||||
onClick={() => selectOption(i())}
|
onClick={() => selectOption(i())}
|
||||||
>
|
>
|
||||||
<span data-slot="question-option-check" aria-hidden="true">
|
<span data-slot="question-option-check" aria-hidden="true">
|
||||||
@@ -349,7 +345,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
data-picked={on()}
|
data-picked={on()}
|
||||||
role={multi() ? "checkbox" : "radio"}
|
role={multi() ? "checkbox" : "radio"}
|
||||||
aria-checked={on()}
|
aria-checked={on()}
|
||||||
disabled={sending()}
|
disabled={store.sending}
|
||||||
onClick={customOpen}
|
onClick={customOpen}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -381,7 +377,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
role={multi() ? "checkbox" : "radio"}
|
role={multi() ? "checkbox" : "radio"}
|
||||||
aria-checked={on()}
|
aria-checked={on()}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (sending()) {
|
if (store.sending) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -423,7 +419,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
placeholder={language.t("ui.question.custom.placeholder")}
|
placeholder={language.t("ui.question.custom.placeholder")}
|
||||||
value={input()}
|
value={input()}
|
||||||
rows={1}
|
rows={1}
|
||||||
disabled={sending()}
|
disabled={store.sending}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|||||||
@@ -217,6 +217,17 @@ export function FileTabContent(props: { tab: string }) {
|
|||||||
onDelete={controls.remove}
|
onDelete={controls.remove}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
onDraftPopoverFocusOut: (e: FocusEvent) => {
|
||||||
|
const current = e.currentTarget as HTMLDivElement
|
||||||
|
const target = e.relatedTarget
|
||||||
|
if (target instanceof Node && current.contains(target)) return
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!document.activeElement || !current.contains(document.activeElement)) {
|
||||||
|
setNote("commenting", null)
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -415,6 +426,7 @@ export function FileTabContent(props: { tab: string }) {
|
|||||||
commentsUi.onLineSelectionEnd(range)
|
commentsUi.onLineSelectionEnd(range)
|
||||||
}}
|
}}
|
||||||
search={search}
|
search={search}
|
||||||
|
overflow="scroll"
|
||||||
class="select-text"
|
class="select-text"
|
||||||
media={{
|
media={{
|
||||||
mode: "auto",
|
mode: "auto",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
|
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
@@ -322,6 +321,7 @@ export function MessageTimeline(props: {
|
|||||||
const [title, setTitle] = createStore({
|
const [title, setTitle] = createStore({
|
||||||
draft: "",
|
draft: "",
|
||||||
editing: false,
|
editing: false,
|
||||||
|
saving: false,
|
||||||
menuOpen: false,
|
menuOpen: false,
|
||||||
pendingRename: false,
|
pendingRename: false,
|
||||||
pendingShare: false,
|
pendingShare: false,
|
||||||
@@ -335,6 +335,38 @@ export function MessageTimeline(props: {
|
|||||||
|
|
||||||
let more: HTMLButtonElement | undefined
|
let more: HTMLButtonElement | undefined
|
||||||
|
|
||||||
|
const [req, setReq] = createStore({ share: false, unshare: false })
|
||||||
|
|
||||||
|
const shareSession = () => {
|
||||||
|
const id = sessionID()
|
||||||
|
if (!id || req.share) return
|
||||||
|
if (!shareEnabled()) return
|
||||||
|
setReq("share", true)
|
||||||
|
globalSDK.client.session
|
||||||
|
.share({ sessionID: id, directory: sdk.directory })
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error("Failed to share session", err)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setReq("share", false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const unshareSession = () => {
|
||||||
|
const id = sessionID()
|
||||||
|
if (!id || req.unshare) return
|
||||||
|
if (!shareEnabled()) return
|
||||||
|
setReq("unshare", true)
|
||||||
|
globalSDK.client.session
|
||||||
|
.unshare({ sessionID: id, directory: sdk.directory })
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error("Failed to unshare session", err)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setReq("unshare", false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const viewShare = () => {
|
const viewShare = () => {
|
||||||
const url = shareUrl()
|
const url = shareUrl()
|
||||||
if (!url) return
|
if (!url) return
|
||||||
@@ -350,54 +382,6 @@ export function MessageTimeline(props: {
|
|||||||
return language.t("common.requestFailed")
|
return language.t("common.requestFailed")
|
||||||
}
|
}
|
||||||
|
|
||||||
const shareMutation = useMutation(() => ({
|
|
||||||
mutationFn: (id: string) => globalSDK.client.session.share({ sessionID: id, directory: sdk.directory }),
|
|
||||||
onError: (err) => {
|
|
||||||
console.error("Failed to share session", err)
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const unshareMutation = useMutation(() => ({
|
|
||||||
mutationFn: (id: string) => globalSDK.client.session.unshare({ sessionID: id, directory: sdk.directory }),
|
|
||||||
onError: (err) => {
|
|
||||||
console.error("Failed to unshare session", err)
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const titleMutation = useMutation(() => ({
|
|
||||||
mutationFn: (input: { id: string; title: string }) =>
|
|
||||||
sdk.client.session.update({ sessionID: input.id, title: input.title }),
|
|
||||||
onSuccess: (_, input) => {
|
|
||||||
sync.set(
|
|
||||||
produce((draft) => {
|
|
||||||
const index = draft.session.findIndex((s) => s.id === input.id)
|
|
||||||
if (index !== -1) draft.session[index].title = input.title
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setTitle("editing", false)
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
showToast({
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: errorMessage(err),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const shareSession = () => {
|
|
||||||
const id = sessionID()
|
|
||||||
if (!id || shareMutation.isPending) return
|
|
||||||
if (!shareEnabled()) return
|
|
||||||
shareMutation.mutate(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const unshareSession = () => {
|
|
||||||
const id = sessionID()
|
|
||||||
if (!id || unshareMutation.isPending) return
|
|
||||||
if (!shareEnabled()) return
|
|
||||||
unshareMutation.mutate(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
sessionKey,
|
sessionKey,
|
||||||
@@ -405,6 +389,7 @@ export function MessageTimeline(props: {
|
|||||||
setTitle({
|
setTitle({
|
||||||
draft: "",
|
draft: "",
|
||||||
editing: false,
|
editing: false,
|
||||||
|
saving: false,
|
||||||
menuOpen: false,
|
menuOpen: false,
|
||||||
pendingRename: false,
|
pendingRename: false,
|
||||||
pendingShare: false,
|
pendingShare: false,
|
||||||
@@ -423,22 +408,40 @@ export function MessageTimeline(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const closeTitleEditor = () => {
|
const closeTitleEditor = () => {
|
||||||
if (titleMutation.isPending) return
|
if (title.saving) return
|
||||||
setTitle("editing", false)
|
setTitle({ editing: false, saving: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveTitleEditor = () => {
|
const saveTitleEditor = async () => {
|
||||||
const id = sessionID()
|
const id = sessionID()
|
||||||
if (!id) return
|
if (!id) return
|
||||||
if (titleMutation.isPending) return
|
if (title.saving) return
|
||||||
|
|
||||||
const next = title.draft.trim()
|
const next = title.draft.trim()
|
||||||
if (!next || next === (titleValue() ?? "")) {
|
if (!next || next === (titleValue() ?? "")) {
|
||||||
setTitle("editing", false)
|
setTitle({ editing: false, saving: false })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
titleMutation.mutate({ id, title: next })
|
setTitle("saving", true)
|
||||||
|
await sdk.client.session
|
||||||
|
.update({ sessionID: id, title: next })
|
||||||
|
.then(() => {
|
||||||
|
sync.set(
|
||||||
|
produce((draft) => {
|
||||||
|
const index = draft.session.findIndex((s) => s.id === id)
|
||||||
|
if (index !== -1) draft.session[index].title = next
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
setTitle({ editing: false, saving: false })
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setTitle("saving", false)
|
||||||
|
showToast({
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: errorMessage(err),
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
|
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
|
||||||
@@ -709,7 +712,7 @@ export function MessageTimeline(props: {
|
|||||||
titleRef = el
|
titleRef = el
|
||||||
}}
|
}}
|
||||||
value={title.draft}
|
value={title.draft}
|
||||||
disabled={titleMutation.isPending}
|
disabled={title.saving}
|
||||||
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
|
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
|
||||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||||
@@ -860,9 +863,9 @@ export function MessageTimeline(props: {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
onClick={shareSession}
|
onClick={shareSession}
|
||||||
disabled={shareMutation.isPending}
|
disabled={req.share}
|
||||||
>
|
>
|
||||||
{shareMutation.isPending
|
{req.share
|
||||||
? language.t("session.share.action.publishing")
|
? language.t("session.share.action.publishing")
|
||||||
: language.t("session.share.action.publish")}
|
: language.t("session.share.action.publish")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -883,9 +886,9 @@ export function MessageTimeline(props: {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
class="w-full shadow-none border border-border-weak-base"
|
class="w-full shadow-none border border-border-weak-base"
|
||||||
onClick={unshareSession}
|
onClick={unshareSession}
|
||||||
disabled={unshareMutation.isPending}
|
disabled={req.unshare}
|
||||||
>
|
>
|
||||||
{unshareMutation.isPending
|
{req.unshare
|
||||||
? language.t("session.share.action.unpublishing")
|
? language.t("session.share.action.unpublishing")
|
||||||
: language.t("session.share.action.unpublish")}
|
: language.t("session.share.action.unpublish")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -894,7 +897,7 @@ export function MessageTimeline(props: {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
onClick={viewShare}
|
onClick={viewShare}
|
||||||
disabled={unshareMutation.isPending}
|
disabled={req.unshare}
|
||||||
>
|
>
|
||||||
{language.t("session.share.action.view")}
|
{language.t("session.share.action.view")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -24,13 +24,7 @@ import {
|
|||||||
FreeUsageLimitError,
|
FreeUsageLimitError,
|
||||||
SubscriptionUsageLimitError,
|
SubscriptionUsageLimitError,
|
||||||
} from "./error"
|
} from "./error"
|
||||||
import {
|
import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider"
|
||||||
buildCostChunk,
|
|
||||||
createBodyConverter,
|
|
||||||
createStreamPartConverter,
|
|
||||||
createResponseConverter,
|
|
||||||
UsageInfo,
|
|
||||||
} from "./provider/provider"
|
|
||||||
import { anthropicHelper } from "./provider/anthropic"
|
import { anthropicHelper } from "./provider/anthropic"
|
||||||
import { googleHelper } from "./provider/google"
|
import { googleHelper } from "./provider/google"
|
||||||
import { openaiHelper } from "./provider/openai"
|
import { openaiHelper } from "./provider/openai"
|
||||||
@@ -96,7 +90,7 @@ export async function handler(
|
|||||||
const projectId = input.request.headers.get("x-opencode-project") ?? ""
|
const projectId = input.request.headers.get("x-opencode-project") ?? ""
|
||||||
const ocClient = input.request.headers.get("x-opencode-client") ?? ""
|
const ocClient = input.request.headers.get("x-opencode-client") ?? ""
|
||||||
logger.metric({
|
logger.metric({
|
||||||
is_stream: isStream,
|
is_tream: isStream,
|
||||||
session: sessionId,
|
session: sessionId,
|
||||||
request: requestId,
|
request: requestId,
|
||||||
client: ocClient,
|
client: ocClient,
|
||||||
@@ -236,7 +230,7 @@ export async function handler(
|
|||||||
const body = JSON.stringify(
|
const body = JSON.stringify(
|
||||||
responseConverter({
|
responseConverter({
|
||||||
...json,
|
...json,
|
||||||
cost: calculateOccurredCost(billingSource, costInfo),
|
cost: calculateOccuredCost(billingSource, costInfo),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
logger.metric({ response_length: body.length })
|
logger.metric({ response_length: body.length })
|
||||||
@@ -280,8 +274,8 @@ export async function handler(
|
|||||||
await trialLimiter?.track(usageInfo)
|
await trialLimiter?.track(usageInfo)
|
||||||
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
|
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
|
||||||
await reload(billingSource, authInfo, costInfo)
|
await reload(billingSource, authInfo, costInfo)
|
||||||
const cost = calculateOccurredCost(billingSource, costInfo)
|
const cost = calculateOccuredCost(billingSource, costInfo)
|
||||||
c.enqueue(encoder.encode(buildCostChunk(opts.format, cost)))
|
c.enqueue(encoder.encode(usageParser.buidlCostChunk(cost)))
|
||||||
}
|
}
|
||||||
c.close()
|
c.close()
|
||||||
return
|
return
|
||||||
@@ -824,7 +818,7 @@ export async function handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateOccurredCost(billingSource: BillingSource, costInfo: CostInfo) {
|
function calculateOccuredCost(billingSource: BillingSource, costInfo: CostInfo) {
|
||||||
return billingSource === "balance" ? (costInfo.totalCostInCent / 100).toFixed(8) : "0"
|
return billingSource === "balance" ? (costInfo.totalCostInCent / 100).toFixed(8) : "0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
|||||||
const isBedrockModelArn = providerModel.startsWith("arn:aws:bedrock:")
|
const isBedrockModelArn = providerModel.startsWith("arn:aws:bedrock:")
|
||||||
const isBedrockModelID = providerModel.startsWith("global.anthropic.")
|
const isBedrockModelID = providerModel.startsWith("global.anthropic.")
|
||||||
const isBedrock = isBedrockModelArn || isBedrockModelID
|
const isBedrock = isBedrockModelArn || isBedrockModelID
|
||||||
const isDatabricks = providerModel.startsWith("databricks-claude-")
|
|
||||||
const supports1m = reqModel.includes("sonnet") || reqModel.includes("opus-4-6")
|
const supports1m = reqModel.includes("sonnet") || reqModel.includes("opus-4-6")
|
||||||
return {
|
return {
|
||||||
format: "anthropic",
|
format: "anthropic",
|
||||||
@@ -29,7 +28,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
|||||||
? `${providerApi}/model/${isBedrockModelArn ? encodeURIComponent(providerModel) : providerModel}/${isStream ? "invoke-with-response-stream" : "invoke"}`
|
? `${providerApi}/model/${isBedrockModelArn ? encodeURIComponent(providerModel) : providerModel}/${isStream ? "invoke-with-response-stream" : "invoke"}`
|
||||||
: providerApi + "/messages",
|
: providerApi + "/messages",
|
||||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
|
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
|
||||||
if (isBedrock || isDatabricks) {
|
if (isBedrock) {
|
||||||
headers.set("Authorization", `Bearer ${apiKey}`)
|
headers.set("Authorization", `Bearer ${apiKey}`)
|
||||||
} else {
|
} else {
|
||||||
headers.set("x-api-key", apiKey)
|
headers.set("x-api-key", apiKey)
|
||||||
@@ -48,14 +47,9 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
|||||||
model: undefined,
|
model: undefined,
|
||||||
stream: undefined,
|
stream: undefined,
|
||||||
}
|
}
|
||||||
: isDatabricks
|
: {
|
||||||
? {
|
service_tier: "standard_only",
|
||||||
anthropic_version: "bedrock-2023-05-31",
|
}),
|
||||||
anthropic_beta: supports1m ? ["context-1m-2025-08-07"] : undefined,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
service_tier: "standard_only",
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
createBinaryStreamDecoder: () => {
|
createBinaryStreamDecoder: () => {
|
||||||
if (!isBedrock) return undefined
|
if (!isBedrock) return undefined
|
||||||
@@ -173,6 +167,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
retrieve: () => usage,
|
retrieve: () => usage,
|
||||||
|
buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
normalizeUsage: (usage: Usage) => ({
|
normalizeUsage: (usage: Usage) => ({
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({
|
|||||||
usage = json.usageMetadata
|
usage = json.usageMetadata
|
||||||
},
|
},
|
||||||
retrieve: () => usage,
|
retrieve: () => usage,
|
||||||
|
buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ type: "ping", cost })}\n\n`,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
normalizeUsage: (usage: Usage) => {
|
normalizeUsage: (usage: Usage) => {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export const oaCompatHelper: ProviderHelper = () => ({
|
|||||||
usage = json.usage
|
usage = json.usage
|
||||||
},
|
},
|
||||||
retrieve: () => usage,
|
retrieve: () => usage,
|
||||||
|
buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ choices: [], cost })}\n\n`,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
normalizeUsage: (usage: Usage) => {
|
normalizeUsage: (usage: Usage) => {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const openaiHelper: ProviderHelper = () => ({
|
|||||||
usage = json.response.usage
|
usage = json.response.usage
|
||||||
},
|
},
|
||||||
retrieve: () => usage,
|
retrieve: () => usage,
|
||||||
|
buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
normalizeUsage: (usage: Usage) => {
|
normalizeUsage: (usage: Usage) => {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export type ProviderHelper = (input: { reqModel: string; providerModel: string }
|
|||||||
createUsageParser: () => {
|
createUsageParser: () => {
|
||||||
parse: (chunk: string) => void
|
parse: (chunk: string) => void
|
||||||
retrieve: () => any
|
retrieve: () => any
|
||||||
|
buidlCostChunk: (cost: string) => string
|
||||||
}
|
}
|
||||||
normalizeUsage: (usage: any) => UsageInfo
|
normalizeUsage: (usage: any) => UsageInfo
|
||||||
}
|
}
|
||||||
@@ -161,19 +162,6 @@ export interface CommonChunk {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildCostChunk(format: ZenData.Format, cost: string): string {
|
|
||||||
switch (format) {
|
|
||||||
case "anthropic":
|
|
||||||
return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`
|
|
||||||
case "openai":
|
|
||||||
return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`
|
|
||||||
case "oa-compat":
|
|
||||||
return `data: ${JSON.stringify({ choices: [], cost })}\n\n`
|
|
||||||
default:
|
|
||||||
return `data: ${JSON.stringify({ type: "ping", cost })}\n\n`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createBodyConverter(from: ZenData.Format, to: ZenData.Format) {
|
export function createBodyConverter(from: ZenData.Format, to: ZenData.Format) {
|
||||||
return (body: any): any => {
|
return (body: any): any => {
|
||||||
if (from === to) return body
|
if (from === to) return body
|
||||||
|
|||||||
@@ -6,11 +6,6 @@ import type { InitStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme,
|
|||||||
import { getStore } from "./store"
|
import { getStore } from "./store"
|
||||||
import { setTitlebar } from "./windows"
|
import { setTitlebar } from "./windows"
|
||||||
|
|
||||||
const pickerFilters = (ext?: string[]) => {
|
|
||||||
if (!ext || ext.length === 0) return undefined
|
|
||||||
return [{ name: "Files", extensions: ext }]
|
|
||||||
}
|
|
||||||
|
|
||||||
type Deps = {
|
type Deps = {
|
||||||
killSidecar: () => void
|
killSidecar: () => void
|
||||||
installCli: () => Promise<string>
|
installCli: () => Promise<string>
|
||||||
@@ -99,15 +94,11 @@ export function registerIpcHandlers(deps: Deps) {
|
|||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
"open-file-picker",
|
"open-file-picker",
|
||||||
async (
|
async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
|
||||||
_event: IpcMainInvokeEvent,
|
|
||||||
opts?: { multiple?: boolean; title?: string; defaultPath?: string; accept?: string[]; extensions?: string[] },
|
|
||||||
) => {
|
|
||||||
const result = await dialog.showOpenDialog({
|
const result = await dialog.showOpenDialog({
|
||||||
properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
|
properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
|
||||||
title: opts?.title ?? "Choose a file",
|
title: opts?.title ?? "Choose a file",
|
||||||
defaultPath: opts?.defaultPath,
|
defaultPath: opts?.defaultPath,
|
||||||
filters: pickerFilters(opts?.extensions),
|
|
||||||
})
|
})
|
||||||
if (result.canceled) return null
|
if (result.canceled) return null
|
||||||
return opts?.multiple ? result.filePaths : result.filePaths[0]
|
return opts?.multiple ? result.filePaths : result.filePaths[0]
|
||||||
|
|||||||
@@ -50,8 +50,6 @@ export type ElectronAPI = {
|
|||||||
multiple?: boolean
|
multiple?: boolean
|
||||||
title?: string
|
title?: string
|
||||||
defaultPath?: string
|
defaultPath?: string
|
||||||
accept?: string[]
|
|
||||||
extensions?: string[]
|
|
||||||
}) => Promise<string | string[] | null>
|
}) => Promise<string | string[] | null>
|
||||||
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
|
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
|
||||||
openLink: (url: string) => void
|
openLink: (url: string) => void
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
// @refresh reload
|
// @refresh reload
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ACCEPTED_FILE_EXTENSIONS,
|
|
||||||
ACCEPTED_FILE_TYPES,
|
|
||||||
AppBaseProviders,
|
AppBaseProviders,
|
||||||
AppInterface,
|
AppInterface,
|
||||||
handleNotificationClick,
|
handleNotificationClick,
|
||||||
@@ -113,8 +111,6 @@ const createPlatform = (): Platform => {
|
|||||||
const result = await window.api.openFilePicker({
|
const result = await window.api.openFilePicker({
|
||||||
multiple: opts?.multiple ?? false,
|
multiple: opts?.multiple ?? false,
|
||||||
title: opts?.title ?? t("desktop.dialog.chooseFile"),
|
title: opts?.title ?? t("desktop.dialog.chooseFile"),
|
||||||
accept: opts?.accept ?? ACCEPTED_FILE_TYPES,
|
|
||||||
extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS,
|
|
||||||
})
|
})
|
||||||
return handleWslPicker(result)
|
return handleWslPicker(result)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
// @refresh reload
|
// @refresh reload
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ACCEPTED_FILE_EXTENSIONS,
|
|
||||||
filePickerFilters,
|
|
||||||
AppBaseProviders,
|
AppBaseProviders,
|
||||||
AppInterface,
|
AppInterface,
|
||||||
handleNotificationClick,
|
handleNotificationClick,
|
||||||
@@ -100,7 +98,6 @@ const createPlatform = (): Platform => {
|
|||||||
directory: false,
|
directory: false,
|
||||||
multiple: opts?.multiple ?? false,
|
multiple: opts?.multiple ?? false,
|
||||||
title: opts?.title ?? t("desktop.dialog.chooseFile"),
|
title: opts?.title ?? t("desktop.dialog.chooseFile"),
|
||||||
filters: filePickerFilters(opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS),
|
|
||||||
})
|
})
|
||||||
return handleWslPicker(result)
|
return handleWslPicker(result)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -90,6 +90,10 @@
|
|||||||
"@aws-sdk/credential-providers": "3.993.0",
|
"@aws-sdk/credential-providers": "3.993.0",
|
||||||
"@clack/prompts": "1.0.0-alpha.1",
|
"@clack/prompts": "1.0.0-alpha.1",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
|
"@gitlab/gitlab-ai-provider": "3.6.0",
|
||||||
|
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||||
|
"@hono/node-server": "1.19.11",
|
||||||
|
"@hono/node-ws": "1.3.0",
|
||||||
"@hono/standard-validator": "0.1.5",
|
"@hono/standard-validator": "0.1.5",
|
||||||
"@hono/zod-validator": "catalog:",
|
"@hono/zod-validator": "catalog:",
|
||||||
"@modelcontextprotocol/sdk": "1.25.2",
|
"@modelcontextprotocol/sdk": "1.25.2",
|
||||||
@@ -121,7 +125,6 @@
|
|||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"gitlab-ai-provider": "5.2.2",
|
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"google-auth-library": "10.5.0",
|
"google-auth-library": "10.5.0",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
@@ -132,7 +135,6 @@
|
|||||||
"mime-types": "3.0.2",
|
"mime-types": "3.0.2",
|
||||||
"minimatch": "10.0.3",
|
"minimatch": "10.0.3",
|
||||||
"open": "10.1.2",
|
"open": "10.1.2",
|
||||||
"opencode-gitlab-auth": "2.0.0",
|
|
||||||
"opentui-spinner": "0.0.6",
|
"opentui-spinner": "0.0.6",
|
||||||
"partial-json": "0.1.7",
|
"partial-json": "0.1.7",
|
||||||
"remeda": "catalog:",
|
"remeda": "catalog:",
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
|
|
||||||
import fs from "fs"
|
|
||||||
import path from "path"
|
|
||||||
import { fileURLToPath } from "url"
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
|
||||||
const __dirname = path.dirname(__filename)
|
|
||||||
const dir = path.resolve(__dirname, "..")
|
|
||||||
|
|
||||||
process.chdir(dir)
|
|
||||||
|
|
||||||
// Load migrations from migration directories
|
|
||||||
const migrationDirs = (
|
|
||||||
await fs.promises.readdir(path.join(dir, "migration"), {
|
|
||||||
withFileTypes: true,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name))
|
|
||||||
.map((entry) => entry.name)
|
|
||||||
.sort()
|
|
||||||
|
|
||||||
const migrations = await Promise.all(
|
|
||||||
migrationDirs.map(async (name) => {
|
|
||||||
const file = path.join(dir, "migration", name, "migration.sql")
|
|
||||||
const sql = await Bun.file(file).text()
|
|
||||||
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name)
|
|
||||||
const timestamp = match
|
|
||||||
? Date.UTC(
|
|
||||||
Number(match[1]),
|
|
||||||
Number(match[2]) - 1,
|
|
||||||
Number(match[3]),
|
|
||||||
Number(match[4]),
|
|
||||||
Number(match[5]),
|
|
||||||
Number(match[6]),
|
|
||||||
)
|
|
||||||
: 0
|
|
||||||
return { sql, timestamp, name }
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
console.log(`Loaded ${migrations.length} migrations`)
|
|
||||||
|
|
||||||
await Bun.build({
|
|
||||||
target: "node",
|
|
||||||
entrypoints: ["./src/node.ts"],
|
|
||||||
outdir: "./dist",
|
|
||||||
format: "esm",
|
|
||||||
external: ["jsonc-parser"],
|
|
||||||
define: {
|
|
||||||
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log("Build complete")
|
|
||||||
@@ -199,19 +199,6 @@ for (const item of targets) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Smoke test: only run if binary is for current platform
|
|
||||||
if (item.os === process.platform && item.arch === process.arch && !item.abi) {
|
|
||||||
const binaryPath = `dist/${name}/bin/opencode`
|
|
||||||
console.log(`Running smoke test: ${binaryPath} --version`)
|
|
||||||
try {
|
|
||||||
const versionOutput = await $`${binaryPath} --version`.text()
|
|
||||||
console.log(`Smoke test passed: ${versionOutput.trim()}`)
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Smoke test failed for ${name}:`, e)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await $`rm -rf ./dist/${name}/bin/tui`
|
await $`rm -rf ./dist/${name}/bin/tui`
|
||||||
await Bun.file(`dist/${name}/package.json`).write(
|
await Bun.file(`dist/${name}/package.json`).write(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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")
|
||||||
@@ -54,6 +55,7 @@ const seed = async () => {
|
|||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
await Instance.disposeAll().catch(() => {})
|
await Instance.disposeAll().catch(() => {})
|
||||||
|
await disposeRuntime().catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,18 +4,18 @@ Practical reference for new and migrated Effect code in `packages/opencode`.
|
|||||||
|
|
||||||
## Choose scope
|
## Choose scope
|
||||||
|
|
||||||
Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a `ScopedCache` keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal.
|
Use the shared runtime for process-wide services with one lifecycle for the whole app.
|
||||||
|
|
||||||
Use `makeRunPromise` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`.
|
Use `src/effect/instances.ts` for services that are created per directory or need `InstanceContext`, per-project state, or per-instance cleanup.
|
||||||
|
|
||||||
- Global services (no per-directory state): Account, Auth, Installation, Truncate
|
- Shared runtime: config readers, stateless helpers, global clients
|
||||||
- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth
|
- Instance-scoped: watchers, per-project caches, session state, project-bound background work
|
||||||
|
|
||||||
Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`.
|
Rule of thumb: if two open directories should not share one copy of the service, it belongs in `Instances`.
|
||||||
|
|
||||||
## Service shape
|
## Service shape
|
||||||
|
|
||||||
Every service follows the same pattern — a single namespace with the service definition, layer, `runPromise`, and async facade functions:
|
For a fully migrated module, use the public namespace directly:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export namespace Foo {
|
export namespace Foo {
|
||||||
@@ -28,52 +28,53 @@ export namespace Foo {
|
|||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
// For instance-scoped services:
|
return Service.of({
|
||||||
const state = yield* InstanceState.make<State>(
|
get: Effect.fn("Foo.get")(function* (id) {
|
||||||
Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
|
return yield* ...
|
||||||
)
|
}),
|
||||||
|
|
||||||
const get = Effect.fn("Foo.get")(function* (id: FooID) {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
// ...
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ get })
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Optional: wire dependencies
|
export const defaultLayer = layer.pipe(Layer.provide(FooRepo.defaultLayer))
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
|
|
||||||
|
|
||||||
// Per-service runtime (inside the namespace)
|
|
||||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
|
||||||
|
|
||||||
// Async facade functions
|
|
||||||
export async function get(id: FooID) {
|
|
||||||
return runPromise((svc) => svc.get(id))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
- Keep everything in one namespace, one file — no separate `service.ts` / `index.ts` split
|
- Keep `Interface`, `Service`, `layer`, and `defaultLayer` on the owning namespace
|
||||||
- `runPromise` goes inside the namespace (not exported unless tests need it)
|
- Export `defaultLayer` only when wiring dependencies is useful
|
||||||
- Facade functions are plain `async function` — no `fn()` wrappers
|
- Use the direct namespace form once the module is fully migrated
|
||||||
- Use `Effect.fn("Namespace.method")` for all Effect functions (for tracing)
|
|
||||||
- No `Layer.fresh` — InstanceState handles per-directory isolation
|
|
||||||
|
|
||||||
## Schema → Zod interop
|
## Temporary mixed-mode pattern
|
||||||
|
|
||||||
When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@/util/effect-zod`:
|
Prefer a single namespace whenever possible.
|
||||||
|
|
||||||
|
Use a `*Effect` namespace only when there is a real mixed-mode split, usually because a legacy boundary facade still exists or because merging everything immediately would create awkward cycles.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { zod } from "@/util/effect-zod"
|
export namespace FooEffect {
|
||||||
|
export interface Interface {
|
||||||
|
readonly get: (id: FooID) => Effect.Effect<Foo, FooError>
|
||||||
|
}
|
||||||
|
|
||||||
export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(...)
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
See `Auth.ZodInfo` for the canonical example.
|
Then keep the old boundary thin:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export namespace Foo {
|
||||||
|
export function get(id: FooID) {
|
||||||
|
return runtime.runPromise(FooEffect.Service.use((svc) => svc.get(id)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the `Effect` suffix when the boundary split is gone.
|
||||||
|
|
||||||
## Scheduled Tasks
|
## Scheduled Tasks
|
||||||
|
|
||||||
@@ -106,23 +107,22 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
|
|||||||
|
|
||||||
## Migration checklist
|
## Migration checklist
|
||||||
|
|
||||||
Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
Done now:
|
||||||
|
|
||||||
- [x] `Account` — `account/index.ts`
|
- [x] `AccountEffect` (mixed-mode)
|
||||||
- [x] `Auth` — `auth/index.ts` (uses `zod()` helper for Schema→Zod interop)
|
- [x] `AuthEffect` (mixed-mode)
|
||||||
- [x] `File` — `file/index.ts`
|
- [x] `TruncateEffect` (mixed-mode)
|
||||||
- [x] `FileTime` — `file/time.ts`
|
- [x] `Question`
|
||||||
- [x] `FileWatcher` — `file/watcher.ts`
|
- [x] `PermissionNext`
|
||||||
- [x] `Format` — `format/index.ts`
|
- [x] `ProviderAuth`
|
||||||
- [x] `Installation` — `installation/index.ts`
|
- [x] `FileWatcher`
|
||||||
- [x] `Permission` — `permission/index.ts`
|
- [x] `FileTime`
|
||||||
- [x] `ProviderAuth` — `provider/auth.ts`
|
- [x] `Format`
|
||||||
- [x] `Question` — `question/index.ts`
|
- [x] `Vcs`
|
||||||
- [x] `Skill` — `skill/index.ts`
|
- [x] `Skill`
|
||||||
- [x] `Snapshot` — `snapshot/index.ts`
|
- [x] `Discovery`
|
||||||
- [x] `Truncate` — `tool/truncate.ts`
|
- [x] `File`
|
||||||
- [x] `Vcs` — `project/vcs.ts`
|
- [x] `Snapshot`
|
||||||
- [x] `Discovery` — `skill/discovery.ts`
|
|
||||||
|
|
||||||
Still open and likely worth migrating:
|
Still open and likely worth migrating:
|
||||||
|
|
||||||
@@ -130,8 +130,9 @@ Still open and likely worth migrating:
|
|||||||
- [ ] `ToolRegistry`
|
- [ ] `ToolRegistry`
|
||||||
- [ ] `Pty`
|
- [ ] `Pty`
|
||||||
- [ ] `Worktree`
|
- [ ] `Worktree`
|
||||||
|
- [ ] `Installation`
|
||||||
- [ ] `Bus`
|
- [ ] `Bus`
|
||||||
- [x] `Command`
|
- [ ] `Command`
|
||||||
- [ ] `Config`
|
- [ ] `Config`
|
||||||
- [ ] `Session`
|
- [ ] `Session`
|
||||||
- [ ] `SessionProcessor`
|
- [ ] `SessionProcessor`
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
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 AccountEffect {
|
||||||
|
export interface Interface {
|
||||||
|
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 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(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"),
|
||||||
|
)
|
||||||
|
|
||||||
|
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||||
|
request.pipe(
|
||||||
|
Effect.flatMap((req) => http.execute(req)),
|
||||||
|
mapAccountServiceError("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: 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("Account.token")((accountID: AccountID) =>
|
||||||
|
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.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 error of errors) {
|
||||||
|
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
|
||||||
|
Effect.annotateLogs({ error: String(error) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
})
|
||||||
|
|
||||||
|
const orgs = Effect.fn("Account.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("Account.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("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: 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("Account.poll")(function* (input: Login) {
|
||||||
|
const response = yield* executeEffect(
|
||||||
|
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 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,397 +1,41 @@
|
|||||||
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
import { Effect, Option } from "effect"
|
||||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
|
||||||
|
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
|
||||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
|
||||||
import { AccountRepo, type AccountRow } from "./repo"
|
|
||||||
import {
|
import {
|
||||||
|
Account as AccountSchema,
|
||||||
type AccountError,
|
type AccountError,
|
||||||
AccessToken,
|
type AccessToken,
|
||||||
AccountID,
|
AccountID,
|
||||||
DeviceCode,
|
AccountEffect,
|
||||||
Info,
|
|
||||||
RefreshToken,
|
|
||||||
AccountServiceError,
|
|
||||||
Login,
|
|
||||||
Org,
|
|
||||||
OrgID,
|
OrgID,
|
||||||
PollDenied,
|
} from "./effect"
|
||||||
PollError,
|
|
||||||
PollExpired,
|
|
||||||
PollPending,
|
|
||||||
type PollResult,
|
|
||||||
PollSlow,
|
|
||||||
PollSuccess,
|
|
||||||
UserCode,
|
|
||||||
} from "./schema"
|
|
||||||
|
|
||||||
export {
|
export { AccessToken, AccountID, OrgID } from "./effect"
|
||||||
AccountID,
|
|
||||||
type AccountError,
|
|
||||||
AccountRepoError,
|
|
||||||
AccountServiceError,
|
|
||||||
AccessToken,
|
|
||||||
RefreshToken,
|
|
||||||
DeviceCode,
|
|
||||||
UserCode,
|
|
||||||
Info,
|
|
||||||
Org,
|
|
||||||
OrgID,
|
|
||||||
Login,
|
|
||||||
PollSuccess,
|
|
||||||
PollPending,
|
|
||||||
PollSlow,
|
|
||||||
PollExpired,
|
|
||||||
PollDenied,
|
|
||||||
PollError,
|
|
||||||
PollResult,
|
|
||||||
} from "./schema"
|
|
||||||
|
|
||||||
export type AccountOrgs = {
|
import { runtime } from "@/effect/runtime"
|
||||||
account: Info
|
|
||||||
orgs: readonly Org[]
|
function runSync<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
|
||||||
|
return runtime.runSync(AccountEffect.Service.use(f))
|
||||||
}
|
}
|
||||||
|
|
||||||
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
|
function runPromise<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
|
||||||
config: Schema.Record(Schema.String, Schema.Json),
|
return runtime.runPromise(AccountEffect.Service.use(f))
|
||||||
}) {}
|
|
||||||
|
|
||||||
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 Account {
|
export namespace Account {
|
||||||
export interface Interface {
|
export const Account = AccountSchema
|
||||||
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
|
export type Account = AccountSchema
|
||||||
readonly list: () => Effect.Effect<Info[], 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 Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
|
export function active(): Account | undefined {
|
||||||
|
return Option.getOrUndefined(runSync((service) => service.active()))
|
||||||
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(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"),
|
|
||||||
)
|
|
||||||
|
|
||||||
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
|
||||||
request.pipe(
|
|
||||||
Effect.flatMap((req) => http.execute(req)),
|
|
||||||
mapAccountServiceError("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: 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("Account.token")((accountID: AccountID) =>
|
|
||||||
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.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 error of errors) {
|
|
||||||
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
|
|
||||||
Effect.annotateLogs({ error: String(error) }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return results
|
|
||||||
})
|
|
||||||
|
|
||||||
const orgs = Effect.fn("Account.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("Account.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("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: 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("Account.poll")(function* (input: Login) {
|
|
||||||
const response = yield* executeEffect(
|
|
||||||
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 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))
|
|
||||||
|
|
||||||
export const runPromise = makeRunPromise(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function active(): Promise<Info | undefined> {
|
|
||||||
return Option.getOrUndefined(await runPromise((service) => service.active()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function config(accountID: AccountID, orgID: OrgID): Promise<Record<string, unknown> | undefined> {
|
export async function config(accountID: AccountID, orgID: OrgID): Promise<Record<string, unknown> | undefined> {
|
||||||
const cfg = await runPromise((service) => service.config(accountID, orgID))
|
const config = await runPromise((service) => service.config(accountID, orgID))
|
||||||
return Option.getOrUndefined(cfg)
|
return Option.getOrUndefined(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
|
export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
|
||||||
const t = await runPromise((service) => service.token(accountID))
|
const token = await runPromise((service) => service.token(accountID))
|
||||||
return Option.getOrUndefined(t)
|
return Option.getOrUndefined(token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
|
|||||||
|
|
||||||
import { Database } from "@/storage/db"
|
import { Database } from "@/storage/db"
|
||||||
import { AccountStateTable, AccountTable } from "./account.sql"
|
import { AccountStateTable, AccountTable } from "./account.sql"
|
||||||
import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema"
|
import { AccessToken, Account, AccountID, AccountRepoError, OrgID, RefreshToken } from "./schema"
|
||||||
|
|
||||||
export type AccountRow = (typeof AccountTable)["$inferSelect"]
|
export type AccountRow = (typeof AccountTable)["$inferSelect"]
|
||||||
|
|
||||||
@@ -13,8 +13,8 @@ const ACCOUNT_STATE_ID = 1
|
|||||||
|
|
||||||
export namespace AccountRepo {
|
export namespace AccountRepo {
|
||||||
export interface Service {
|
export interface Service {
|
||||||
readonly active: () => Effect.Effect<Option.Option<Info>, AccountRepoError>
|
readonly active: () => Effect.Effect<Option.Option<Account>, AccountRepoError>
|
||||||
readonly list: () => Effect.Effect<Info[], AccountRepoError>
|
readonly list: () => Effect.Effect<Account[], AccountRepoError>
|
||||||
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
|
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
|
||||||
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
|
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
|
||||||
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
|
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
|
||||||
@@ -40,7 +40,7 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
|
|||||||
static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
|
static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
|
||||||
AccountRepo,
|
AccountRepo,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const decode = Schema.decodeUnknownSync(Info)
|
const decode = Schema.decodeUnknownSync(Account)
|
||||||
|
|
||||||
const query = <A>(f: (db: DbClient) => A) =>
|
const query = <A>(f: (db: DbClient) => A) =>
|
||||||
Effect.try({
|
Effect.try({
|
||||||
@@ -136,8 +136,6 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
|
|||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: AccountTable.id,
|
target: AccountTable.id,
|
||||||
set: {
|
set: {
|
||||||
email: input.email,
|
|
||||||
url: input.url,
|
|
||||||
access_token: input.accessToken,
|
access_token: input.accessToken,
|
||||||
refresh_token: input.refreshToken,
|
refresh_token: input.refreshToken,
|
||||||
token_expiry: input.expiry,
|
token_expiry: input.expiry,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const UserCode = Schema.String.pipe(
|
|||||||
)
|
)
|
||||||
export type UserCode = Schema.Schema.Type<typeof UserCode>
|
export type UserCode = Schema.Schema.Type<typeof UserCode>
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Account")({
|
export class Account extends Schema.Class<Account>("Account")({
|
||||||
id: AccountID,
|
id: AccountID,
|
||||||
email: Schema.String,
|
email: Schema.String,
|
||||||
url: Schema.String,
|
url: Schema.String,
|
||||||
|
|||||||
@@ -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 { Permission } from "@/permission"
|
import { PermissionNext } from "@/permission"
|
||||||
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"
|
||||||
@@ -32,7 +32,7 @@ export namespace Agent {
|
|||||||
topP: z.number().optional(),
|
topP: z.number().optional(),
|
||||||
temperature: z.number().optional(),
|
temperature: z.number().optional(),
|
||||||
color: z.string().optional(),
|
color: z.string().optional(),
|
||||||
permission: Permission.Ruleset,
|
permission: PermissionNext.Ruleset,
|
||||||
model: z
|
model: z
|
||||||
.object({
|
.object({
|
||||||
modelID: ModelID.zod,
|
modelID: ModelID.zod,
|
||||||
@@ -54,7 +54,7 @@ export namespace Agent {
|
|||||||
|
|
||||||
const skillDirs = await Skill.dirs()
|
const skillDirs = await Skill.dirs()
|
||||||
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
|
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
|
||||||
const defaults = Permission.fromConfig({
|
const defaults = PermissionNext.fromConfig({
|
||||||
"*": "allow",
|
"*": "allow",
|
||||||
doom_loop: "ask",
|
doom_loop: "ask",
|
||||||
external_directory: {
|
external_directory: {
|
||||||
@@ -72,16 +72,16 @@ export namespace Agent {
|
|||||||
"*.env.example": "allow",
|
"*.env.example": "allow",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const user = Permission.fromConfig(cfg.permission ?? {})
|
const user = PermissionNext.fromConfig(cfg.permission ?? {})
|
||||||
|
|
||||||
const result: Record<string, Info> = {
|
const result: Record<string, Info> = {
|
||||||
build: {
|
build: {
|
||||||
name: "build",
|
name: "build",
|
||||||
description: "The default agent. Executes tools based on configured permissions.",
|
description: "The default agent. Executes tools based on configured permissions.",
|
||||||
options: {},
|
options: {},
|
||||||
permission: Permission.merge(
|
permission: PermissionNext.merge(
|
||||||
defaults,
|
defaults,
|
||||||
Permission.fromConfig({
|
PermissionNext.fromConfig({
|
||||||
question: "allow",
|
question: "allow",
|
||||||
plan_enter: "allow",
|
plan_enter: "allow",
|
||||||
}),
|
}),
|
||||||
@@ -94,9 +94,9 @@ export namespace Agent {
|
|||||||
name: "plan",
|
name: "plan",
|
||||||
description: "Plan mode. Disallows all edit tools.",
|
description: "Plan mode. Disallows all edit tools.",
|
||||||
options: {},
|
options: {},
|
||||||
permission: Permission.merge(
|
permission: PermissionNext.merge(
|
||||||
defaults,
|
defaults,
|
||||||
Permission.fromConfig({
|
PermissionNext.fromConfig({
|
||||||
question: "allow",
|
question: "allow",
|
||||||
plan_exit: "allow",
|
plan_exit: "allow",
|
||||||
external_directory: {
|
external_directory: {
|
||||||
@@ -116,9 +116,9 @@ export namespace Agent {
|
|||||||
general: {
|
general: {
|
||||||
name: "general",
|
name: "general",
|
||||||
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
|
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
|
||||||
permission: Permission.merge(
|
permission: PermissionNext.merge(
|
||||||
defaults,
|
defaults,
|
||||||
Permission.fromConfig({
|
PermissionNext.fromConfig({
|
||||||
todoread: "deny",
|
todoread: "deny",
|
||||||
todowrite: "deny",
|
todowrite: "deny",
|
||||||
}),
|
}),
|
||||||
@@ -130,9 +130,9 @@ export namespace Agent {
|
|||||||
},
|
},
|
||||||
explore: {
|
explore: {
|
||||||
name: "explore",
|
name: "explore",
|
||||||
permission: Permission.merge(
|
permission: PermissionNext.merge(
|
||||||
defaults,
|
defaults,
|
||||||
Permission.fromConfig({
|
PermissionNext.fromConfig({
|
||||||
"*": "deny",
|
"*": "deny",
|
||||||
grep: "allow",
|
grep: "allow",
|
||||||
glob: "allow",
|
glob: "allow",
|
||||||
@@ -161,9 +161,9 @@ export namespace Agent {
|
|||||||
native: true,
|
native: true,
|
||||||
hidden: true,
|
hidden: true,
|
||||||
prompt: PROMPT_COMPACTION,
|
prompt: PROMPT_COMPACTION,
|
||||||
permission: Permission.merge(
|
permission: PermissionNext.merge(
|
||||||
defaults,
|
defaults,
|
||||||
Permission.fromConfig({
|
PermissionNext.fromConfig({
|
||||||
"*": "deny",
|
"*": "deny",
|
||||||
}),
|
}),
|
||||||
user,
|
user,
|
||||||
@@ -177,9 +177,9 @@ export namespace Agent {
|
|||||||
native: true,
|
native: true,
|
||||||
hidden: true,
|
hidden: true,
|
||||||
temperature: 0.5,
|
temperature: 0.5,
|
||||||
permission: Permission.merge(
|
permission: PermissionNext.merge(
|
||||||
defaults,
|
defaults,
|
||||||
Permission.fromConfig({
|
PermissionNext.fromConfig({
|
||||||
"*": "deny",
|
"*": "deny",
|
||||||
}),
|
}),
|
||||||
user,
|
user,
|
||||||
@@ -192,9 +192,9 @@ export namespace Agent {
|
|||||||
options: {},
|
options: {},
|
||||||
native: true,
|
native: true,
|
||||||
hidden: true,
|
hidden: true,
|
||||||
permission: Permission.merge(
|
permission: PermissionNext.merge(
|
||||||
defaults,
|
defaults,
|
||||||
Permission.fromConfig({
|
PermissionNext.fromConfig({
|
||||||
"*": "deny",
|
"*": "deny",
|
||||||
}),
|
}),
|
||||||
user,
|
user,
|
||||||
@@ -213,7 +213,7 @@ export namespace Agent {
|
|||||||
item = result[key] = {
|
item = result[key] = {
|
||||||
name: key,
|
name: key,
|
||||||
mode: "all",
|
mode: "all",
|
||||||
permission: Permission.merge(defaults, user),
|
permission: PermissionNext.merge(defaults, user),
|
||||||
options: {},
|
options: {},
|
||||||
native: false,
|
native: false,
|
||||||
}
|
}
|
||||||
@@ -229,7 +229,7 @@ export namespace Agent {
|
|||||||
item.name = value.name ?? item.name
|
item.name = value.name ?? item.name
|
||||||
item.steps = value.steps ?? item.steps
|
item.steps = value.steps ?? item.steps
|
||||||
item.options = mergeDeep(item.options, value.options ?? {})
|
item.options = mergeDeep(item.options, value.options ?? {})
|
||||||
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
|
item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure Truncate.GLOB is allowed unless explicitly configured
|
// Ensure Truncate.GLOB is allowed unless explicitly configured
|
||||||
@@ -242,9 +242,9 @@ export namespace Agent {
|
|||||||
})
|
})
|
||||||
if (explicit) continue
|
if (explicit) continue
|
||||||
|
|
||||||
result[name].permission = Permission.merge(
|
result[name].permission = PermissionNext.merge(
|
||||||
result[name].permission,
|
result[name].permission,
|
||||||
Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
|
PermissionNext.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,11 +322,11 @@ export namespace Agent {
|
|||||||
}),
|
}),
|
||||||
} satisfies Parameters<typeof generateObject>[0]
|
} satisfies Parameters<typeof generateObject>[0]
|
||||||
|
|
||||||
// TODO: clean this up so provider specific logic doesnt bleed over
|
|
||||||
if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") {
|
if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") {
|
||||||
const result = streamObject({
|
const result = streamObject({
|
||||||
...params,
|
...params,
|
||||||
providerOptions: ProviderTransform.providerOptions(model, {
|
providerOptions: ProviderTransform.providerOptions(model, {
|
||||||
|
instructions: SystemPrompt.instructions(),
|
||||||
store: false,
|
store: false,
|
||||||
}),
|
}),
|
||||||
onError: () => {},
|
onError: () => {},
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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 AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
|
||||||
|
message: Schema.String,
|
||||||
|
cause: Schema.optional(Schema.Defect),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
const file = path.join(Global.Path.data, "auth.json")
|
||||||
|
|
||||||
|
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
|
||||||
|
|
||||||
|
export namespace AuthEffect {
|
||||||
|
export interface Interface {
|
||||||
|
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
|
||||||
|
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
|
||||||
|
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
|
||||||
|
readonly remove: (key: string) => Effect.Effect<void, AuthError>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,101 +1,43 @@
|
|||||||
import path from "path"
|
import { Effect } from "effect"
|
||||||
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
|
import z from "zod"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
import { runtime } from "@/effect/runtime"
|
||||||
import { zod } from "@/util/effect-zod"
|
import * as S from "./effect"
|
||||||
import { Global } from "../global"
|
|
||||||
import { Filesystem } from "../util/filesystem"
|
|
||||||
|
|
||||||
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
export { OAUTH_DUMMY_KEY } from "./effect"
|
||||||
|
|
||||||
const file = path.join(Global.Path.data, "auth.json")
|
function runPromise<A>(f: (service: S.AuthEffect.Interface) => Effect.Effect<A, S.AuthError>) {
|
||||||
|
return runtime.runPromise(S.AuthEffect.Service.use(f))
|
||||||
const fail = (message: string) => (cause: unknown) => new Auth.AuthError({ message, cause })
|
}
|
||||||
|
|
||||||
export namespace Auth {
|
export namespace Auth {
|
||||||
export class Oauth extends Schema.Class<Oauth>("OAuth")({
|
export const Oauth = z
|
||||||
type: Schema.Literal("oauth"),
|
.object({
|
||||||
refresh: Schema.String,
|
type: z.literal("oauth"),
|
||||||
access: Schema.String,
|
refresh: z.string(),
|
||||||
expires: Schema.Number,
|
access: z.string(),
|
||||||
accountId: Schema.optional(Schema.String),
|
expires: z.number(),
|
||||||
enterpriseUrl: Schema.optional(Schema.String),
|
accountId: z.string().optional(),
|
||||||
}) {}
|
enterpriseUrl: z.string().optional(),
|
||||||
|
})
|
||||||
|
.meta({ ref: "OAuth" })
|
||||||
|
|
||||||
export class Api extends Schema.Class<Api>("ApiAuth")({
|
export const Api = z
|
||||||
type: Schema.Literal("api"),
|
.object({
|
||||||
key: Schema.String,
|
type: z.literal("api"),
|
||||||
}) {}
|
key: z.string(),
|
||||||
|
})
|
||||||
|
.meta({ ref: "ApiAuth" })
|
||||||
|
|
||||||
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
|
export const WellKnown = z
|
||||||
type: Schema.Literal("wellknown"),
|
.object({
|
||||||
key: Schema.String,
|
type: z.literal("wellknown"),
|
||||||
token: Schema.String,
|
key: z.string(),
|
||||||
}) {}
|
token: z.string(),
|
||||||
|
})
|
||||||
|
.meta({ ref: "WellKnownAuth" })
|
||||||
|
|
||||||
const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" })
|
export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" })
|
||||||
export const Info = Object.assign(_Info, { zod: zod(_Info) })
|
export type Info = z.infer<typeof Info>
|
||||||
export type Info = Schema.Schema.Type<typeof _Info>
|
|
||||||
|
|
||||||
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
|
|
||||||
message: Schema.String,
|
|
||||||
cause: Schema.optional(Schema.Defect),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
|
|
||||||
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
|
|
||||||
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
|
|
||||||
readonly remove: (key: string) => Effect.Effect<void, AuthError>
|
|
||||||
}
|
|
||||||
|
|
||||||
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 })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
|
||||||
|
|
||||||
export async function get(providerID: string) {
|
export async function get(providerID: string) {
|
||||||
return runPromise((service) => service.get(providerID))
|
return runPromise((service) => service.get(providerID))
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { cmd } from "./cmd"
|
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 { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
|
import { runtime } from "@/effect/runtime"
|
||||||
|
import { AccountID, AccountEffect, OrgID, PollExpired, type PollResult } from "@/account/effect"
|
||||||
import { type AccountError } 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"
|
||||||
@@ -16,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* Account.Service
|
const service = yield* AccountEffect.Service
|
||||||
|
|
||||||
yield* Prompt.intro("Log in")
|
yield* Prompt.intro("Log in")
|
||||||
const login = yield* service.login(url)
|
const login = yield* service.login(url)
|
||||||
@@ -57,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* Account.Service
|
const service = yield* AccountEffect.Service
|
||||||
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")
|
||||||
|
|
||||||
@@ -97,7 +98,7 @@ interface OrgChoice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const switchEffect = Effect.fn("switch")(function* () {
|
const switchEffect = Effect.fn("switch")(function* () {
|
||||||
const service = yield* Account.Service
|
const service = yield* AccountEffect.Service
|
||||||
|
|
||||||
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")
|
||||||
@@ -128,7 +129,7 @@ const switchEffect = Effect.fn("switch")(function* () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const orgsEffect = Effect.fn("orgs")(function* () {
|
const orgsEffect = Effect.fn("orgs")(function* () {
|
||||||
const service = yield* Account.Service
|
const service = yield* AccountEffect.Service
|
||||||
|
|
||||||
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")
|
||||||
@@ -159,7 +160,7 @@ export const LoginCommand = cmd({
|
|||||||
}),
|
}),
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
await Account.runPromise((_svc) => loginEffect(args.url))
|
await runtime.runPromise(loginEffect(args.url))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -173,7 +174,7 @@ export const LogoutCommand = cmd({
|
|||||||
}),
|
}),
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
await Account.runPromise((_svc) => logoutEffect(args.email))
|
await runtime.runPromise(logoutEffect(args.email))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -182,7 +183,7 @@ export const SwitchCommand = cmd({
|
|||||||
describe: false,
|
describe: false,
|
||||||
async handler() {
|
async handler() {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
await Account.runPromise((_svc) => switchEffect())
|
await runtime.runPromise(switchEffect())
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -191,7 +192,7 @@ export const OrgsCommand = cmd({
|
|||||||
describe: false,
|
describe: false,
|
||||||
async handler() {
|
async handler() {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
await Account.runPromise((_svc) => orgsEffect())
|
await runtime.runPromise(orgsEffect())
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const AcpCommand = cmd({
|
|||||||
process.env.OPENCODE_CLIENT = "acp"
|
process.env.OPENCODE_CLIENT = "acp"
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
const opts = await resolveNetworkOptions(args)
|
const opts = await resolveNetworkOptions(args)
|
||||||
const server = Server.listen(opts)
|
const server = await Server.listen(opts)
|
||||||
|
|
||||||
const sdk = createOpencodeClient({
|
const sdk = createOpencodeClient({
|
||||||
baseUrl: `http://${server.hostname}:${server.port}`,
|
baseUrl: `http://${server.hostname}:${server.port}`,
|
||||||
|
|||||||
@@ -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 { Permission } from "../../../permission"
|
import { PermissionNext } from "../../../permission"
|
||||||
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"
|
||||||
@@ -75,7 +75,7 @@ async function getAvailableTools(agent: Agent.Info) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
|
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
|
||||||
const disabled = Permission.disabled(
|
const disabled = PermissionNext.disabled(
|
||||||
availableTools.map((tool) => tool.id),
|
availableTools.map((tool) => tool.id),
|
||||||
agent.permission,
|
agent.permission,
|
||||||
)
|
)
|
||||||
@@ -145,7 +145,7 @@ async function createToolContext(agent: Agent.Info) {
|
|||||||
}
|
}
|
||||||
await Session.updateMessage(message)
|
await Session.updateMessage(message)
|
||||||
|
|
||||||
const ruleset = Permission.merge(agent.permission, session.permission ?? [])
|
const ruleset = PermissionNext.merge(agent.permission, session.permission ?? [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
@@ -155,11 +155,11 @@ async function createToolContext(agent: Agent.Info) {
|
|||||||
abort: new AbortController().signal,
|
abort: new AbortController().signal,
|
||||||
messages: [],
|
messages: [],
|
||||||
metadata: () => {},
|
metadata: () => {},
|
||||||
async ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
|
async ask(req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) {
|
||||||
for (const pattern of req.patterns) {
|
for (const pattern of req.patterns) {
|
||||||
const rule = Permission.evaluate(req.permission, pattern, ruleset)
|
const rule = PermissionNext.evaluate(req.permission, pattern, ruleset)
|
||||||
if (rule.action === "deny") {
|
if (rule.action === "deny") {
|
||||||
throw new Permission.DeniedError({ ruleset })
|
throw new PermissionNext.DeniedError({ ruleset })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 { Permission } from "../../permission"
|
import { PermissionNext } from "../../permission"
|
||||||
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"
|
||||||
@@ -354,7 +354,7 @@ export const RunCommand = cmd({
|
|||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rules: Permission.Ruleset = [
|
const rules: PermissionNext.Ruleset = [
|
||||||
{
|
{
|
||||||
permission: "question",
|
permission: "question",
|
||||||
action: "deny",
|
action: "deny",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const ServeCommand = cmd({
|
|||||||
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||||
}
|
}
|
||||||
const opts = await resolveNetworkOptions(args)
|
const opts = await resolveNetworkOptions(args)
|
||||||
const server = Server.listen(opts)
|
const server = await Server.listen(opts)
|
||||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||||
|
|
||||||
await new Promise(() => {})
|
await new Promise(() => {})
|
||||||
|
|||||||
@@ -1667,7 +1667,6 @@ function InlineTool(props: {
|
|||||||
|
|
||||||
const denied = createMemo(
|
const denied = createMemo(
|
||||||
() =>
|
() =>
|
||||||
error()?.includes("QuestionRejectedError") ||
|
|
||||||
error()?.includes("rejected permission") ||
|
error()?.includes("rejected permission") ||
|
||||||
error()?.includes("specified a rule") ||
|
error()?.includes("specified a rule") ||
|
||||||
error()?.includes("user dismissed"),
|
error()?.includes("user dismissed"),
|
||||||
|
|||||||
@@ -58,10 +58,10 @@ export const UpgradeCommand = {
|
|||||||
spinner.stop("Upgrade failed", 1)
|
spinner.stop("Upgrade failed", 1)
|
||||||
if (err instanceof Installation.UpgradeFailedError) {
|
if (err instanceof Installation.UpgradeFailedError) {
|
||||||
// necessary because choco only allows install/upgrade in elevated terminals
|
// necessary because choco only allows install/upgrade in elevated terminals
|
||||||
if (method === "choco" && err.stderr.includes("not running from an elevated command shell")) {
|
if (method === "choco" && err.data.stderr.includes("not running from an elevated command shell")) {
|
||||||
prompts.log.error("Please run the terminal as Administrator and try again")
|
prompts.log.error("Please run the terminal as Administrator and try again")
|
||||||
} else {
|
} else {
|
||||||
prompts.log.error(err.stderr)
|
prompts.log.error(err.data.stderr)
|
||||||
}
|
}
|
||||||
} else if (err instanceof Error) prompts.log.error(err.message)
|
} else if (err instanceof Error) prompts.log.error(err.message)
|
||||||
prompts.outro("Done")
|
prompts.outro("Done")
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const WebCommand = cmd({
|
|||||||
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||||
}
|
}
|
||||||
const opts = await resolveNetworkOptions(args)
|
const opts = await resolveNetworkOptions(args)
|
||||||
const server = Server.listen(opts)
|
const server = await Server.listen(opts)
|
||||||
UI.empty()
|
UI.empty()
|
||||||
UI.println(UI.logo(" "))
|
UI.println(UI.logo(" "))
|
||||||
UI.empty()
|
UI.empty()
|
||||||
|
|||||||
@@ -1,23 +1,15 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
|
||||||
import { SessionID, MessageID } from "@/session/schema"
|
import { SessionID, MessageID } from "@/session/schema"
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { MCP } from "../mcp"
|
import { Instance } from "../project/instance"
|
||||||
import { Skill } from "../skill"
|
import { Identifier } from "../id/id"
|
||||||
import { Log } from "../util/log"
|
|
||||||
import PROMPT_INITIALIZE from "./template/initialize.txt"
|
import PROMPT_INITIALIZE from "./template/initialize.txt"
|
||||||
import PROMPT_REVIEW from "./template/review.txt"
|
import PROMPT_REVIEW from "./template/review.txt"
|
||||||
|
import { MCP } from "../mcp"
|
||||||
|
import { Skill } from "../skill"
|
||||||
|
|
||||||
export namespace Command {
|
export namespace Command {
|
||||||
const log = Log.create({ service: "command" })
|
|
||||||
|
|
||||||
type State = {
|
|
||||||
commands: Record<string, Info>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Event = {
|
export const Event = {
|
||||||
Executed: BusEvent.define(
|
Executed: BusEvent.define(
|
||||||
"command.executed",
|
"command.executed",
|
||||||
@@ -50,7 +42,7 @@ export namespace Command {
|
|||||||
// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
|
// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
|
||||||
export type Info = Omit<z.infer<typeof Info>, "template"> & { template: Promise<string> | string }
|
export type Info = Omit<z.infer<typeof Info>, "template"> & { template: Promise<string> | string }
|
||||||
|
|
||||||
export function hints(template: string) {
|
export function hints(template: string): string[] {
|
||||||
const result: string[] = []
|
const result: string[] = []
|
||||||
const numbered = template.match(/\$\d+/g)
|
const numbered = template.match(/\$\d+/g)
|
||||||
if (numbered) {
|
if (numbered) {
|
||||||
@@ -65,121 +57,95 @@ export namespace Command {
|
|||||||
REVIEW: "review",
|
REVIEW: "review",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export interface Interface {
|
const state = Instance.state(async () => {
|
||||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
const cfg = await Config.get()
|
||||||
readonly list: () => Effect.Effect<Info[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Command") {}
|
const result: Record<string, Info> = {
|
||||||
|
[Default.INIT]: {
|
||||||
|
name: Default.INIT,
|
||||||
|
description: "create/update AGENTS.md",
|
||||||
|
source: "command",
|
||||||
|
get template() {
|
||||||
|
return PROMPT_INITIALIZE.replace("${path}", Instance.worktree)
|
||||||
|
},
|
||||||
|
hints: hints(PROMPT_INITIALIZE),
|
||||||
|
},
|
||||||
|
[Default.REVIEW]: {
|
||||||
|
name: Default.REVIEW,
|
||||||
|
description: "review changes [commit|branch|pr], defaults to uncommitted",
|
||||||
|
source: "command",
|
||||||
|
get template() {
|
||||||
|
return PROMPT_REVIEW.replace("${path}", Instance.worktree)
|
||||||
|
},
|
||||||
|
subtask: true,
|
||||||
|
hints: hints(PROMPT_REVIEW),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
for (const [name, command] of Object.entries(cfg.command ?? {})) {
|
||||||
Service,
|
result[name] = {
|
||||||
Effect.gen(function* () {
|
name,
|
||||||
const init = Effect.fn("Command.state")(function* (ctx) {
|
agent: command.agent,
|
||||||
const cfg = yield* Effect.promise(() => Config.get())
|
model: command.model,
|
||||||
const commands: Record<string, Info> = {}
|
description: command.description,
|
||||||
|
source: "command",
|
||||||
|
get template() {
|
||||||
|
return command.template
|
||||||
|
},
|
||||||
|
subtask: command.subtask,
|
||||||
|
hints: hints(command.template),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [name, prompt] of Object.entries(await MCP.prompts())) {
|
||||||
|
result[name] = {
|
||||||
|
name,
|
||||||
|
source: "mcp",
|
||||||
|
description: prompt.description,
|
||||||
|
get template() {
|
||||||
|
// since a getter can't be async we need to manually return a promise here
|
||||||
|
return new Promise<string>(async (resolve, reject) => {
|
||||||
|
const template = await MCP.getPrompt(
|
||||||
|
prompt.client,
|
||||||
|
prompt.name,
|
||||||
|
prompt.arguments
|
||||||
|
? // substitute each argument with $1, $2, etc.
|
||||||
|
Object.fromEntries(prompt.arguments?.map((argument, i) => [argument.name, `$${i + 1}`]))
|
||||||
|
: {},
|
||||||
|
).catch(reject)
|
||||||
|
resolve(
|
||||||
|
template?.messages
|
||||||
|
.map((message) => (message.content.type === "text" ? message.content.text : ""))
|
||||||
|
.join("\n") || "",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
commands[Default.INIT] = {
|
// Add skills as invokable commands
|
||||||
name: Default.INIT,
|
for (const skill of await Skill.all()) {
|
||||||
description: "create/update AGENTS.md",
|
// Skip if a command with this name already exists
|
||||||
source: "command",
|
if (result[skill.name]) continue
|
||||||
get template() {
|
result[skill.name] = {
|
||||||
return PROMPT_INITIALIZE.replace("${path}", ctx.worktree)
|
name: skill.name,
|
||||||
},
|
description: skill.description,
|
||||||
hints: hints(PROMPT_INITIALIZE),
|
source: "skill",
|
||||||
}
|
get template() {
|
||||||
commands[Default.REVIEW] = {
|
return skill.content
|
||||||
name: Default.REVIEW,
|
},
|
||||||
description: "review changes [commit|branch|pr], defaults to uncommitted",
|
hints: [],
|
||||||
source: "command",
|
}
|
||||||
get template() {
|
}
|
||||||
return PROMPT_REVIEW.replace("${path}", ctx.worktree)
|
|
||||||
},
|
|
||||||
subtask: true,
|
|
||||||
hints: hints(PROMPT_REVIEW),
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [name, command] of Object.entries(cfg.command ?? {})) {
|
return result
|
||||||
commands[name] = {
|
})
|
||||||
name,
|
|
||||||
agent: command.agent,
|
|
||||||
model: command.model,
|
|
||||||
description: command.description,
|
|
||||||
source: "command",
|
|
||||||
get template() {
|
|
||||||
return command.template
|
|
||||||
},
|
|
||||||
subtask: command.subtask,
|
|
||||||
hints: hints(command.template),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [name, prompt] of Object.entries(yield* Effect.promise(() => MCP.prompts()))) {
|
|
||||||
commands[name] = {
|
|
||||||
name,
|
|
||||||
source: "mcp",
|
|
||||||
description: prompt.description,
|
|
||||||
get template() {
|
|
||||||
return new Promise<string>(async (resolve, reject) => {
|
|
||||||
const template = await MCP.getPrompt(
|
|
||||||
prompt.client,
|
|
||||||
prompt.name,
|
|
||||||
prompt.arguments
|
|
||||||
? Object.fromEntries(prompt.arguments.map((argument, i) => [argument.name, `$${i + 1}`]))
|
|
||||||
: {},
|
|
||||||
).catch(reject)
|
|
||||||
resolve(
|
|
||||||
template?.messages
|
|
||||||
.map((message) => (message.content.type === "text" ? message.content.text : ""))
|
|
||||||
.join("\n") || "",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const skill of yield* Effect.promise(() => Skill.all())) {
|
|
||||||
if (commands[skill.name]) continue
|
|
||||||
commands[skill.name] = {
|
|
||||||
name: skill.name,
|
|
||||||
description: skill.description,
|
|
||||||
source: "skill",
|
|
||||||
get template() {
|
|
||||||
return skill.content
|
|
||||||
},
|
|
||||||
hints: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
commands,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const cache = yield* InstanceState.make<State>((ctx) => init(ctx))
|
|
||||||
|
|
||||||
const get = Effect.fn("Command.get")(function* (name: string) {
|
|
||||||
const state = yield* InstanceState.get(cache)
|
|
||||||
return state.commands[name]
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = Effect.fn("Command.list")(function* () {
|
|
||||||
const state = yield* InstanceState.get(cache)
|
|
||||||
return Object.values(state.commands)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ get, list })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
|
||||||
|
|
||||||
export async function get(name: string) {
|
export async function get(name: string) {
|
||||||
return runPromise((svc) => svc.get(name))
|
return state().then((x) => x[name])
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function list() {
|
export async function list() {
|
||||||
return runPromise((svc) => svc.list())
|
return state().then((x) => Object.values(x))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export namespace Config {
|
|||||||
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
||||||
}
|
}
|
||||||
|
|
||||||
const active = await Account.active()
|
const active = Account.active()
|
||||||
if (active?.active_org_id) {
|
if (active?.active_org_id) {
|
||||||
try {
|
try {
|
||||||
const [config, token] = await Promise.all([
|
const [config, token] = await Promise.all([
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createAdaptorServer } from "@hono/node-server"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { Instance } from "../../project/instance"
|
import { Instance } from "../../project/instance"
|
||||||
import { InstanceBootstrap } from "../../project/bootstrap"
|
import { InstanceBootstrap } from "../../project/bootstrap"
|
||||||
@@ -56,10 +57,24 @@ export namespace WorkspaceServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Listen(opts: { hostname: string; port: number }) {
|
export function Listen(opts: { hostname: string; port: number }) {
|
||||||
return Bun.serve({
|
const server = createAdaptorServer({
|
||||||
hostname: opts.hostname,
|
|
||||||
port: opts.port,
|
|
||||||
fetch: App().fetch,
|
fetch: App().fetch,
|
||||||
})
|
})
|
||||||
|
server.listen(opts.port, opts.hostname)
|
||||||
|
return {
|
||||||
|
hostname: opts.hostname,
|
||||||
|
port: opts.port,
|
||||||
|
stop() {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
server.close((err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import { Effect, ScopedCache, Scope } from "effect"
|
|
||||||
import { Instance, type Shape } from "@/project/instance"
|
|
||||||
import { registerDisposer } from "./instance-registry"
|
|
||||||
|
|
||||||
const TypeId = "~opencode/InstanceState"
|
|
||||||
|
|
||||||
export interface InstanceState<A, E = never, R = never> {
|
|
||||||
readonly [TypeId]: typeof TypeId
|
|
||||||
readonly cache: ScopedCache.ScopedCache<string, A, E, R>
|
|
||||||
}
|
|
||||||
|
|
||||||
export namespace InstanceState {
|
|
||||||
export const make = <A, E = never, R = never>(
|
|
||||||
init: (ctx: Shape) => Effect.Effect<A, E, R | Scope.Scope>,
|
|
||||||
): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const cache = yield* ScopedCache.make<string, A, E, R>({
|
|
||||||
capacity: Number.POSITIVE_INFINITY,
|
|
||||||
lookup: () => init(Instance.current),
|
|
||||||
})
|
|
||||||
|
|
||||||
const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory)))
|
|
||||||
yield* Effect.addFinalizer(() => Effect.sync(off))
|
|
||||||
|
|
||||||
return {
|
|
||||||
[TypeId]: TypeId,
|
|
||||||
cache,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export const get = <A, E, R>(self: InstanceState<A, E, R>) =>
|
|
||||||
Effect.suspend(() => ScopedCache.get(self.cache, Instance.directory))
|
|
||||||
|
|
||||||
export const use = <A, E, R, B>(self: InstanceState<A, E, R>, select: (value: A) => B) =>
|
|
||||||
Effect.map(get(self), select)
|
|
||||||
|
|
||||||
export const useEffect = <A, E, R, B, E2, R2>(
|
|
||||||
self: InstanceState<A, E, R>,
|
|
||||||
select: (value: A) => Effect.Effect<B, E2, R2>,
|
|
||||||
) => Effect.flatMap(get(self), select)
|
|
||||||
|
|
||||||
export const has = <A, E, R>(self: InstanceState<A, E, R>) =>
|
|
||||||
Effect.suspend(() => ScopedCache.has(self.cache, Instance.directory))
|
|
||||||
|
|
||||||
export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
|
|
||||||
Effect.suspend(() => ScopedCache.invalidate(self.cache, Instance.directory))
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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 { PermissionNext } from "@/permission"
|
||||||
|
import { Instance } from "@/project/instance"
|
||||||
|
import { Vcs } from "@/project/vcs"
|
||||||
|
import { ProviderAuth } from "@/provider/auth"
|
||||||
|
import { Question } from "@/question"
|
||||||
|
import { Skill } from "@/skill/skill"
|
||||||
|
import { Snapshot } from "@/snapshot"
|
||||||
|
import { InstanceContext } from "./instance-context"
|
||||||
|
import { registerDisposer } from "./instance-registry"
|
||||||
|
|
||||||
|
export { InstanceContext } from "./instance-context"
|
||||||
|
|
||||||
|
export type InstanceServices =
|
||||||
|
| Question.Service
|
||||||
|
| PermissionNext.Service
|
||||||
|
| ProviderAuth.Service
|
||||||
|
| FileWatcher.Service
|
||||||
|
| Vcs.Service
|
||||||
|
| FileTime.Service
|
||||||
|
| Format.Service
|
||||||
|
| File.Service
|
||||||
|
| Skill.Service
|
||||||
|
| Snapshot.Service
|
||||||
|
|
||||||
|
// NOTE: LayerMap only passes the key (directory string) to lookup, but we need
|
||||||
|
// the full instance context (directory, worktree, project). We read from the
|
||||||
|
// legacy Instance ALS here, which is safe because lookup is only triggered via
|
||||||
|
// 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(
|
||||||
|
Layer.fresh(Question.layer),
|
||||||
|
Layer.fresh(PermissionNext.layer),
|
||||||
|
Layer.fresh(ProviderAuth.defaultLayer),
|
||||||
|
Layer.fresh(FileWatcher.layer).pipe(Layer.orDie),
|
||||||
|
Layer.fresh(Vcs.layer),
|
||||||
|
Layer.fresh(FileTime.layer).pipe(Layer.orDie),
|
||||||
|
Layer.fresh(Format.layer),
|
||||||
|
Layer.fresh(File.layer),
|
||||||
|
Layer.fresh(Skill.defaultLayer),
|
||||||
|
Layer.fresh(Snapshot.defaultLayer),
|
||||||
|
).pipe(Layer.provide(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<string, InstanceServices>>()(
|
||||||
|
"opencode/Instances",
|
||||||
|
) {
|
||||||
|
static readonly layer = Layer.effect(
|
||||||
|
Instances,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
|
||||||
|
const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
|
||||||
|
yield* Effect.addFinalizer(() => Effect.sync(unregister))
|
||||||
|
return Instances.of(layerMap)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
static get(directory: string): Layer.Layer<InstanceServices, never, Instances> {
|
||||||
|
return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory))))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
|
||||||
import * as ServiceMap from "effect/ServiceMap"
|
|
||||||
|
|
||||||
export const memoMap = Layer.makeMemoMapUnsafe()
|
|
||||||
|
|
||||||
export function makeRunPromise<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
|
|
||||||
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
|
|
||||||
|
|
||||||
return <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) => {
|
|
||||||
rt ??= ManagedRuntime.make(layer, { memoMap })
|
|
||||||
return rt.runPromise(service.use(fn), options)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||||
|
import { AccountEffect } from "@/account/effect"
|
||||||
|
import { AuthEffect } from "@/auth/effect"
|
||||||
|
import { Instances } from "@/effect/instances"
|
||||||
|
import type { InstanceServices } from "@/effect/instances"
|
||||||
|
import { TruncateEffect } from "@/tool/truncate-effect"
|
||||||
|
import { Instance } from "@/project/instance"
|
||||||
|
|
||||||
|
export const runtime = ManagedRuntime.make(
|
||||||
|
Layer.mergeAll(
|
||||||
|
AccountEffect.defaultLayer, //
|
||||||
|
TruncateEffect.defaultLayer,
|
||||||
|
Instances.layer,
|
||||||
|
).pipe(Layer.provideMerge(AuthEffect.layer)),
|
||||||
|
)
|
||||||
|
|
||||||
|
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
|
||||||
|
return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disposeRuntime() {
|
||||||
|
return runtime.dispose()
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
import { git } from "@/util/git"
|
import { git } from "@/util/git"
|
||||||
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
|
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
|
||||||
import { formatPatch, structuredPatch } from "diff"
|
import { formatPatch, structuredPatch } from "diff"
|
||||||
@@ -83,6 +83,26 @@ export namespace File {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function init() {
|
||||||
|
return runPromiseInstance(Service.use((svc) => svc.init()))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function status() {
|
||||||
|
return runPromiseInstance(Service.use((svc) => svc.status()))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function read(file: string): Promise<Content> {
|
||||||
|
return runPromiseInstance(Service.use((svc) => svc.read(file)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function list(dir?: string) {
|
||||||
|
return runPromiseInstance(Service.use((svc) => svc.list(dir)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
|
||||||
|
return runPromiseInstance(Service.use((svc) => svc.search(input)))
|
||||||
|
}
|
||||||
|
|
||||||
const log = Log.create({ service: "file" })
|
const log = Log.create({ service: "file" })
|
||||||
|
|
||||||
const binary = new Set([
|
const binary = new Set([
|
||||||
@@ -179,6 +199,12 @@ export namespace File {
|
|||||||
"efi",
|
"efi",
|
||||||
"rom",
|
"rom",
|
||||||
"com",
|
"com",
|
||||||
|
"cmd",
|
||||||
|
"ps1",
|
||||||
|
"sh",
|
||||||
|
"bash",
|
||||||
|
"zsh",
|
||||||
|
"fish",
|
||||||
])
|
])
|
||||||
|
|
||||||
const image = new Set([
|
const image = new Set([
|
||||||
@@ -297,7 +323,7 @@ export namespace File {
|
|||||||
|
|
||||||
function shouldEncode(mimeType: string) {
|
function shouldEncode(mimeType: string) {
|
||||||
const type = mimeType.toLowerCase()
|
const type = mimeType.toLowerCase()
|
||||||
log.debug("shouldEncode", { type })
|
log.info("shouldEncode", { type })
|
||||||
if (!type) return false
|
if (!type) return false
|
||||||
if (type.startsWith("text/")) return false
|
if (type.startsWith("text/")) return false
|
||||||
if (type.includes("charset=")) return false
|
if (type.includes("charset=")) return false
|
||||||
@@ -321,11 +347,6 @@ export namespace File {
|
|||||||
return [...visible, ...hiddenItems]
|
return [...visible, ...hiddenItems]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
|
||||||
cache: Entry
|
|
||||||
fiber: Fiber.Fiber<void> | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
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[]>
|
||||||
@@ -344,18 +365,12 @@ export namespace File {
|
|||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const state = yield* InstanceState.make<State>(
|
const instance = yield* InstanceContext
|
||||||
Effect.fn("File.state")(() =>
|
let cache: Entry = { files: [], dirs: [] }
|
||||||
Effect.succeed({
|
const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
|
||||||
cache: { files: [], dirs: [] } as Entry,
|
|
||||||
fiber: undefined as Fiber.Fiber<void> | undefined,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const scan = Effect.fn("File.scan")(function* () {
|
const scan = Effect.fn("File.scan")(function* () {
|
||||||
if (Instance.directory === path.parse(Instance.directory).root) return
|
if (instance.directory === path.parse(instance.directory).root) return
|
||||||
const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
|
|
||||||
const next: Entry = { files: [], dirs: [] }
|
const next: Entry = { files: [], dirs: [] }
|
||||||
|
|
||||||
yield* Effect.promise(async () => {
|
yield* Effect.promise(async () => {
|
||||||
@@ -366,7 +381,7 @@ export namespace File {
|
|||||||
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[])
|
||||||
|
|
||||||
for (const entry of top) {
|
for (const entry of top) {
|
||||||
@@ -374,7 +389,7 @@ export namespace File {
|
|||||||
if (shouldIgnoreName(entry.name)) continue
|
if (shouldIgnoreName(entry.name)) continue
|
||||||
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
|
||||||
@@ -386,7 +401,7 @@ export namespace File {
|
|||||||
next.dirs = Array.from(dirs).toSorted()
|
next.dirs = Array.from(dirs).toSorted()
|
||||||
} else {
|
} else {
|
||||||
const seen = new Set<string>()
|
const seen = 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
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -402,38 +417,31 @@ export namespace File {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const s = yield* InstanceState.get(state)
|
cache = next
|
||||||
s.cache = next
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const getFiles = () => cache
|
||||||
|
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
|
let fiber: Fiber.Fiber<void> | undefined
|
||||||
const ensure = Effect.fn("File.ensure")(function* () {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
if (!s.fiber)
|
|
||||||
s.fiber = yield* scan().pipe(
|
|
||||||
Effect.catchCause(() => Effect.void),
|
|
||||||
Effect.ensuring(
|
|
||||||
Effect.sync(() => {
|
|
||||||
s.fiber = undefined
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Effect.forkIn(scope),
|
|
||||||
)
|
|
||||||
yield* Fiber.join(s.fiber)
|
|
||||||
})
|
|
||||||
|
|
||||||
const init = Effect.fn("File.init")(function* () {
|
const init = Effect.fn("File.init")(function* () {
|
||||||
yield* ensure()
|
if (!fiber) {
|
||||||
|
fiber = yield* scan().pipe(
|
||||||
|
Effect.catchCause(() => Effect.void),
|
||||||
|
Effect.forkIn(scope),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
yield* Fiber.join(fiber)
|
||||||
})
|
})
|
||||||
|
|
||||||
const status = Effect.fn("File.status")(function* () {
|
const status = Effect.fn("File.status")(function* () {
|
||||||
if (Instance.project.vcs !== "git") return []
|
if (instance.project.vcs !== "git") return []
|
||||||
|
|
||||||
return yield* Effect.promise(async () => {
|
return yield* Effect.promise(async () => {
|
||||||
const diffOutput = (
|
const diffOutput = (
|
||||||
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
|
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
|
||||||
cwd: Instance.directory,
|
cwd: instance.directory,
|
||||||
})
|
})
|
||||||
).text()
|
).text()
|
||||||
|
|
||||||
@@ -463,7 +471,7 @@ export namespace File {
|
|||||||
"--exclude-standard",
|
"--exclude-standard",
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
cwd: Instance.directory,
|
cwd: instance.directory,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
).text()
|
).text()
|
||||||
@@ -471,7 +479,7 @@ export namespace File {
|
|||||||
if (untrackedOutput.trim()) {
|
if (untrackedOutput.trim()) {
|
||||||
for (const file of untrackedOutput.trim().split("\n")) {
|
for (const file of untrackedOutput.trim().split("\n")) {
|
||||||
try {
|
try {
|
||||||
const content = await Filesystem.readText(path.join(Instance.directory, file))
|
const content = await Filesystem.readText(path.join(instance.directory, file))
|
||||||
changed.push({
|
changed.push({
|
||||||
path: file,
|
path: file,
|
||||||
added: content.split("\n").length,
|
added: content.split("\n").length,
|
||||||
@@ -497,7 +505,7 @@ export namespace File {
|
|||||||
"HEAD",
|
"HEAD",
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
cwd: Instance.directory,
|
cwd: instance.directory,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
).text()
|
).text()
|
||||||
@@ -514,10 +522,10 @@ export namespace File {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return changed.map((item) => {
|
return changed.map((item) => {
|
||||||
const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
|
const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path)
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
path: path.relative(Instance.directory, full),
|
path: path.relative(instance.directory, full),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -526,7 +534,7 @@ export namespace File {
|
|||||||
const read = Effect.fn("File.read")(function* (file: string) {
|
const read = Effect.fn("File.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")
|
||||||
@@ -574,19 +582,19 @@ export namespace File {
|
|||||||
|
|
||||||
const content = (await Filesystem.readText(full).catch(() => "")).trim()
|
const content = (await Filesystem.readText(full).catch(() => "")).trim()
|
||||||
|
|
||||||
if (Instance.project.vcs === "git") {
|
if (instance.project.vcs === "git") {
|
||||||
let diff = (
|
let diff = (
|
||||||
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
|
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory })
|
||||||
).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()) {
|
||||||
const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
|
const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text()
|
||||||
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
||||||
context: Infinity,
|
context: Infinity,
|
||||||
ignoreWhitespace: true,
|
ignoreWhitespace: true,
|
||||||
@@ -608,20 +616,20 @@ export namespace File {
|
|||||||
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 gitignore = path.join(instance.project.worktree, ".gitignore")
|
||||||
if (await Filesystem.exists(gitignore)) {
|
if (await Filesystem.exists(gitignore)) {
|
||||||
ig.add(await Filesystem.readText(gitignore))
|
ig.add(await Filesystem.readText(gitignore))
|
||||||
}
|
}
|
||||||
const ignoreFile = path.join(Instance.project.worktree, ".ignore")
|
const ignoreFile = path.join(instance.project.worktree, ".ignore")
|
||||||
if (await Filesystem.exists(ignoreFile)) {
|
if (await Filesystem.exists(ignoreFile)) {
|
||||||
ig.add(await Filesystem.readText(ignoreFile))
|
ig.add(await Filesystem.readText(ignoreFile))
|
||||||
}
|
}
|
||||||
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")
|
||||||
}
|
}
|
||||||
@@ -630,7 +638,7 @@ export namespace File {
|
|||||||
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 absolute = path.join(resolved, entry.name)
|
||||||
const file = path.relative(Instance.directory, absolute)
|
const file = path.relative(instance.directory, absolute)
|
||||||
const type = entry.isDirectory() ? "directory" : "file"
|
const type = entry.isDirectory() ? "directory" : "file"
|
||||||
nodes.push({
|
nodes.push({
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
@@ -654,16 +662,13 @@ export namespace File {
|
|||||||
dirs?: boolean
|
dirs?: boolean
|
||||||
type?: "file" | "directory"
|
type?: "file" | "directory"
|
||||||
}) {
|
}) {
|
||||||
yield* ensure()
|
|
||||||
const { cache } = yield* InstanceState.get(state)
|
|
||||||
|
|
||||||
return yield* Effect.promise(async () => {
|
return yield* Effect.promise(async () => {
|
||||||
const query = input.query.trim()
|
const query = input.query.trim()
|
||||||
const limit = input.limit ?? 100
|
const limit = input.limit ?? 100
|
||||||
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
||||||
log.info("search", { query, kind })
|
log.info("search", { query, kind })
|
||||||
|
|
||||||
const result = cache
|
const result = getFiles()
|
||||||
const preferHidden = query.startsWith(".") || query.includes("/.")
|
const preferHidden = query.startsWith(".") || query.includes("/.")
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
@@ -687,26 +692,4 @@ export namespace File {
|
|||||||
return Service.of({ init, status, read, list, search })
|
return Service.of({ init, status, read, list, search })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
|
||||||
|
|
||||||
export function init() {
|
|
||||||
return runPromise((svc) => svc.init())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function status() {
|
|
||||||
return runPromise((svc) => svc.status())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function read(file: string): Promise<Content> {
|
|
||||||
return runPromise((svc) => svc.read(file))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function list(dir?: string) {
|
|
||||||
return runPromise((svc) => svc.list(dir))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
|
|
||||||
return runPromise((svc) => svc.search(input))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
|
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import type { SessionID } from "@/session/schema"
|
import type { SessionID } from "@/session/schema"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
@@ -36,11 +35,6 @@ export namespace FileTime {
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
|
||||||
reads: Map<SessionID, Map<string, Stamp>>
|
|
||||||
locks: Map<string, Semaphore.Semaphore>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
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>
|
||||||
@@ -54,40 +48,30 @@ export namespace FileTime {
|
|||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
|
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
|
||||||
const state = yield* InstanceState.make<State>(
|
const reads = new Map<SessionID, Map<string, Stamp>>()
|
||||||
Effect.fn("FileTime.state")(() =>
|
const locks = new Map<string, Semaphore.Semaphore>()
|
||||||
Effect.succeed({
|
|
||||||
reads: new Map<SessionID, Map<string, Stamp>>(),
|
|
||||||
locks: new Map<string, Semaphore.Semaphore>(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) {
|
const getLock = (filepath: string) => {
|
||||||
const locks = (yield* InstanceState.get(state)).locks
|
|
||||||
const lock = locks.get(filepath)
|
const lock = locks.get(filepath)
|
||||||
if (lock) return lock
|
if (lock) return lock
|
||||||
|
|
||||||
const next = Semaphore.makeUnsafe(1)
|
const next = Semaphore.makeUnsafe(1)
|
||||||
locks.set(filepath, next)
|
locks.set(filepath, next)
|
||||||
return next
|
return next
|
||||||
})
|
}
|
||||||
|
|
||||||
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
|
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
|
||||||
const reads = (yield* InstanceState.get(state)).reads
|
|
||||||
log.info("read", { sessionID, file })
|
log.info("read", { sessionID, file })
|
||||||
session(reads, sessionID).set(file, yield* stamp(file))
|
session(reads, sessionID).set(file, yield* stamp(file))
|
||||||
})
|
})
|
||||||
|
|
||||||
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
|
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
|
||||||
const reads = (yield* InstanceState.get(state)).reads
|
|
||||||
return reads.get(sessionID)?.get(file)?.read
|
return reads.get(sessionID)?.get(file)?.read
|
||||||
})
|
})
|
||||||
|
|
||||||
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
|
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
|
||||||
if (disableCheck) return
|
if (disableCheck) return
|
||||||
|
|
||||||
const reads = (yield* InstanceState.get(state)).reads
|
|
||||||
const time = reads.get(sessionID)?.get(filepath)
|
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`)
|
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
|
||||||
|
|
||||||
@@ -101,28 +85,26 @@ export namespace FileTime {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
|
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
|
||||||
return yield* Effect.promise(fn).pipe((yield* getLock(filepath)).withPermits(1))
|
return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1))
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ read, get, assert, withLock })
|
return Service.of({ read, get, assert, withLock })
|
||||||
}),
|
}),
|
||||||
).pipe(Layer.orDie)
|
)
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
|
||||||
|
|
||||||
export function read(sessionID: SessionID, file: string) {
|
export function read(sessionID: SessionID, file: string) {
|
||||||
return runPromise((s) => s.read(sessionID, file))
|
return runPromiseInstance(Service.use((s) => s.read(sessionID, file)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function get(sessionID: SessionID, file: string) {
|
export function get(sessionID: SessionID, file: string) {
|
||||||
return runPromise((s) => s.get(sessionID, file))
|
return runPromiseInstance(Service.use((s) => s.get(sessionID, file)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function assert(sessionID: SessionID, filepath: string) {
|
export async function assert(sessionID: SessionID, filepath: string) {
|
||||||
return runPromise((s) => s.assert(sessionID, filepath))
|
return runPromiseInstance(Service.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 runPromise((s) => s.withLock(filepath, fn))
|
return runPromiseInstance(Service.use((s) => s.withLock(filepath, fn)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Cause, Effect, Layer, Scope, ServiceMap } from "effect"
|
import { Cause, Effect, Layer, ServiceMap } from "effect"
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||||
import type ParcelWatcher from "@parcel/watcher"
|
import type ParcelWatcher from "@parcel/watcher"
|
||||||
@@ -7,8 +7,7 @@ import path from "path"
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { git } from "@/util/git"
|
import { git } from "@/util/git"
|
||||||
@@ -61,107 +60,82 @@ export namespace FileWatcher {
|
|||||||
|
|
||||||
export const hasNativeBinding = () => !!watcher()
|
export const hasNativeBinding = () => !!watcher()
|
||||||
|
|
||||||
export interface Interface {
|
export class Service extends ServiceMap.Service<Service, {}>()("@opencode/FileWatcher") {}
|
||||||
readonly init: () => Effect.Effect<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileWatcher") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const state = yield* InstanceState.make(
|
const instance = yield* InstanceContext
|
||||||
Effect.fn("FileWatcher.state")(
|
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({})
|
||||||
function* () {
|
|
||||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
|
|
||||||
|
|
||||||
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
|
return Service.of({})
|
||||||
}
|
}
|
||||||
|
|
||||||
const w = watcher()
|
const w = watcher()
|
||||||
if (!w) return
|
if (!w) return Service.of({})
|
||||||
|
|
||||||
log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend })
|
log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend })
|
||||||
|
|
||||||
const subs: ParcelWatcher.AsyncSubscription[] = []
|
const subs: ParcelWatcher.AsyncSubscription[] = []
|
||||||
yield* Effect.addFinalizer(() =>
|
yield* Effect.addFinalizer(() => Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))))
|
||||||
Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
|
|
||||||
)
|
|
||||||
|
|
||||||
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" })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const subscribe = (dir: string, ignore: string[]) => {
|
const subscribe = (dir: string, ignore: string[]) => {
|
||||||
const pending = w.subscribe(dir, cb, { ignore, backend })
|
const pending = w.subscribe(dir, cb, { ignore, backend })
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const sub = yield* Effect.promise(() => pending)
|
const sub = yield* Effect.promise(() => pending)
|
||||||
subs.push(sub)
|
subs.push(sub)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
|
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
|
||||||
Effect.catchCause((cause) => {
|
|
||||||
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
|
|
||||||
pending.then((s) => s.unsubscribe()).catch(() => {})
|
|
||||||
return Effect.void
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cfg = yield* Effect.promise(() => Config.get())
|
|
||||||
const cfgIgnores = cfg.watcher?.ignore ?? []
|
|
||||||
|
|
||||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
|
||||||
yield* subscribe(Instance.directory, [
|
|
||||||
...FileIgnore.PATTERNS,
|
|
||||||
...cfgIgnores,
|
|
||||||
...protecteds(Instance.directory),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Instance.project.vcs === "git") {
|
|
||||||
const result = yield* Effect.promise(() =>
|
|
||||||
git(["rev-parse", "--git-dir"], {
|
|
||||||
cwd: Instance.project.worktree,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const vcsDir =
|
|
||||||
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
|
|
||||||
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
|
||||||
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
|
|
||||||
(entry) => entry !== "HEAD",
|
|
||||||
)
|
|
||||||
yield* subscribe(vcsDir, ignore)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Effect.catchCause((cause) => {
|
Effect.catchCause((cause) => {
|
||||||
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
|
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
|
||||||
|
pending.then((s) => s.unsubscribe()).catch(() => {})
|
||||||
return Effect.void
|
return Effect.void
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
)
|
}
|
||||||
|
|
||||||
return Service.of({
|
const cfg = yield* Effect.promise(() => Config.get())
|
||||||
init: Effect.fn("FileWatcher.init")(function* () {
|
const cfgIgnores = cfg.watcher?.ignore ?? []
|
||||||
yield* InstanceState.get(state)
|
|
||||||
}),
|
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
||||||
})
|
yield* subscribe(instance.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(instance.directory)])
|
||||||
}),
|
}
|
||||||
|
|
||||||
|
if (instance.project.vcs === "git") {
|
||||||
|
const result = yield* Effect.promise(() =>
|
||||||
|
git(["rev-parse", "--git-dir"], {
|
||||||
|
cwd: instance.project.worktree,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const vcsDir = result.exitCode === 0 ? path.resolve(instance.project.worktree, result.text().trim()) : undefined
|
||||||
|
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
||||||
|
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
|
||||||
|
(entry) => entry !== "HEAD",
|
||||||
|
)
|
||||||
|
yield* subscribe(vcsDir, ignore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Service.of({})
|
||||||
|
}).pipe(
|
||||||
|
Effect.catchCause((cause) => {
|
||||||
|
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
|
||||||
|
return Effect.succeed(Service.of({}))
|
||||||
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
|
||||||
|
|
||||||
export function init() {
|
|
||||||
return runPromise((svc) => svc.init())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ export namespace Flag {
|
|||||||
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
||||||
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
||||||
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
|
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
|
||||||
export const OPENCODE_DB = process.env["OPENCODE_DB"]
|
|
||||||
export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB")
|
export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB")
|
||||||
export const OPENCODE_SKIP_MIGRATIONS = truthy("OPENCODE_SKIP_MIGRATIONS")
|
export const OPENCODE_SKIP_MIGRATIONS = truthy("OPENCODE_SKIP_MIGRATIONS")
|
||||||
export const OPENCODE_STRICT_CONFIG_DEPS = truthy("OPENCODE_STRICT_CONFIG_DEPS")
|
export const OPENCODE_STRICT_CONFIG_DEPS = truthy("OPENCODE_STRICT_CONFIG_DEPS")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Effect, Layer, ServiceMap } from "effect"
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { mergeDeep } from "remeda"
|
import { mergeDeep } from "remeda"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
@@ -27,7 +27,6 @@ export namespace Format {
|
|||||||
export type Status = z.infer<typeof Status>
|
export type Status = z.infer<typeof Status>
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly init: () => Effect.Effect<void>
|
|
||||||
readonly status: () => Effect.Effect<Status[]>
|
readonly status: () => Effect.Effect<Status[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,124 +35,106 @@ export namespace Format {
|
|||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const state = yield* InstanceState.make(
|
const instance = yield* InstanceContext
|
||||||
Effect.fn("Format.state")(function* (_ctx) {
|
|
||||||
const enabled: Record<string, boolean> = {}
|
|
||||||
const formatters: Record<string, Formatter.Info> = {}
|
|
||||||
|
|
||||||
const cfg = yield* Effect.promise(() => Config.get())
|
const enabled: Record<string, boolean> = {}
|
||||||
|
const formatters: Record<string, Formatter.Info> = {}
|
||||||
|
|
||||||
if (cfg.formatter !== false) {
|
const cfg = yield* Effect.promise(() => Config.get())
|
||||||
for (const item of Object.values(Formatter)) {
|
|
||||||
formatters[item.name] = item
|
|
||||||
}
|
|
||||||
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
|
|
||||||
if (item.disabled) {
|
|
||||||
delete formatters[name]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const info = mergeDeep(formatters[name] ?? {}, {
|
|
||||||
command: [],
|
|
||||||
extensions: [],
|
|
||||||
...item,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (info.command.length === 0) continue
|
if (cfg.formatter !== false) {
|
||||||
|
for (const item of Object.values(Formatter)) {
|
||||||
formatters[name] = {
|
formatters[item.name] = item
|
||||||
...info,
|
}
|
||||||
name,
|
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
|
||||||
enabled: async () => true,
|
if (item.disabled) {
|
||||||
}
|
delete formatters[name]
|
||||||
}
|
continue
|
||||||
} else {
|
|
||||||
log.info("all formatters are disabled")
|
|
||||||
}
|
}
|
||||||
|
const info = mergeDeep(formatters[name] ?? {}, {
|
||||||
|
command: [],
|
||||||
|
extensions: [],
|
||||||
|
...item,
|
||||||
|
})
|
||||||
|
|
||||||
async function isEnabled(item: Formatter.Info) {
|
if (info.command.length === 0) continue
|
||||||
let status = enabled[item.name]
|
|
||||||
if (status === undefined) {
|
formatters[name] = {
|
||||||
status = await item.enabled()
|
...info,
|
||||||
enabled[item.name] = status
|
name,
|
||||||
}
|
enabled: async () => true,
|
||||||
return status
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.info("all formatters are disabled")
|
||||||
|
}
|
||||||
|
|
||||||
async function getFormatter(ext: string) {
|
async function isEnabled(item: Formatter.Info) {
|
||||||
const matching = Object.values(formatters).filter((item) => item.extensions.includes(ext))
|
let status = enabled[item.name]
|
||||||
const checks = await Promise.all(
|
if (status === undefined) {
|
||||||
matching.map(async (item) => {
|
status = await item.enabled()
|
||||||
log.info("checking", { name: item.name, ext })
|
enabled[item.name] = status
|
||||||
const on = await isEnabled(item)
|
}
|
||||||
if (on) {
|
return status
|
||||||
log.info("enabled", { name: item.name, ext })
|
}
|
||||||
}
|
|
||||||
return {
|
|
||||||
item,
|
|
||||||
enabled: on,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return checks.filter((x) => x.enabled).map((x) => x.item)
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* Effect.acquireRelease(
|
async function getFormatter(ext: string) {
|
||||||
Effect.sync(() =>
|
const result = []
|
||||||
Bus.subscribe(
|
for (const item of Object.values(formatters)) {
|
||||||
File.Event.Edited,
|
log.info("checking", { name: item.name, ext })
|
||||||
Instance.bind(async (payload) => {
|
if (!item.extensions.includes(ext)) continue
|
||||||
const file = payload.properties.file
|
if (!(await isEnabled(item))) continue
|
||||||
log.info("formatting", { file })
|
log.info("enabled", { name: item.name, ext })
|
||||||
const ext = path.extname(file)
|
result.push(item)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
for (const item of await getFormatter(ext)) {
|
yield* Effect.acquireRelease(
|
||||||
log.info("running", { command: item.command })
|
Effect.sync(() =>
|
||||||
try {
|
Bus.subscribe(
|
||||||
const proc = Process.spawn(
|
File.Event.Edited,
|
||||||
item.command.map((x) => x.replace("$FILE", file)),
|
Instance.bind(async (payload) => {
|
||||||
{
|
const file = payload.properties.file
|
||||||
cwd: Instance.directory,
|
log.info("formatting", { file })
|
||||||
env: { ...process.env, ...item.environment },
|
const ext = path.extname(file)
|
||||||
stdout: "ignore",
|
|
||||||
stderr: "ignore",
|
for (const item of await getFormatter(ext)) {
|
||||||
},
|
log.info("running", { command: item.command })
|
||||||
)
|
try {
|
||||||
const exit = await proc.exited
|
const proc = Process.spawn(
|
||||||
if (exit !== 0) {
|
item.command.map((x) => x.replace("$FILE", file)),
|
||||||
log.error("failed", {
|
{
|
||||||
command: item.command,
|
cwd: instance.directory,
|
||||||
...item.environment,
|
env: { ...process.env, ...item.environment },
|
||||||
})
|
stdout: "ignore",
|
||||||
}
|
stderr: "ignore",
|
||||||
} catch (error) {
|
},
|
||||||
log.error("failed to format file", {
|
)
|
||||||
error,
|
const exit = await proc.exited
|
||||||
command: item.command,
|
if (exit !== 0) {
|
||||||
...item.environment,
|
log.error("failed", {
|
||||||
file,
|
command: item.command,
|
||||||
})
|
...item.environment,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}),
|
} catch (error) {
|
||||||
),
|
log.error("failed to format file", {
|
||||||
),
|
error,
|
||||||
(unsubscribe) => Effect.sync(unsubscribe),
|
command: item.command,
|
||||||
)
|
...item.environment,
|
||||||
log.info("init")
|
file,
|
||||||
|
})
|
||||||
return {
|
}
|
||||||
formatters,
|
}
|
||||||
isEnabled,
|
}),
|
||||||
}
|
),
|
||||||
}),
|
),
|
||||||
|
(unsubscribe) => Effect.sync(unsubscribe),
|
||||||
)
|
)
|
||||||
|
log.info("init")
|
||||||
const init = Effect.fn("Format.init")(function* () {
|
|
||||||
yield* InstanceState.get(state)
|
|
||||||
})
|
|
||||||
|
|
||||||
const status = Effect.fn("Format.status")(function* () {
|
const status = Effect.fn("Format.status")(function* () {
|
||||||
const { formatters, isEnabled } = yield* InstanceState.get(state)
|
|
||||||
const result: Status[] = []
|
const result: 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))
|
||||||
@@ -166,17 +147,11 @@ export namespace Format {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ init, status })
|
return Service.of({ status })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
|
||||||
|
|
||||||
export async function init() {
|
|
||||||
return runPromise((s) => s.init())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function status() {
|
export async function status() {
|
||||||
return runPromise((s) => s.status())
|
return runPromiseInstance(Service.use((s) => s.status()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
|
|
||||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
|
||||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
import { Flag } from "../flag/flag"
|
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
import { iife } from "@/util/iife"
|
||||||
|
import { Flag } from "../flag/flag"
|
||||||
|
import { Process } from "@/util/process"
|
||||||
|
import { buffer } from "node:stream/consumers"
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
const OPENCODE_VERSION: string
|
const OPENCODE_VERSION: string
|
||||||
@@ -18,7 +16,39 @@ declare global {
|
|||||||
export namespace Installation {
|
export namespace Installation {
|
||||||
const log = Log.create({ service: "installation" })
|
const log = Log.create({ service: "installation" })
|
||||||
|
|
||||||
export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown"
|
async function text(cmd: string[], opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) {
|
||||||
|
return Process.text(cmd, {
|
||||||
|
cwd: opts.cwd,
|
||||||
|
env: opts.env,
|
||||||
|
nothrow: true,
|
||||||
|
}).then((x) => x.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upgradeCurl(target: string) {
|
||||||
|
const body = await fetch("https://opencode.ai/install").then((res) => {
|
||||||
|
if (!res.ok) throw new Error(res.statusText)
|
||||||
|
return res.text()
|
||||||
|
})
|
||||||
|
const proc = Process.spawn(["bash"], {
|
||||||
|
stdin: "pipe",
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
VERSION: target,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!proc.stdin || !proc.stdout || !proc.stderr) throw new Error("Process output not available")
|
||||||
|
proc.stdin.end(body)
|
||||||
|
const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Method = Awaited<ReturnType<typeof method>>
|
||||||
|
|
||||||
export const Event = {
|
export const Event = {
|
||||||
Updated: BusEvent.define(
|
Updated: BusEvent.define(
|
||||||
@@ -45,9 +75,12 @@ export namespace Installation {
|
|||||||
})
|
})
|
||||||
export type Info = z.infer<typeof Info>
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
|
export async function info() {
|
||||||
export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
|
return {
|
||||||
export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}`
|
version: VERSION,
|
||||||
|
latest: await latest(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function isPreview() {
|
export function isPreview() {
|
||||||
return CHANNEL !== "latest"
|
return CHANNEL !== "latest"
|
||||||
@@ -57,294 +90,214 @@ export namespace Installation {
|
|||||||
return CHANNEL === "local"
|
return CHANNEL === "local"
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedError>()("UpgradeFailedError", {
|
export async function method() {
|
||||||
stderr: Schema.String,
|
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl"
|
||||||
}) {}
|
if (process.execPath.includes(path.join(".local", "bin"))) return "curl"
|
||||||
|
const exec = process.execPath.toLowerCase()
|
||||||
|
|
||||||
// Response schemas for external version APIs
|
const checks = [
|
||||||
const GitHubRelease = Schema.Struct({ tag_name: Schema.String })
|
{
|
||||||
const NpmPackage = Schema.Struct({ version: Schema.String })
|
name: "npm" as const,
|
||||||
const BrewFormula = Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })
|
command: () => text(["npm", "list", "-g", "--depth=0"]),
|
||||||
const BrewInfoV2 = Schema.Struct({
|
},
|
||||||
formulae: Schema.Array(Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })),
|
{
|
||||||
})
|
name: "yarn" as const,
|
||||||
const ChocoPackage = Schema.Struct({
|
command: () => text(["yarn", "global", "list"]),
|
||||||
d: Schema.Struct({ results: Schema.Array(Schema.Struct({ Version: Schema.String })) }),
|
},
|
||||||
})
|
{
|
||||||
const ScoopManifest = NpmPackage
|
name: "pnpm" as const,
|
||||||
|
command: () => text(["pnpm", "list", "-g", "--depth=0"]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bun" as const,
|
||||||
|
command: () => text(["bun", "pm", "ls", "-g"]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "brew" as const,
|
||||||
|
command: () => text(["brew", "list", "--formula", "opencode"]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "scoop" as const,
|
||||||
|
command: () => text(["scoop", "list", "opencode"]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "choco" as const,
|
||||||
|
command: () => text(["choco", "list", "--limit-output", "opencode"]),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
export interface Interface {
|
checks.sort((a, b) => {
|
||||||
readonly info: () => Effect.Effect<Info>
|
const aMatches = exec.includes(a.name)
|
||||||
readonly method: () => Effect.Effect<Method>
|
const bMatches = exec.includes(b.name)
|
||||||
readonly latest: (method?: Method) => Effect.Effect<string>
|
if (aMatches && !bMatches) return -1
|
||||||
readonly upgrade: (method: Method, target: string) => Effect.Effect<void, UpgradeFailedError>
|
if (!aMatches && bMatches) return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const check of checks) {
|
||||||
|
const output = await check.command()
|
||||||
|
const installedName =
|
||||||
|
check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
|
||||||
|
if (output.includes(installedName)) {
|
||||||
|
return check.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Installation") {}
|
export const UpgradeFailedError = NamedError.create(
|
||||||
|
"UpgradeFailedError",
|
||||||
export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildProcessSpawner.ChildProcessSpawner> =
|
z.object({
|
||||||
Layer.effect(
|
stderr: z.string(),
|
||||||
Service,
|
}),
|
||||||
Effect.gen(function* () {
|
|
||||||
const http = yield* HttpClient.HttpClient
|
|
||||||
const httpOk = HttpClient.filterStatusOk(withTransientReadRetry(http))
|
|
||||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
||||||
|
|
||||||
const text = Effect.fnUntraced(
|
|
||||||
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
|
|
||||||
const proc = ChildProcess.make(cmd[0], cmd.slice(1), {
|
|
||||||
cwd: opts?.cwd,
|
|
||||||
env: opts?.env,
|
|
||||||
extendEnv: true,
|
|
||||||
})
|
|
||||||
const handle = yield* spawner.spawn(proc)
|
|
||||||
const out = yield* Stream.mkString(Stream.decodeText(handle.stdout))
|
|
||||||
yield* handle.exitCode
|
|
||||||
return out
|
|
||||||
},
|
|
||||||
Effect.scoped,
|
|
||||||
Effect.catch(() => Effect.succeed("")),
|
|
||||||
)
|
|
||||||
|
|
||||||
const run = Effect.fnUntraced(
|
|
||||||
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
|
|
||||||
const proc = ChildProcess.make(cmd[0], cmd.slice(1), {
|
|
||||||
cwd: opts?.cwd,
|
|
||||||
env: opts?.env,
|
|
||||||
extendEnv: true,
|
|
||||||
})
|
|
||||||
const handle = yield* spawner.spawn(proc)
|
|
||||||
const [stdout, 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, stdout, stderr }
|
|
||||||
},
|
|
||||||
Effect.scoped,
|
|
||||||
Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })),
|
|
||||||
)
|
|
||||||
|
|
||||||
const getBrewFormula = Effect.fnUntraced(function* () {
|
|
||||||
const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
|
|
||||||
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
|
|
||||||
const coreFormula = yield* text(["brew", "list", "--formula", "opencode"])
|
|
||||||
if (coreFormula.includes("opencode")) return "opencode"
|
|
||||||
return "opencode"
|
|
||||||
})
|
|
||||||
|
|
||||||
const upgradeCurl = Effect.fnUntraced(
|
|
||||||
function* (target: string) {
|
|
||||||
const response = yield* httpOk.execute(HttpClientRequest.get("https://opencode.ai/install"))
|
|
||||||
const body = yield* response.text
|
|
||||||
const bodyBytes = new TextEncoder().encode(body)
|
|
||||||
const proc = ChildProcess.make("bash", [], {
|
|
||||||
stdin: Stream.make(bodyBytes),
|
|
||||||
env: { VERSION: target },
|
|
||||||
extendEnv: true,
|
|
||||||
})
|
|
||||||
const handle = yield* spawner.spawn(proc)
|
|
||||||
const [stdout, 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, stdout, stderr }
|
|
||||||
},
|
|
||||||
Effect.scoped,
|
|
||||||
Effect.orDie,
|
|
||||||
)
|
|
||||||
|
|
||||||
const methodImpl = Effect.fn("Installation.method")(function* () {
|
|
||||||
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" as Method
|
|
||||||
if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method
|
|
||||||
const exec = process.execPath.toLowerCase()
|
|
||||||
|
|
||||||
const checks: Array<{ name: Method; command: () => Effect.Effect<string> }> = [
|
|
||||||
{ name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) },
|
|
||||||
{ name: "yarn", command: () => text(["yarn", "global", "list"]) },
|
|
||||||
{ name: "pnpm", command: () => text(["pnpm", "list", "-g", "--depth=0"]) },
|
|
||||||
{ name: "bun", command: () => text(["bun", "pm", "ls", "-g"]) },
|
|
||||||
{ name: "brew", command: () => text(["brew", "list", "--formula", "opencode"]) },
|
|
||||||
{ name: "scoop", command: () => text(["scoop", "list", "opencode"]) },
|
|
||||||
{ name: "choco", command: () => text(["choco", "list", "--limit-output", "opencode"]) },
|
|
||||||
]
|
|
||||||
|
|
||||||
checks.sort((a, b) => {
|
|
||||||
const aMatches = exec.includes(a.name)
|
|
||||||
const bMatches = exec.includes(b.name)
|
|
||||||
if (aMatches && !bMatches) return -1
|
|
||||||
if (!aMatches && bMatches) return 1
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const check of checks) {
|
|
||||||
const output = yield* check.command()
|
|
||||||
const installedName =
|
|
||||||
check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
|
|
||||||
if (output.includes(installedName)) {
|
|
||||||
return check.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "unknown" as Method
|
|
||||||
})
|
|
||||||
|
|
||||||
const latestImpl = Effect.fn("Installation.latest")(function* (installMethod?: Method) {
|
|
||||||
const detectedMethod = installMethod || (yield* methodImpl())
|
|
||||||
|
|
||||||
if (detectedMethod === "brew") {
|
|
||||||
const formula = yield* getBrewFormula()
|
|
||||||
if (formula.includes("/")) {
|
|
||||||
const infoJson = yield* text(["brew", "info", "--json=v2", formula])
|
|
||||||
const info = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(BrewInfoV2))(infoJson)
|
|
||||||
return info.formulae[0].versions.stable
|
|
||||||
}
|
|
||||||
const response = yield* httpOk.execute(
|
|
||||||
HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response)
|
|
||||||
return data.versions.stable
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
|
|
||||||
const r = (yield* text(["npm", "config", "get", "registry"])).trim()
|
|
||||||
const reg = r || "https://registry.npmjs.org"
|
|
||||||
const registry = reg.endsWith("/") ? reg.slice(0, -1) : reg
|
|
||||||
const channel = CHANNEL
|
|
||||||
const response = yield* httpOk.execute(
|
|
||||||
HttpClientRequest.get(`${registry}/opencode-ai/${channel}`).pipe(HttpClientRequest.acceptJson),
|
|
||||||
)
|
|
||||||
const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response)
|
|
||||||
return data.version
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detectedMethod === "choco") {
|
|
||||||
const response = yield* httpOk.execute(
|
|
||||||
HttpClientRequest.get(
|
|
||||||
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
|
|
||||||
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json;odata=verbose" })),
|
|
||||||
)
|
|
||||||
const data = yield* HttpClientResponse.schemaBodyJson(ChocoPackage)(response)
|
|
||||||
return data.d.results[0].Version
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detectedMethod === "scoop") {
|
|
||||||
const response = yield* httpOk.execute(
|
|
||||||
HttpClientRequest.get(
|
|
||||||
"https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json",
|
|
||||||
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json" })),
|
|
||||||
)
|
|
||||||
const data = yield* HttpClientResponse.schemaBodyJson(ScoopManifest)(response)
|
|
||||||
return data.version
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = yield* httpOk.execute(
|
|
||||||
HttpClientRequest.get("https://api.github.com/repos/anomalyco/opencode/releases/latest").pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response)
|
|
||||||
return data.tag_name.replace(/^v/, "")
|
|
||||||
}, Effect.orDie)
|
|
||||||
|
|
||||||
const upgradeImpl = Effect.fn("Installation.upgrade")(function* (m: Method, target: string) {
|
|
||||||
let result: { code: ChildProcessSpawner.ExitCode; stdout: string; stderr: string } | undefined
|
|
||||||
switch (m) {
|
|
||||||
case "curl":
|
|
||||||
result = yield* upgradeCurl(target)
|
|
||||||
break
|
|
||||||
case "npm":
|
|
||||||
result = yield* run(["npm", "install", "-g", `opencode-ai@${target}`])
|
|
||||||
break
|
|
||||||
case "pnpm":
|
|
||||||
result = yield* run(["pnpm", "install", "-g", `opencode-ai@${target}`])
|
|
||||||
break
|
|
||||||
case "bun":
|
|
||||||
result = yield* run(["bun", "install", "-g", `opencode-ai@${target}`])
|
|
||||||
break
|
|
||||||
case "brew": {
|
|
||||||
const formula = yield* getBrewFormula()
|
|
||||||
const env = { HOMEBREW_NO_AUTO_UPDATE: "1" }
|
|
||||||
if (formula.includes("/")) {
|
|
||||||
const tap = yield* run(["brew", "tap", "anomalyco/tap"], { env })
|
|
||||||
if (tap.code !== 0) {
|
|
||||||
result = tap
|
|
||||||
break
|
|
||||||
}
|
|
||||||
const repo = yield* text(["brew", "--repo", "anomalyco/tap"])
|
|
||||||
const dir = repo.trim()
|
|
||||||
if (dir) {
|
|
||||||
const pull = yield* run(["git", "pull", "--ff-only"], { cwd: dir, env })
|
|
||||||
if (pull.code !== 0) {
|
|
||||||
result = pull
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result = yield* run(["brew", "upgrade", formula], { env })
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "choco":
|
|
||||||
result = yield* run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"])
|
|
||||||
break
|
|
||||||
case "scoop":
|
|
||||||
result = yield* run(["scoop", "install", `opencode@${target}`])
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` })
|
|
||||||
}
|
|
||||||
if (!result || result.code !== 0) {
|
|
||||||
const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || ""
|
|
||||||
return yield* new UpgradeFailedError({ stderr })
|
|
||||||
}
|
|
||||||
log.info("upgraded", {
|
|
||||||
method: m,
|
|
||||||
target,
|
|
||||||
stdout: result.stdout,
|
|
||||||
stderr: result.stderr,
|
|
||||||
})
|
|
||||||
yield* text([process.execPath, "--version"])
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
info: Effect.fn("Installation.info")(function* () {
|
|
||||||
return {
|
|
||||||
version: VERSION,
|
|
||||||
latest: yield* latestImpl(),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
method: methodImpl,
|
|
||||||
latest: latestImpl,
|
|
||||||
upgrade: upgradeImpl,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
|
||||||
Layer.provide(FetchHttpClient.layer),
|
|
||||||
Layer.provide(NodeChildProcessSpawner.layer),
|
|
||||||
Layer.provide(NodeFileSystem.layer),
|
|
||||||
Layer.provide(NodePath.layer),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
async function getBrewFormula() {
|
||||||
|
const tapFormula = await text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
|
||||||
export async function info(): Promise<Info> {
|
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
|
||||||
return runPromise((svc) => svc.info())
|
const coreFormula = await text(["brew", "list", "--formula", "opencode"])
|
||||||
|
if (coreFormula.includes("opencode")) return "opencode"
|
||||||
|
return "opencode"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function method(): Promise<Method> {
|
export async function upgrade(method: Method, target: string) {
|
||||||
return runPromise((svc) => svc.method())
|
let result: Awaited<ReturnType<typeof upgradeCurl>> | undefined
|
||||||
|
switch (method) {
|
||||||
|
case "curl":
|
||||||
|
result = await upgradeCurl(target)
|
||||||
|
break
|
||||||
|
case "npm":
|
||||||
|
result = await Process.run(["npm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
|
||||||
|
break
|
||||||
|
case "pnpm":
|
||||||
|
result = await Process.run(["pnpm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
|
||||||
|
break
|
||||||
|
case "bun":
|
||||||
|
result = await Process.run(["bun", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
|
||||||
|
break
|
||||||
|
case "brew": {
|
||||||
|
const formula = await getBrewFormula()
|
||||||
|
const env = {
|
||||||
|
HOMEBREW_NO_AUTO_UPDATE: "1",
|
||||||
|
...process.env,
|
||||||
|
}
|
||||||
|
if (formula.includes("/")) {
|
||||||
|
const tap = await Process.run(["brew", "tap", "anomalyco/tap"], { env, nothrow: true })
|
||||||
|
if (tap.code !== 0) {
|
||||||
|
result = tap
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const repo = await Process.text(["brew", "--repo", "anomalyco/tap"], { env, nothrow: true })
|
||||||
|
if (repo.code !== 0) {
|
||||||
|
result = repo
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const dir = repo.text.trim()
|
||||||
|
if (dir) {
|
||||||
|
const pull = await Process.run(["git", "pull", "--ff-only"], { cwd: dir, env, nothrow: true })
|
||||||
|
if (pull.code !== 0) {
|
||||||
|
result = pull
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = await Process.run(["brew", "upgrade", formula], { env, nothrow: true })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "choco":
|
||||||
|
result = await Process.run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"], { nothrow: true })
|
||||||
|
break
|
||||||
|
case "scoop":
|
||||||
|
result = await Process.run(["scoop", "install", `opencode@${target}`], { nothrow: true })
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown method: ${method}`)
|
||||||
|
}
|
||||||
|
if (!result || result.code !== 0) {
|
||||||
|
const stderr =
|
||||||
|
method === "choco" ? "not running from an elevated command shell" : result?.stderr.toString("utf8") || ""
|
||||||
|
throw new UpgradeFailedError({
|
||||||
|
stderr: stderr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
log.info("upgraded", {
|
||||||
|
method,
|
||||||
|
target,
|
||||||
|
stdout: result.stdout.toString(),
|
||||||
|
stderr: result.stderr.toString(),
|
||||||
|
})
|
||||||
|
await Process.text([process.execPath, "--version"], { nothrow: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function latest(installMethod?: Method): Promise<string> {
|
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
|
||||||
return runPromise((svc) => svc.latest(installMethod))
|
export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
|
||||||
}
|
export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}`
|
||||||
|
|
||||||
export async function upgrade(m: Method, target: string): Promise<void> {
|
export async function latest(installMethod?: Method) {
|
||||||
return runPromise((svc) => svc.upgrade(m, target))
|
const detectedMethod = installMethod || (await method())
|
||||||
|
|
||||||
|
if (detectedMethod === "brew") {
|
||||||
|
const formula = await getBrewFormula()
|
||||||
|
if (formula.includes("/")) {
|
||||||
|
const infoJson = await text(["brew", "info", "--json=v2", formula])
|
||||||
|
const info = JSON.parse(infoJson)
|
||||||
|
const version = info.formulae?.[0]?.versions?.stable
|
||||||
|
if (!version) throw new Error(`Could not detect version for tap formula: ${formula}`)
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
return fetch("https://formulae.brew.sh/api/formula/opencode.json")
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(res.statusText)
|
||||||
|
return res.json()
|
||||||
|
})
|
||||||
|
.then((data: any) => data.versions.stable)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
|
||||||
|
const registry = await iife(async () => {
|
||||||
|
const r = (await text(["npm", "config", "get", "registry"])).trim()
|
||||||
|
const reg = r || "https://registry.npmjs.org"
|
||||||
|
return reg.endsWith("/") ? reg.slice(0, -1) : reg
|
||||||
|
})
|
||||||
|
const channel = CHANNEL
|
||||||
|
return fetch(`${registry}/opencode-ai/${channel}`)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(res.statusText)
|
||||||
|
return res.json()
|
||||||
|
})
|
||||||
|
.then((data: any) => data.version)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detectedMethod === "choco") {
|
||||||
|
return fetch(
|
||||||
|
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
|
||||||
|
{ headers: { Accept: "application/json;odata=verbose" } },
|
||||||
|
)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(res.statusText)
|
||||||
|
return res.json()
|
||||||
|
})
|
||||||
|
.then((data: any) => data.d.results[0].Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detectedMethod === "scoop") {
|
||||||
|
return fetch("https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(res.statusText)
|
||||||
|
return res.json()
|
||||||
|
})
|
||||||
|
.then((data: any) => data.version)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch("https://api.github.com/repos/anomalyco/opencode/releases/latest")
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(res.statusText)
|
||||||
|
return res.json()
|
||||||
|
})
|
||||||
|
.then((data: any) => data.tag_name.replace(/^v/, ""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { Server } from "./server/server"
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { Wildcard } from "@/util/wildcard"
|
|
||||||
|
|
||||||
type Rule = {
|
|
||||||
permission: string
|
|
||||||
pattern: string
|
|
||||||
action: "allow" | "deny" | "ask"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function evaluate(permission: string, pattern: string, ...rulesets: Rule[][]): Rule {
|
|
||||||
const rules = rulesets.flat()
|
|
||||||
const match = rules.findLast(
|
|
||||||
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
|
|
||||||
)
|
|
||||||
return match ?? { action: "ask", permission, pattern: "*" }
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,21 @@
|
|||||||
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
|
||||||
import { ProjectID } from "@/project/schema"
|
import { ProjectID } from "@/project/schema"
|
||||||
import { Instance } from "@/project/instance"
|
|
||||||
import { MessageID, SessionID } from "@/session/schema"
|
import { MessageID, SessionID } from "@/session/schema"
|
||||||
import { PermissionTable } from "@/session/session.sql"
|
import { PermissionTable } from "@/session/session.sql"
|
||||||
import { Database, eq } from "@/storage/db"
|
import { Database, eq } from "@/storage/db"
|
||||||
|
import { fn } from "@/util/fn"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import { Wildcard } from "@/util/wildcard"
|
import { Wildcard } from "@/util/wildcard"
|
||||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { evaluate as evalRule } from "./evaluate"
|
|
||||||
import { PermissionID } from "./schema"
|
import { PermissionID } from "./schema"
|
||||||
|
|
||||||
export namespace Permission {
|
export namespace PermissionNext {
|
||||||
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({
|
||||||
@@ -125,46 +124,28 @@ export namespace Permission {
|
|||||||
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
|
||||||
pending: Map<PermissionID, PendingEntry>
|
|
||||||
approved: Ruleset
|
|
||||||
}
|
|
||||||
|
|
||||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||||
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
|
const rules = rulesets.flat()
|
||||||
return evalRule(permission, pattern, ...rulesets)
|
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/Permission") {}
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const state = yield* InstanceState.make<State>(
|
const { project } = yield* InstanceContext
|
||||||
Effect.fn("Permission.state")(function* (ctx) {
|
const row = Database.use((db) =>
|
||||||
const row = Database.use((db) =>
|
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(),
|
||||||
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(),
|
|
||||||
)
|
|
||||||
const state = {
|
|
||||||
pending: new Map<PermissionID, PendingEntry>(),
|
|
||||||
approved: row?.data ?? [],
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
for (const item of state.pending.values()) {
|
|
||||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
|
||||||
}
|
|
||||||
state.pending.clear()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return state
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
const pending = new Map<PermissionID, PendingEntry>()
|
||||||
|
const approved: Ruleset = row?.data ?? []
|
||||||
|
|
||||||
const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
|
const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
|
||||||
const { approved, pending } = yield* InstanceState.get(state)
|
|
||||||
const { ruleset, ...request } = input
|
const { ruleset, ...request } = input
|
||||||
let needsAsk = false
|
let needsAsk = false
|
||||||
|
|
||||||
@@ -201,7 +182,6 @@ export namespace Permission {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
|
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
|
||||||
const { approved, pending } = yield* InstanceState.get(state)
|
|
||||||
const existing = pending.get(input.requestID)
|
const existing = pending.get(input.requestID)
|
||||||
if (!existing) return
|
if (!existing) return
|
||||||
|
|
||||||
@@ -259,7 +239,6 @@ export namespace Permission {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const list = Effect.fn("Permission.list")(function* () {
|
const list = Effect.fn("Permission.list")(function* () {
|
||||||
const pending = (yield* InstanceState.get(state)).pending
|
|
||||||
return Array.from(pending.values(), (item) => item.info)
|
return Array.from(pending.values(), (item) => item.info)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -293,6 +272,14 @@ export namespace Permission {
|
|||||||
return rulesets.flat()
|
return rulesets.flat()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ask = fn(AskInput, async (input) => runPromiseInstance(Service.use((svc) => svc.ask(input))))
|
||||||
|
|
||||||
|
export const reply = fn(ReplyInput, async (input) => runPromiseInstance(Service.use((svc) => svc.reply(input))))
|
||||||
|
|
||||||
|
export async function list() {
|
||||||
|
return runPromiseInstance(Service.use((svc) => svc.list()))
|
||||||
|
}
|
||||||
|
|
||||||
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
|
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
|
||||||
|
|
||||||
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
|
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
|
||||||
@@ -305,18 +292,4 @@ export namespace Permission {
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export const runPromise = makeRunPromise(Service, layer)
|
|
||||||
|
|
||||||
export async function ask(input: z.infer<typeof AskInput>) {
|
|
||||||
return runPromise((s) => s.ask(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reply(input: z.infer<typeof ReplyInput>) {
|
|
||||||
return runPromise((s) => s.reply(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function list() {
|
|
||||||
return runPromise((s) => s.list())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { CodexAuthPlugin } from "./codex"
|
|||||||
import { Session } from "../session"
|
import { Session } from "../session"
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
import { CopilotAuthPlugin } from "./copilot"
|
import { CopilotAuthPlugin } from "./copilot"
|
||||||
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth"
|
||||||
|
|
||||||
export namespace Plugin {
|
export namespace Plugin {
|
||||||
const log = Log.create({ service: "plugin" })
|
const log = Log.create({ service: "plugin" })
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { Plugin } from "../plugin"
|
import { Plugin } from "../plugin"
|
||||||
import { Format } from "../format"
|
|
||||||
import { LSP } from "../lsp"
|
import { LSP } from "../lsp"
|
||||||
import { File } from "../file"
|
import { File } from "../file"
|
||||||
import { FileWatcher } from "../file/watcher"
|
|
||||||
import { Snapshot } from "../snapshot"
|
|
||||||
import { Project } from "./project"
|
import { Project } from "./project"
|
||||||
import { Vcs } from "./vcs"
|
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { Command } from "../command"
|
import { Command } from "../command"
|
||||||
import { Instance } from "./instance"
|
import { Instance } from "./instance"
|
||||||
@@ -16,12 +12,8 @@ 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()
|
||||||
Format.init()
|
|
||||||
await LSP.init()
|
await LSP.init()
|
||||||
File.init()
|
File.init()
|
||||||
FileWatcher.init()
|
|
||||||
Vcs.init()
|
|
||||||
Snapshot.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) {
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import { Context } from "../util/context"
|
|||||||
import { Project } from "./project"
|
import { Project } from "./project"
|
||||||
import { State } from "./state"
|
import { State } from "./state"
|
||||||
|
|
||||||
export interface Shape {
|
interface Context {
|
||||||
directory: string
|
directory: string
|
||||||
worktree: string
|
worktree: string
|
||||||
project: Project.Info
|
project: Project.Info
|
||||||
}
|
}
|
||||||
const context = Context.create<Shape>("instance")
|
const context = Context.create<Context>("instance")
|
||||||
const cache = new Map<string, Promise<Shape>>()
|
const cache = new Map<string, Promise<Context>>()
|
||||||
|
|
||||||
const disposal = {
|
const disposal = {
|
||||||
all: undefined as Promise<void> | undefined,
|
all: undefined as Promise<void> | undefined,
|
||||||
@@ -52,7 +52,7 @@ function boot(input: { directory: string; init?: () => Promise<any>; project?: P
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function track(directory: string, next: Promise<Shape>) {
|
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
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Effect, Layer, ServiceMap } from "effect"
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
|
||||||
import { FileWatcher } from "@/file/watcher"
|
import { FileWatcher } from "@/file/watcher"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import { git } from "@/util/git"
|
import { git } from "@/util/git"
|
||||||
@@ -31,81 +30,54 @@ export namespace Vcs {
|
|||||||
export type Info = z.infer<typeof Info>
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly init: () => Effect.Effect<void>
|
|
||||||
readonly branch: () => Effect.Effect<string | undefined>
|
readonly branch: () => Effect.Effect<string | undefined>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
|
||||||
current: string | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const state = yield* InstanceState.make<State>(
|
const instance = yield* InstanceContext
|
||||||
Effect.fn("Vcs.state")((ctx) =>
|
let currentBranch: string | undefined
|
||||||
Effect.gen(function* () {
|
|
||||||
if (ctx.project.vcs !== "git") {
|
|
||||||
return { current: undefined }
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCurrentBranch = async () => {
|
if (instance.project.vcs === "git") {
|
||||||
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
|
const getCurrentBranch = async () => {
|
||||||
cwd: ctx.worktree,
|
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||||
})
|
cwd: instance.project.worktree,
|
||||||
if (result.exitCode !== 0) return undefined
|
})
|
||||||
const text = result.text().trim()
|
if (result.exitCode !== 0) return undefined
|
||||||
return text || undefined
|
const text = result.text().trim()
|
||||||
}
|
return text || undefined
|
||||||
|
}
|
||||||
|
|
||||||
const value = {
|
currentBranch = yield* Effect.promise(() => getCurrentBranch())
|
||||||
current: yield* Effect.promise(() => getCurrentBranch()),
|
log.info("initialized", { branch: currentBranch })
|
||||||
}
|
|
||||||
log.info("initialized", { branch: value.current })
|
|
||||||
|
|
||||||
yield* Effect.acquireRelease(
|
yield* Effect.acquireRelease(
|
||||||
Effect.sync(() =>
|
Effect.sync(() =>
|
||||||
Bus.subscribe(
|
Bus.subscribe(
|
||||||
FileWatcher.Event.Updated,
|
FileWatcher.Event.Updated,
|
||||||
Instance.bind(async (evt) => {
|
Instance.bind(async (evt) => {
|
||||||
if (!evt.properties.file.endsWith("HEAD")) return
|
if (!evt.properties.file.endsWith("HEAD")) return
|
||||||
const next = await getCurrentBranch()
|
const next = await getCurrentBranch()
|
||||||
if (next !== value.current) {
|
if (next !== currentBranch) {
|
||||||
log.info("branch changed", { from: value.current, to: next })
|
log.info("branch changed", { from: currentBranch, to: next })
|
||||||
value.current = next
|
currentBranch = next
|
||||||
Bus.publish(Event.BranchUpdated, { branch: next })
|
Bus.publish(Event.BranchUpdated, { branch: next })
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(unsubscribe) => Effect.sync(unsubscribe),
|
(unsubscribe) => Effect.sync(unsubscribe),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
return value
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
init: Effect.fn("Vcs.init")(function* () {
|
|
||||||
yield* InstanceState.get(state)
|
|
||||||
}),
|
|
||||||
branch: Effect.fn("Vcs.branch")(function* () {
|
branch: Effect.fn("Vcs.branch")(function* () {
|
||||||
return yield* InstanceState.use(state, (x) => x.current)
|
return currentBranch
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
|
||||||
|
|
||||||
export function init() {
|
|
||||||
return runPromise((svc) => svc.init())
|
|
||||||
}
|
|
||||||
|
|
||||||
export function branch() {
|
|
||||||
return runPromise((svc) => svc.branch())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import type { AuthOuathResult, Hooks } 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 { Auth } from "@/auth"
|
import * as Auth from "@/auth/effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
import { fn } from "@/util/fn"
|
||||||
import { Plugin } from "../plugin"
|
|
||||||
import { ProviderID } from "./schema"
|
import { ProviderID } from "./schema"
|
||||||
import { Array as Arr, Effect, Layer, Record, Result, ServiceMap } from "effect"
|
import { Array as Arr, Effect, Layer, Record, Result, ServiceMap, Struct } from "effect"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
||||||
export namespace ProviderAuth {
|
export namespace ProviderAuth {
|
||||||
@@ -92,8 +91,6 @@ export namespace ProviderAuth {
|
|||||||
| InstanceType<typeof OauthCallbackFailed>
|
| InstanceType<typeof OauthCallbackFailed>
|
||||||
| InstanceType<typeof ValidationFailed>
|
| InstanceType<typeof ValidationFailed>
|
||||||
|
|
||||||
type Hook = NonNullable<Hooks["auth"]>
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>>
|
readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>>
|
||||||
readonly authorize: (input: {
|
readonly authorize: (input: {
|
||||||
@@ -104,37 +101,26 @@ export namespace ProviderAuth {
|
|||||||
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, Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
|
||||||
hooks: Record<ProviderID, Hook>
|
|
||||||
pending: Map<ProviderID, AuthOuathResult>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const auth = yield* Auth.Service
|
const auth = yield* Auth.AuthEffect.Service
|
||||||
const state = yield* InstanceState.make<State>(
|
const hooks = yield* Effect.promise(async () => {
|
||||||
Effect.fn("ProviderAuth.state")(() =>
|
const mod = await import("../plugin")
|
||||||
Effect.promise(async () => {
|
const plugins = await mod.Plugin.list()
|
||||||
const plugins = await Plugin.list()
|
return Record.fromEntries(
|
||||||
return {
|
Arr.filterMap(plugins, (x) =>
|
||||||
hooks: Record.fromEntries(
|
x.auth?.provider !== undefined
|
||||||
Arr.filterMap(plugins, (x) =>
|
? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
|
||||||
x.auth?.provider !== undefined
|
: Result.failVoid,
|
||||||
? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
|
),
|
||||||
: Result.failVoid,
|
)
|
||||||
),
|
})
|
||||||
),
|
const pending = new Map<ProviderID, AuthOuathResult>()
|
||||||
pending: new Map<ProviderID, AuthOuathResult>(),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const methods = Effect.fn("ProviderAuth.methods")(function* () {
|
const methods = Effect.fn("ProviderAuth.methods")(function* () {
|
||||||
const hooks = (yield* InstanceState.get(state)).hooks
|
|
||||||
return Record.map(hooks, (item) =>
|
return Record.map(hooks, (item) =>
|
||||||
item.methods.map(
|
item.methods.map(
|
||||||
(method): Method => ({
|
(method): Method => ({
|
||||||
@@ -168,7 +154,6 @@ export namespace ProviderAuth {
|
|||||||
method: number
|
method: number
|
||||||
inputs?: Record<string, string>
|
inputs?: Record<string, string>
|
||||||
}) {
|
}) {
|
||||||
const { hooks, pending } = yield* InstanceState.get(state)
|
|
||||||
const method = hooks[input.providerID].methods[input.method]
|
const method = hooks[input.providerID].methods[input.method]
|
||||||
if (method.type !== "oauth") return
|
if (method.type !== "oauth") return
|
||||||
|
|
||||||
@@ -195,7 +180,6 @@ export namespace ProviderAuth {
|
|||||||
method: number
|
method: number
|
||||||
code?: string
|
code?: string
|
||||||
}) {
|
}) {
|
||||||
const pending = (yield* InstanceState.get(state)).pending
|
|
||||||
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) {
|
||||||
@@ -229,23 +213,27 @@ export namespace ProviderAuth {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(Auth.AuthEffect.layer))
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function methods() {
|
export async function methods() {
|
||||||
return runPromise((svc) => svc.methods())
|
return runPromiseInstance(Service.use((svc) => svc.methods()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function authorize(input: {
|
export const authorize = fn(
|
||||||
providerID: ProviderID
|
z.object({
|
||||||
method: number
|
providerID: ProviderID.zod,
|
||||||
inputs?: Record<string, string>
|
method: z.number(),
|
||||||
}): Promise<Authorization | undefined> {
|
inputs: z.record(z.string(), z.string()).optional(),
|
||||||
return runPromise((svc) => svc.authorize(input))
|
}),
|
||||||
}
|
async (input): Promise<Authorization | undefined> => runPromiseInstance(Service.use((svc) => svc.authorize(input))),
|
||||||
|
)
|
||||||
|
|
||||||
export async function callback(input: { providerID: ProviderID; method: number; code?: string }) {
|
export const callback = fn(
|
||||||
return runPromise((svc) => svc.callback(input))
|
z.object({
|
||||||
}
|
providerID: ProviderID.zod,
|
||||||
|
method: z.number(),
|
||||||
|
code: z.string().optional(),
|
||||||
|
}),
|
||||||
|
async (input) => runPromiseInstance(Service.use((svc) => svc.callback(input))),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,12 +40,7 @@ import { createGateway } from "@ai-sdk/gateway"
|
|||||||
import { createTogetherAI } from "@ai-sdk/togetherai"
|
import { createTogetherAI } from "@ai-sdk/togetherai"
|
||||||
import { createPerplexity } from "@ai-sdk/perplexity"
|
import { createPerplexity } from "@ai-sdk/perplexity"
|
||||||
import { createVercel } from "@ai-sdk/vercel"
|
import { createVercel } from "@ai-sdk/vercel"
|
||||||
import {
|
import { createGitLab, VERSION as GITLAB_PROVIDER_VERSION } from "@gitlab/gitlab-ai-provider"
|
||||||
createGitLab,
|
|
||||||
VERSION as GITLAB_PROVIDER_VERSION,
|
|
||||||
isWorkflowModel,
|
|
||||||
discoverWorkflowModels,
|
|
||||||
} from "gitlab-ai-provider"
|
|
||||||
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
||||||
import { GoogleAuth } from "google-auth-library"
|
import { GoogleAuth } from "google-auth-library"
|
||||||
import { ProviderTransform } from "./transform"
|
import { ProviderTransform } from "./transform"
|
||||||
@@ -129,20 +124,18 @@ export namespace Provider {
|
|||||||
"@ai-sdk/togetherai": createTogetherAI,
|
"@ai-sdk/togetherai": createTogetherAI,
|
||||||
"@ai-sdk/perplexity": createPerplexity,
|
"@ai-sdk/perplexity": createPerplexity,
|
||||||
"@ai-sdk/vercel": createVercel,
|
"@ai-sdk/vercel": createVercel,
|
||||||
"gitlab-ai-provider": createGitLab,
|
"@gitlab/gitlab-ai-provider": createGitLab,
|
||||||
// @ts-ignore (TODO: kill this code so we dont have to maintain it)
|
// @ts-ignore (TODO: kill this code so we dont have to maintain it)
|
||||||
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
|
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
|
||||||
}
|
}
|
||||||
|
|
||||||
type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
|
type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
|
||||||
type CustomVarsLoader = (options: Record<string, any>) => Record<string, string>
|
type CustomVarsLoader = (options: Record<string, any>) => Record<string, string>
|
||||||
type CustomDiscoverModels = () => Promise<Record<string, Model>>
|
|
||||||
type CustomLoader = (provider: Info) => Promise<{
|
type CustomLoader = (provider: Info) => Promise<{
|
||||||
autoload: boolean
|
autoload: boolean
|
||||||
getModel?: CustomModelLoader
|
getModel?: CustomModelLoader
|
||||||
vars?: CustomVarsLoader
|
vars?: CustomVarsLoader
|
||||||
options?: Record<string, any>
|
options?: Record<string, any>
|
||||||
discoverModels?: CustomDiscoverModels
|
|
||||||
}>
|
}>
|
||||||
|
|
||||||
function useLanguageModel(sdk: any) {
|
function useLanguageModel(sdk: any) {
|
||||||
@@ -191,15 +184,6 @@ export namespace Provider {
|
|||||||
options: {},
|
options: {},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
xai: async () => {
|
|
||||||
return {
|
|
||||||
autoload: false,
|
|
||||||
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
|
||||||
return sdk.responses(modelID)
|
|
||||||
},
|
|
||||||
options: {},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"github-copilot": async () => {
|
"github-copilot": async () => {
|
||||||
return {
|
return {
|
||||||
autoload: false,
|
autoload: false,
|
||||||
@@ -540,105 +524,28 @@ export namespace Provider {
|
|||||||
...(providerConfig?.options?.aiGatewayHeaders || {}),
|
...(providerConfig?.options?.aiGatewayHeaders || {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
const featureFlags = {
|
|
||||||
duo_agent_platform_agentic_chat: true,
|
|
||||||
duo_agent_platform: true,
|
|
||||||
...(providerConfig?.options?.featureFlags || {}),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
autoload: !!apiKey,
|
autoload: !!apiKey,
|
||||||
options: {
|
options: {
|
||||||
instanceUrl,
|
instanceUrl,
|
||||||
apiKey,
|
apiKey,
|
||||||
aiGatewayHeaders,
|
aiGatewayHeaders,
|
||||||
featureFlags,
|
featureFlags: {
|
||||||
|
duo_agent_platform_agentic_chat: true,
|
||||||
|
duo_agent_platform: true,
|
||||||
|
...(providerConfig?.options?.featureFlags || {}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string, options?: Record<string, any>) {
|
async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string) {
|
||||||
if (modelID.startsWith("duo-workflow-")) {
|
|
||||||
const workflowRef = options?.workflowRef as string | undefined
|
|
||||||
// Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef
|
|
||||||
const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow"
|
|
||||||
const model = sdk.workflowChat(sdkModelID, {
|
|
||||||
featureFlags,
|
|
||||||
})
|
|
||||||
if (workflowRef) {
|
|
||||||
model.selectedModelRef = workflowRef
|
|
||||||
}
|
|
||||||
return model
|
|
||||||
}
|
|
||||||
return sdk.agenticChat(modelID, {
|
return sdk.agenticChat(modelID, {
|
||||||
aiGatewayHeaders,
|
aiGatewayHeaders,
|
||||||
featureFlags,
|
featureFlags: {
|
||||||
|
duo_agent_platform_agentic_chat: true,
|
||||||
|
duo_agent_platform: true,
|
||||||
|
...(providerConfig?.options?.featureFlags || {}),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
async discoverModels(): Promise<Record<string, Model>> {
|
|
||||||
if (!apiKey) {
|
|
||||||
log.info("gitlab model discovery skipped: no apiKey")
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const token = apiKey
|
|
||||||
const getHeaders = (): Record<string, string> =>
|
|
||||||
auth?.type === "api" ? { "PRIVATE-TOKEN": token } : { Authorization: `Bearer ${token}` }
|
|
||||||
|
|
||||||
log.info("gitlab model discovery starting", { instanceUrl })
|
|
||||||
const result = await discoverWorkflowModels(
|
|
||||||
{ instanceUrl, getHeaders },
|
|
||||||
{ workingDirectory: Instance.directory },
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!result.models.length) {
|
|
||||||
log.info("gitlab model discovery skipped: no models found", {
|
|
||||||
project: result.project ? { id: result.project.id, path: result.project.pathWithNamespace } : null,
|
|
||||||
})
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const models: Record<string, Model> = {}
|
|
||||||
for (const m of result.models) {
|
|
||||||
if (!input.models[m.id]) {
|
|
||||||
models[m.id] = {
|
|
||||||
id: ModelID.make(m.id),
|
|
||||||
providerID: ProviderID.make("gitlab"),
|
|
||||||
name: `Agent Platform (${m.name})`,
|
|
||||||
family: "",
|
|
||||||
api: {
|
|
||||||
id: m.id,
|
|
||||||
url: instanceUrl,
|
|
||||||
npm: "gitlab-ai-provider",
|
|
||||||
},
|
|
||||||
status: "active",
|
|
||||||
headers: {},
|
|
||||||
options: { workflowRef: m.ref },
|
|
||||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
|
||||||
limit: { context: m.context, output: m.output },
|
|
||||||
capabilities: {
|
|
||||||
temperature: false,
|
|
||||||
reasoning: true,
|
|
||||||
attachment: true,
|
|
||||||
toolcall: true,
|
|
||||||
input: { text: true, audio: false, image: true, video: false, pdf: true },
|
|
||||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
|
||||||
interleaved: false,
|
|
||||||
},
|
|
||||||
release_date: "",
|
|
||||||
variants: {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("gitlab model discovery complete", {
|
|
||||||
count: Object.keys(models).length,
|
|
||||||
models: Object.keys(models),
|
|
||||||
})
|
|
||||||
return models
|
|
||||||
} catch (e) {
|
|
||||||
log.warn("gitlab model discovery failed", { error: e })
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cloudflare-workers-ai": async (input) => {
|
"cloudflare-workers-ai": async (input) => {
|
||||||
@@ -937,9 +844,6 @@ export namespace Provider {
|
|||||||
const varsLoaders: {
|
const varsLoaders: {
|
||||||
[providerID: string]: CustomVarsLoader
|
[providerID: string]: CustomVarsLoader
|
||||||
} = {}
|
} = {}
|
||||||
const discoveryLoaders: {
|
|
||||||
[providerID: string]: CustomDiscoverModels
|
|
||||||
} = {}
|
|
||||||
const sdk = new Map<string, SDK>()
|
const sdk = new Map<string, SDK>()
|
||||||
|
|
||||||
log.info("init")
|
log.info("init")
|
||||||
@@ -1096,7 +1000,6 @@ export namespace Provider {
|
|||||||
if (result && (result.autoload || providers[providerID])) {
|
if (result && (result.autoload || providers[providerID])) {
|
||||||
if (result.getModel) modelLoaders[providerID] = result.getModel
|
if (result.getModel) modelLoaders[providerID] = result.getModel
|
||||||
if (result.vars) varsLoaders[providerID] = result.vars
|
if (result.vars) varsLoaders[providerID] = result.vars
|
||||||
if (result.discoverModels) discoveryLoaders[providerID] = result.discoverModels
|
|
||||||
const opts = result.options ?? {}
|
const opts = result.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)
|
||||||
@@ -1158,18 +1061,6 @@ export namespace Provider {
|
|||||||
log.info("found", { providerID })
|
log.info("found", { providerID })
|
||||||
}
|
}
|
||||||
|
|
||||||
const gitlab = ProviderID.make("gitlab")
|
|
||||||
if (discoveryLoaders[gitlab] && providers[gitlab]) {
|
|
||||||
await (async () => {
|
|
||||||
const discovered = await discoveryLoaders[gitlab]()
|
|
||||||
for (const [modelID, model] of Object.entries(discovered)) {
|
|
||||||
if (!providers[gitlab].models[modelID]) {
|
|
||||||
providers[gitlab].models[modelID] = model
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})().catch((e) => log.warn("state discovery error", { id: "gitlab", error: e }))
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
models: languages,
|
models: languages,
|
||||||
providers,
|
providers,
|
||||||
@@ -1350,7 +1241,7 @@ export namespace Provider {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const language = s.modelLoaders[model.providerID]
|
const language = s.modelLoaders[model.providerID]
|
||||||
? await s.modelLoaders[model.providerID](sdk, model.api.id, { ...provider.options, ...model.options })
|
? await s.modelLoaders[model.providerID](sdk, model.api.id, provider.options)
|
||||||
: sdk.languageModel(model.api.id)
|
: sdk.languageModel(model.api.id)
|
||||||
s.models.set(key, language)
|
s.models.set(key, language)
|
||||||
return language
|
return language
|
||||||
|
|||||||
@@ -755,13 +755,11 @@ export namespace ProviderTransform {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (input.model.api.npm === "@ai-sdk/google" || input.model.api.npm === "@ai-sdk/google-vertex") {
|
if (input.model.api.npm === "@ai-sdk/google" || input.model.api.npm === "@ai-sdk/google-vertex") {
|
||||||
if (input.model.capabilities.reasoning) {
|
result["thinkingConfig"] = {
|
||||||
result["thinkingConfig"] = {
|
includeThoughts: true,
|
||||||
includeThoughts: true,
|
}
|
||||||
}
|
if (input.model.api.id.includes("gemini-3")) {
|
||||||
if (input.model.api.id.includes("gemini-3")) {
|
result["thinkingConfig"]["thinkingLevel"] = "high"
|
||||||
result["thinkingConfig"]["thinkingLevel"] = "high"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export namespace Pty {
|
|||||||
close: (code?: number, reason?: string) => void
|
close: (code?: number, reason?: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const key = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
|
||||||
|
|
||||||
// WebSocket control frame: 0x00 + UTF-8 JSON.
|
// WebSocket control frame: 0x00 + UTF-8 JSON.
|
||||||
const meta = (cursor: number) => {
|
const meta = (cursor: number) => {
|
||||||
const json = JSON.stringify({ cursor })
|
const json = JSON.stringify({ cursor })
|
||||||
@@ -97,9 +99,9 @@ export namespace Pty {
|
|||||||
try {
|
try {
|
||||||
session.process.kill()
|
session.process.kill()
|
||||||
} catch {}
|
} catch {}
|
||||||
for (const [key, ws] of session.subscribers.entries()) {
|
for (const [id, ws] of session.subscribers.entries()) {
|
||||||
try {
|
try {
|
||||||
if (ws.data === key) ws.close()
|
if (key(ws) === id) ws.close()
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -230,9 +232,9 @@ export namespace Pty {
|
|||||||
try {
|
try {
|
||||||
session.process.kill()
|
session.process.kill()
|
||||||
} catch {}
|
} catch {}
|
||||||
for (const [key, ws] of session.subscribers.entries()) {
|
for (const [id, ws] of session.subscribers.entries()) {
|
||||||
try {
|
try {
|
||||||
if (ws.data === key) ws.close()
|
if (key(ws) === id) ws.close()
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -263,16 +265,13 @@ export namespace Pty {
|
|||||||
}
|
}
|
||||||
log.info("client connected to session", { id })
|
log.info("client connected to session", { id })
|
||||||
|
|
||||||
// Use ws.data as the unique key for this connection lifecycle.
|
const sub = key(ws)
|
||||||
// If ws.data is undefined, fallback to ws object.
|
|
||||||
const connectionKey = ws.data && typeof ws.data === "object" ? ws.data : ws
|
|
||||||
|
|
||||||
// Optionally cleanup if the key somehow exists
|
session.subscribers.delete(sub)
|
||||||
session.subscribers.delete(connectionKey)
|
session.subscribers.set(sub, ws)
|
||||||
session.subscribers.set(connectionKey, ws)
|
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
session.subscribers.delete(connectionKey)
|
session.subscribers.delete(sub)
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = session.bufferCursor
|
const start = session.bufferCursor
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||||
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
|
||||||
import { SessionID, MessageID } from "@/session/schema"
|
import { SessionID, MessageID } from "@/session/schema"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { QuestionID } from "./schema"
|
import { QuestionID } from "./schema"
|
||||||
|
|
||||||
export namespace Question {
|
const log = Log.create({ service: "question" })
|
||||||
const log = Log.create({ service: "question" })
|
|
||||||
|
|
||||||
|
export namespace Question {
|
||||||
// Schemas
|
// Schemas
|
||||||
|
|
||||||
export const Option = z
|
export const Option = z
|
||||||
@@ -87,10 +86,6 @@ export namespace Question {
|
|||||||
deferred: Deferred.Deferred<Answer[], RejectedError>
|
deferred: Deferred.Deferred<Answer[], RejectedError>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
|
||||||
pending: Map<QuestionID, PendingEntry>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Service
|
// Service
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
@@ -109,31 +104,13 @@ export namespace Question {
|
|||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const state = yield* InstanceState.make<State>(
|
const pending = new Map<QuestionID, PendingEntry>()
|
||||||
Effect.fn("Question.state")(function* () {
|
|
||||||
const state = {
|
|
||||||
pending: new Map<QuestionID, PendingEntry>(),
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
for (const item of state.pending.values()) {
|
|
||||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
|
||||||
}
|
|
||||||
state.pending.clear()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return state
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const ask = Effect.fn("Question.ask")(function* (input: {
|
const ask = Effect.fn("Question.ask")(function* (input: {
|
||||||
sessionID: SessionID
|
sessionID: SessionID
|
||||||
questions: Info[]
|
questions: Info[]
|
||||||
tool?: { messageID: MessageID; callID: string }
|
tool?: { messageID: MessageID; callID: string }
|
||||||
}) {
|
}) {
|
||||||
const pending = (yield* InstanceState.get(state)).pending
|
|
||||||
const id = QuestionID.ascending()
|
const id = QuestionID.ascending()
|
||||||
log.info("asking", { id, questions: input.questions.length })
|
log.info("asking", { id, questions: input.questions.length })
|
||||||
|
|
||||||
@@ -156,7 +133,6 @@ export namespace Question {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
|
const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
|
||||||
const pending = (yield* InstanceState.get(state)).pending
|
|
||||||
const existing = pending.get(input.requestID)
|
const existing = pending.get(input.requestID)
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
log.warn("reply for unknown request", { requestID: input.requestID })
|
log.warn("reply for unknown request", { requestID: input.requestID })
|
||||||
@@ -173,7 +149,6 @@ export namespace Question {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
|
const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
|
||||||
const pending = (yield* InstanceState.get(state)).pending
|
|
||||||
const existing = pending.get(requestID)
|
const existing = pending.get(requestID)
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
log.warn("reject for unknown request", { requestID })
|
log.warn("reject for unknown request", { requestID })
|
||||||
@@ -189,7 +164,6 @@ export namespace Question {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const list = Effect.fn("Question.list")(function* () {
|
const list = Effect.fn("Question.list")(function* () {
|
||||||
const pending = (yield* InstanceState.get(state)).pending
|
|
||||||
return Array.from(pending.values(), (x) => x.info)
|
return Array.from(pending.values(), (x) => x.info)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -197,25 +171,23 @@ export namespace Question {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
|
||||||
|
|
||||||
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 runPromise((s) => s.ask(input))
|
return runPromiseInstance(Service.use((svc) => svc.ask(input)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reply(input: { requestID: QuestionID; answers: Answer[] }) {
|
export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> {
|
||||||
return runPromise((s) => s.reply(input))
|
return runPromiseInstance(Service.use((svc) => svc.reply(input)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reject(requestID: QuestionID) {
|
export async function reject(requestID: QuestionID): Promise<void> {
|
||||||
return runPromise((s) => s.reject(requestID))
|
return runPromiseInstance(Service.use((svc) => svc.reject(requestID)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function list() {
|
export async function list(): Promise<Request[]> {
|
||||||
return runPromise((s) => s.list())
|
return runPromiseInstance(Service.use((svc) => svc.list()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Permission } from "@/permission"
|
import { PermissionNext } from "@/permission"
|
||||||
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"
|
||||||
@@ -32,11 +32,11 @@ export const PermissionRoutes = lazy(() =>
|
|||||||
requestID: PermissionID.zod,
|
requestID: PermissionID.zod,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
validator("json", z.object({ reply: Permission.Reply, message: z.string().optional() })),
|
validator("json", z.object({ reply: PermissionNext.Reply, message: z.string().optional() })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const params = c.req.valid("param")
|
const params = c.req.valid("param")
|
||||||
const json = c.req.valid("json")
|
const json = c.req.valid("json")
|
||||||
await Permission.reply({
|
await PermissionNext.reply({
|
||||||
requestID: params.requestID,
|
requestID: params.requestID,
|
||||||
reply: json.reply,
|
reply: json.reply,
|
||||||
message: json.message,
|
message: json.message,
|
||||||
@@ -55,14 +55,14 @@ export const PermissionRoutes = lazy(() =>
|
|||||||
description: "List of pending permissions",
|
description: "List of pending permissions",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: resolver(Permission.Request.array()),
|
schema: resolver(PermissionNext.Request.array()),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const permissions = await Permission.list()
|
const permissions = await PermissionNext.list()
|
||||||
return c.json(permissions)
|
return c.json(permissions)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const ProjectRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const projects = Project.list()
|
const projects = await Project.list()
|
||||||
return c.json(projects)
|
return c.json(projects)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ import { ProviderID } from "../../provider/schema"
|
|||||||
import { mapValues } from "remeda"
|
import { mapValues } from "remeda"
|
||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
import { lazy } from "../../util/lazy"
|
import { lazy } from "../../util/lazy"
|
||||||
import { Log } from "../../util/log"
|
|
||||||
|
|
||||||
const log = Log.create({ service: "server" })
|
|
||||||
|
|
||||||
export const ProviderRoutes = lazy(() =>
|
export const ProviderRoutes = lazy(() =>
|
||||||
new Hono()
|
new Hono()
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||||
import { upgradeWebSocket } from "hono/bun"
|
import type { UpgradeWebSocket } from "hono/ws"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Pty } from "@/pty"
|
import { Pty } from "@/pty"
|
||||||
import { PtyID } from "@/pty/schema"
|
import { PtyID } from "@/pty/schema"
|
||||||
import { NotFoundError } from "../../storage/db"
|
import { NotFoundError } from "../../storage/db"
|
||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
import { lazy } from "../../util/lazy"
|
|
||||||
|
|
||||||
export const PtyRoutes = lazy(() =>
|
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||||
new Hono()
|
return new Hono()
|
||||||
.get(
|
.get(
|
||||||
"/",
|
"/",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -197,5 +196,5 @@ export const PtyRoutes = 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 { Permission } from "@/permission"
|
import { PermissionNext } from "@/permission"
|
||||||
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"
|
||||||
@@ -1010,10 +1010,10 @@ export const SessionRoutes = lazy(() =>
|
|||||||
permissionID: PermissionID.zod,
|
permissionID: PermissionID.zod,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
validator("json", z.object({ response: Permission.Reply })),
|
validator("json", z.object({ response: PermissionNext.Reply })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const params = c.req.valid("param")
|
const params = c.req.valid("param")
|
||||||
Permission.reply({
|
PermissionNext.reply({
|
||||||
requestID: params.permissionID,
|
requestID: params.permissionID,
|
||||||
reply: c.req.valid("json").response,
|
reply: c.req.valid("json").response,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import { streamSSE } from "hono/streaming"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
import { Bus } from "../bus"
|
||||||
|
import { BusEvent } from "../bus/bus-event"
|
||||||
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
|
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { cors } from "hono/cors"
|
import { cors } from "hono/cors"
|
||||||
@@ -12,8 +15,9 @@ 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 } from "../project/vcs"
|
||||||
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
import { Agent } from "../agent/agent"
|
import { Agent } from "../agent/agent"
|
||||||
import { Skill } from "../skill"
|
import { Skill } from "../skill/skill"
|
||||||
import { Auth } from "../auth"
|
import { Auth } from "../auth"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { Command } from "../command"
|
import { Command } from "../command"
|
||||||
@@ -24,7 +28,7 @@ import { ProviderID } from "../provider/schema"
|
|||||||
import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware"
|
import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware"
|
||||||
import { ProjectRoutes } from "./routes/project"
|
import { ProjectRoutes } from "./routes/project"
|
||||||
import { SessionRoutes } from "./routes/session"
|
import { SessionRoutes } from "./routes/session"
|
||||||
import { PtyRoutes } from "./routes/pty"
|
// import { PtyRoutes } from "./routes/pty"
|
||||||
import { McpRoutes } from "./routes/mcp"
|
import { McpRoutes } from "./routes/mcp"
|
||||||
import { FileRoutes } from "./routes/file"
|
import { FileRoutes } from "./routes/file"
|
||||||
import { ConfigRoutes } from "./routes/config"
|
import { ConfigRoutes } from "./routes/config"
|
||||||
@@ -34,7 +38,8 @@ import { EventRoutes } from "./routes/event"
|
|||||||
import { InstanceBootstrap } from "../project/bootstrap"
|
import { InstanceBootstrap } from "../project/bootstrap"
|
||||||
import { NotFoundError } from "../storage/db"
|
import { NotFoundError } from "../storage/db"
|
||||||
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
||||||
import { websocket } from "hono/bun"
|
import { createAdaptorServer, type ServerType } from "@hono/node-server"
|
||||||
|
import { createNodeWebSocket } from "@hono/node-ws"
|
||||||
import { HTTPException } from "hono/http-exception"
|
import { HTTPException } from "hono/http-exception"
|
||||||
import { errors } from "./error"
|
import { errors } from "./error"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
@@ -48,13 +53,20 @@ import { lazy } from "@/util/lazy"
|
|||||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||||
|
|
||||||
export namespace Server {
|
export namespace Server {
|
||||||
const log = Log.create({ service: "server" })
|
export type Listener = {
|
||||||
|
hostname: string
|
||||||
|
port: number
|
||||||
|
url: URL
|
||||||
|
stop: (close?: boolean) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
export const Default = lazy(() => createApp({}))
|
export const Default = lazy(() => create({}).app)
|
||||||
|
|
||||||
export const createApp = (opts: { cors?: string[] }): Hono => {
|
function create(opts: { cors?: string[] }) {
|
||||||
|
const log = Log.create({ service: "server" })
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
return app
|
const ws = createNodeWebSocket({ app })
|
||||||
|
const route = app
|
||||||
.onError((err, c) => {
|
.onError((err, c) => {
|
||||||
log.error("failed", {
|
log.error("failed", {
|
||||||
error: err,
|
error: err,
|
||||||
@@ -151,7 +163,7 @@ export namespace Server {
|
|||||||
providerID: ProviderID.zod,
|
providerID: ProviderID.zod,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
validator("json", Auth.Info.zod),
|
validator("json", Auth.Info),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const providerID = c.req.valid("param").providerID
|
const providerID = c.req.valid("param").providerID
|
||||||
const info = c.req.valid("json")
|
const info = c.req.valid("json")
|
||||||
@@ -240,7 +252,6 @@ export namespace Server {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.route("/project", ProjectRoutes())
|
.route("/project", ProjectRoutes())
|
||||||
.route("/pty", PtyRoutes())
|
|
||||||
.route("/config", ConfigRoutes())
|
.route("/config", ConfigRoutes())
|
||||||
.route("/experimental", ExperimentalRoutes())
|
.route("/experimental", ExperimentalRoutes())
|
||||||
.route("/session", SessionRoutes())
|
.route("/session", SessionRoutes())
|
||||||
@@ -330,7 +341,7 @@ export namespace Server {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const branch = await Vcs.branch()
|
const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch()))
|
||||||
return c.json({
|
return c.json({
|
||||||
branch,
|
branch,
|
||||||
})
|
})
|
||||||
@@ -496,22 +507,70 @@ export namespace Server {
|
|||||||
return c.json(await Format.status())
|
return c.json(await Format.status())
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.all("/*", async (c) => {
|
.get(
|
||||||
const path = c.req.path
|
"/event",
|
||||||
|
describeRoute({
|
||||||
const response = await proxy(`https://app.opencode.ai${path}`, {
|
summary: "Subscribe to events",
|
||||||
...c.req,
|
description: "Get events",
|
||||||
headers: {
|
operationId: "event.subscribe",
|
||||||
...c.req.raw.headers,
|
responses: {
|
||||||
host: "app.opencode.ai",
|
200: {
|
||||||
|
description: "Event stream",
|
||||||
|
content: {
|
||||||
|
"text/event-stream": {
|
||||||
|
schema: resolver(BusEvent.payloads()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
response.headers.set(
|
async (c) => {
|
||||||
"Content-Security-Policy",
|
log.info("event connected")
|
||||||
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:",
|
c.header("X-Accel-Buffering", "no")
|
||||||
)
|
c.header("X-Content-Type-Options", "nosniff")
|
||||||
return response
|
return streamSSE(c, async (stream) => {
|
||||||
})
|
stream.writeSSE({
|
||||||
|
data: JSON.stringify({
|
||||||
|
type: "server.connected",
|
||||||
|
properties: {},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const unsub = Bus.subscribeAll(async (event) => {
|
||||||
|
await stream.writeSSE({
|
||||||
|
data: JSON.stringify(event),
|
||||||
|
})
|
||||||
|
if (event.type === Bus.InstanceDisposed.type) {
|
||||||
|
stream.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Send heartbeat every 10s to prevent stalled proxy streams.
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
stream.writeSSE({
|
||||||
|
data: JSON.stringify({
|
||||||
|
type: "server.heartbeat",
|
||||||
|
properties: {},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}, 10_000)
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
stream.onAbort(() => {
|
||||||
|
clearInterval(heartbeat)
|
||||||
|
unsub()
|
||||||
|
resolve()
|
||||||
|
log.info("event disconnected")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
// .route("/pty", PtyRoutes(ws.upgradeWebSocket))
|
||||||
|
|
||||||
|
return {
|
||||||
|
app: route as Hono,
|
||||||
|
ws,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openapi() {
|
export async function openapi() {
|
||||||
@@ -529,52 +588,89 @@ export namespace Server {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated do not use this dumb shit */
|
|
||||||
export let url: URL
|
export let url: URL
|
||||||
|
|
||||||
export function listen(opts: {
|
export async function listen(opts: {
|
||||||
port: number
|
port: number
|
||||||
hostname: string
|
hostname: string
|
||||||
mdns?: boolean
|
mdns?: boolean
|
||||||
mdnsDomain?: string
|
mdnsDomain?: string
|
||||||
cors?: string[]
|
cors?: string[]
|
||||||
}) {
|
}): Promise<Listener> {
|
||||||
url = new URL(`http://${opts.hostname}:${opts.port}`)
|
const log = Log.create({ service: "server" })
|
||||||
const app = createApp(opts)
|
const built = create({
|
||||||
const args = {
|
...opts,
|
||||||
hostname: opts.hostname,
|
})
|
||||||
idleTimeout: 0,
|
const start = (port: number) =>
|
||||||
fetch: app.fetch,
|
new Promise<ServerType>((resolve, reject) => {
|
||||||
websocket: websocket,
|
const server = createAdaptorServer({ fetch: built.app.fetch })
|
||||||
} as const
|
built.ws.injectWebSocket(server)
|
||||||
const tryServe = (port: number) => {
|
const fail = (err: Error) => {
|
||||||
try {
|
cleanup()
|
||||||
return Bun.serve({ ...args, port })
|
reject(err)
|
||||||
} catch {
|
}
|
||||||
return undefined
|
const ready = () => {
|
||||||
}
|
cleanup()
|
||||||
|
resolve(server)
|
||||||
|
}
|
||||||
|
const cleanup = () => {
|
||||||
|
server.off("error", fail)
|
||||||
|
server.off("listening", ready)
|
||||||
|
}
|
||||||
|
server.once("error", fail)
|
||||||
|
server.once("listening", ready)
|
||||||
|
server.listen(port, opts.hostname)
|
||||||
|
})
|
||||||
|
|
||||||
|
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
|
||||||
|
const addr = server.address()
|
||||||
|
if (!addr || typeof addr === "string") {
|
||||||
|
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
||||||
}
|
}
|
||||||
const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port)
|
|
||||||
if (!server) throw new Error(`Failed to start server on port ${opts.port}`)
|
const url = new URL("http://localhost")
|
||||||
|
url.hostname = opts.hostname
|
||||||
|
url.port = String(addr.port)
|
||||||
|
Server.url = url
|
||||||
|
|
||||||
const shouldPublishMDNS =
|
const shouldPublishMDNS =
|
||||||
opts.mdns &&
|
opts.mdns &&
|
||||||
server.port &&
|
addr.port &&
|
||||||
opts.hostname !== "127.0.0.1" &&
|
opts.hostname !== "127.0.0.1" &&
|
||||||
opts.hostname !== "localhost" &&
|
opts.hostname !== "localhost" &&
|
||||||
opts.hostname !== "::1"
|
opts.hostname !== "::1"
|
||||||
if (shouldPublishMDNS) {
|
if (shouldPublishMDNS) {
|
||||||
MDNS.publish(server.port!, opts.mdnsDomain)
|
MDNS.publish(addr.port, opts.mdnsDomain)
|
||||||
} else if (opts.mdns) {
|
} else if (opts.mdns) {
|
||||||
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalStop = server.stop.bind(server)
|
let closing: Promise<void> | undefined
|
||||||
server.stop = async (closeActiveConnections?: boolean) => {
|
return {
|
||||||
if (shouldPublishMDNS) MDNS.unpublish()
|
hostname: opts.hostname,
|
||||||
return originalStop(closeActiveConnections)
|
port: addr.port,
|
||||||
|
url,
|
||||||
|
stop(close?: boolean) {
|
||||||
|
closing ??= new Promise((resolve, reject) => {
|
||||||
|
if (shouldPublishMDNS) MDNS.unpublish()
|
||||||
|
server.close((err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
if (close) {
|
||||||
|
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
|
||||||
|
server.closeAllConnections()
|
||||||
|
}
|
||||||
|
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
|
||||||
|
server.closeIdleConnections()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return closing
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return server
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Permission } from "@/permission"
|
import { PermissionNext } from "@/permission"
|
||||||
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"
|
||||||
@@ -148,7 +148,7 @@ export namespace Session {
|
|||||||
compacting: z.number().optional(),
|
compacting: z.number().optional(),
|
||||||
archived: z.number().optional(),
|
archived: z.number().optional(),
|
||||||
}),
|
}),
|
||||||
permission: Permission.Ruleset.optional(),
|
permission: PermissionNext.Ruleset.optional(),
|
||||||
revert: z
|
revert: z
|
||||||
.object({
|
.object({
|
||||||
messageID: MessageID.zod,
|
messageID: MessageID.zod,
|
||||||
@@ -300,7 +300,7 @@ export namespace Session {
|
|||||||
parentID?: SessionID
|
parentID?: SessionID
|
||||||
workspaceID?: WorkspaceID
|
workspaceID?: WorkspaceID
|
||||||
directory: string
|
directory: string
|
||||||
permission?: Permission.Ruleset
|
permission?: PermissionNext.Ruleset
|
||||||
}) {
|
}) {
|
||||||
const result: Info = {
|
const result: Info = {
|
||||||
id: SessionID.descending(input.id),
|
id: SessionID.descending(input.id),
|
||||||
@@ -423,7 +423,7 @@ export namespace Session {
|
|||||||
export const setPermission = fn(
|
export const setPermission = fn(
|
||||||
z.object({
|
z.object({
|
||||||
sessionID: SessionID.zod,
|
sessionID: SessionID.zod,
|
||||||
permission: Permission.Ruleset,
|
permission: PermissionNext.Ruleset,
|
||||||
}),
|
}),
|
||||||
async (input) => {
|
async (input) => {
|
||||||
return Database.use((db) => {
|
return Database.use((db) => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user