mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-18 12:44:29 +00:00
Compare commits
26 Commits
no-project
...
review-git
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f81e302f8 | ||
|
|
58e26ac413 | ||
|
|
331dacf9db | ||
|
|
4ba7d3b406 | ||
|
|
a43783a6d4 | ||
|
|
37c5295111 | ||
|
|
56102ff642 | ||
|
|
1b86c27fb8 | ||
|
|
fe43bdb699 | ||
|
|
a849a17e93 | ||
|
|
0292f1b559 | ||
|
|
5dfe86dcb1 | ||
|
|
4b4dd2b882 | ||
|
|
bc949af623 | ||
|
|
9e7c136de7 | ||
|
|
fee3c196c5 | ||
|
|
6c047391bb | ||
|
|
350df0b261 | ||
|
|
fbabc97c4c | ||
|
|
7daea69e13 | ||
|
|
352af13d3a | ||
|
|
0772a95918 | ||
|
|
dadddc9c8c | ||
|
|
6708c3f6cf | ||
|
|
948138f385 | ||
|
|
fbb074480d |
@@ -201,6 +201,10 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
|
||||
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
||||
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
||||
|
||||
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
|
||||
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
|
||||
const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL")
|
||||
|
||||
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
|
||||
handler: "packages/console/function/src/log-processor.ts",
|
||||
link: [new sst.Secret("HONEYCOMB_API_KEY")],
|
||||
@@ -219,6 +223,9 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
EMAILOCTOPUS_API_KEY,
|
||||
AWS_SES_ACCESS_KEY_ID,
|
||||
AWS_SES_SECRET_ACCESS_KEY,
|
||||
SALESFORCE_CLIENT_ID,
|
||||
SALESFORCE_CLIENT_SECRET,
|
||||
SALESFORCE_INSTANCE_URL,
|
||||
ZEN_BLACK_PRICE,
|
||||
ZEN_LITE_PRICE,
|
||||
new sst.Secret("ZEN_LIMITS"),
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"dev:desktop": "bun --cwd packages/desktop tauri dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"prepare": "husky",
|
||||
|
||||
@@ -3,8 +3,11 @@ import { serverNamePattern } from "../utils"
|
||||
|
||||
test("home renders and shows core entrypoints", async ({ page }) => {
|
||||
await page.goto("/")
|
||||
const nav = page.locator('[data-component="sidebar-nav-desktop"]')
|
||||
|
||||
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
|
||||
await expect(nav.getByText("No projects open")).toBeVisible()
|
||||
await expect(nav.getByText("Open a project to get started")).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -19,42 +22,3 @@ test("server picker dialog opens from home", async ({ page }) => {
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByRole("textbox").first()).toBeVisible()
|
||||
})
|
||||
|
||||
test("home hides desktop history and sidebar controls", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await page.goto("/")
|
||||
|
||||
await expect(page.getByRole("button", { name: "Toggle sidebar" })).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Go back" })).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Go forward" })).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Toggle menu" })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("home keeps the mobile menu available", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 430, height: 900 })
|
||||
await page.goto("/")
|
||||
|
||||
const toggle = page.getByRole("button", { name: "Toggle menu" }).first()
|
||||
await expect(toggle).toBeVisible()
|
||||
await toggle.click()
|
||||
|
||||
const nav = page.locator('[data-component="sidebar-nav-mobile"]')
|
||||
await expect(nav).toBeVisible()
|
||||
await expect.poll(async () => (await nav.boundingBox())?.width ?? 0).toBeLessThan(120)
|
||||
await expect(nav.getByRole("button", { name: "Settings" })).toBeVisible()
|
||||
await expect(nav.getByRole("button", { name: "Help" })).toBeVisible()
|
||||
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await expect(nav).toBeHidden()
|
||||
|
||||
await page.setViewportSize({ width: 430, height: 900 })
|
||||
await expect(toggle).toBeVisible()
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(nav).toHaveClass(/-translate-x-full/)
|
||||
|
||||
await toggle.click()
|
||||
await expect(nav).toBeVisible()
|
||||
|
||||
await nav.getByRole("button", { name: "Settings" }).click()
|
||||
await expect(page.getByRole("dialog")).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -58,7 +58,6 @@ export function Titlebar() {
|
||||
})
|
||||
|
||||
const path = () => `${location.pathname}${location.search}${location.hash}`
|
||||
const home = createMemo(() => !params.dir)
|
||||
const creating = createMemo(() => {
|
||||
if (!params.dir) return false
|
||||
if (params.id) return false
|
||||
@@ -78,6 +77,7 @@ export function Titlebar() {
|
||||
|
||||
const canBack = createMemo(() => history.index > 0)
|
||||
const canForward = createMemo(() => history.index < history.stack.length - 1)
|
||||
const hasProjects = createMemo(() => layout.projects.list().length > 0)
|
||||
|
||||
const back = () => {
|
||||
const next = backPath(history)
|
||||
@@ -199,60 +199,60 @@ export function Titlebar() {
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!home()}>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<TooltipKeybind
|
||||
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
|
||||
placement="bottom"
|
||||
title={language.t("command.sidebar.toggle")}
|
||||
keybind={command.keybind("sidebar.toggle")}
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<TooltipKeybind
|
||||
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
|
||||
placement="bottom"
|
||||
title={language.t("command.sidebar.toggle")}
|
||||
keybind={command.keybind("sidebar.toggle")}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
|
||||
onClick={layout.sidebar.toggle}
|
||||
aria-label={language.t("command.sidebar.toggle")}
|
||||
aria-expanded={layout.sidebar.opened()}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
|
||||
onClick={layout.sidebar.toggle}
|
||||
aria-label={language.t("command.sidebar.toggle")}
|
||||
aria-expanded={layout.sidebar.opened()}
|
||||
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
<div class="hidden xl:flex items-center shrink-0">
|
||||
<Show when={params.dir}>
|
||||
<div
|
||||
class="flex items-center shrink-0 w-8 mr-1"
|
||||
aria-hidden={layout.sidebar.opened() ? "true" : undefined}
|
||||
>
|
||||
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
<div class="hidden xl:flex items-center shrink-0">
|
||||
<Show when={params.dir}>
|
||||
<div
|
||||
class="flex items-center shrink-0 w-8 mr-1"
|
||||
aria-hidden={layout.sidebar.opened() ? "true" : undefined}
|
||||
class="transition-opacity"
|
||||
classList={{
|
||||
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
|
||||
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="transition-opacity"
|
||||
classList={{
|
||||
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
|
||||
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
|
||||
}}
|
||||
<TooltipKeybind
|
||||
placement="bottom"
|
||||
title={language.t("command.session.new")}
|
||||
keybind={command.keybind("session.new")}
|
||||
openDelay={2000}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="bottom"
|
||||
title={language.t("command.session.new")}
|
||||
keybind={command.keybind("session.new")}
|
||||
openDelay={2000}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon={creating() ? "new-session-active" : "new-session"}
|
||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||
disabled={layout.sidebar.opened()}
|
||||
tabIndex={layout.sidebar.opened() ? -1 : undefined}
|
||||
onClick={() => {
|
||||
if (!params.dir) return
|
||||
navigate(`/${params.dir}/session`)
|
||||
}}
|
||||
aria-label={language.t("command.session.new")}
|
||||
aria-current={creating() ? "page" : undefined}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon={creating() ? "new-session-active" : "new-session"}
|
||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||
disabled={layout.sidebar.opened()}
|
||||
tabIndex={layout.sidebar.opened() ? -1 : undefined}
|
||||
onClick={() => {
|
||||
if (!params.dir) return
|
||||
navigate(`/${params.dir}/session`)
|
||||
}}
|
||||
aria-label={language.t("command.session.new")}
|
||||
aria-current={creating() ? "page" : undefined}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={hasProjects()}>
|
||||
<div
|
||||
class="flex items-center gap-0 transition-transform"
|
||||
classList={{
|
||||
@@ -283,9 +283,9 @@ export function Titlebar() {
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
@@ -238,9 +238,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
})
|
||||
const warnedDuplicates = new Set<string>()
|
||||
|
||||
type CommandCatalog = Record<string, CommandCatalogItem>
|
||||
const [catalog, setCatalog, _, catalogReady] = persisted(
|
||||
Persist.global("command.catalog.v1"),
|
||||
createStore<Record<string, CommandCatalogItem>>({}),
|
||||
createStore<CommandCatalog>({}),
|
||||
)
|
||||
|
||||
const bind = (id: string, def: KeybindConfig | undefined) => {
|
||||
@@ -259,7 +260,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
if (seen.has(opt.id)) {
|
||||
if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
|
||||
warnedDuplicates.add(opt.id)
|
||||
console.warn(`[command] duplicate command id \"${opt.id}\" registered; keeping first entry`)
|
||||
console.warn(`[command] duplicate command id "${opt.id}" registered; keeping first entry`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -274,16 +275,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
createEffect(() => {
|
||||
if (!catalogReady()) return
|
||||
|
||||
for (const opt of registered()) {
|
||||
const id = actionId(opt.id)
|
||||
setCatalog(id, {
|
||||
title: opt.title,
|
||||
description: opt.description,
|
||||
category: opt.category,
|
||||
keybind: opt.keybind,
|
||||
slash: opt.slash,
|
||||
})
|
||||
}
|
||||
setCatalog(
|
||||
registered().reduce((acc, opt) => {
|
||||
const id = actionId(opt.id)
|
||||
acc[id] = {
|
||||
title: opt.title,
|
||||
description: opt.description,
|
||||
category: opt.category,
|
||||
keybind: opt.keybind,
|
||||
slash: opt.slash,
|
||||
}
|
||||
return acc
|
||||
}, {} as CommandCatalog),
|
||||
)
|
||||
})
|
||||
|
||||
const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta })))
|
||||
|
||||
@@ -158,7 +158,7 @@ export async function bootstrapDirectory(input: {
|
||||
input.sdk.vcs.get().then((x) => {
|
||||
const next = x.data ?? input.store.vcs
|
||||
input.setStore("vcs", next)
|
||||
if (next?.branch) input.vcsCache.setStore("value", next)
|
||||
if (next) input.vcsCache.setStore("value", next)
|
||||
}),
|
||||
input.sdk.permission.list().then((x) => {
|
||||
const grouped = groupBySession(
|
||||
|
||||
@@ -268,9 +268,9 @@ export function applyDirectoryEvent(input: {
|
||||
break
|
||||
}
|
||||
case "vcs.branch.updated": {
|
||||
const props = event.properties as { branch: string }
|
||||
const props = event.properties as { branch?: string }
|
||||
if (input.store.vcs?.branch === props.branch) break
|
||||
const next = { branch: props.branch }
|
||||
const next = { ...input.store.vcs, branch: props.branch }
|
||||
input.setStore("vcs", next)
|
||||
if (input.vcsCache) input.vcsCache.setStore("value", next)
|
||||
break
|
||||
|
||||
@@ -532,6 +532,8 @@ export const dict = {
|
||||
"session.review.noVcs.createGit.action": "Create Git repository",
|
||||
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
|
||||
"session.review.noChanges": "No changes",
|
||||
"session.review.noUncommittedChanges": "No uncommitted changes yet",
|
||||
"session.review.noBranchChanges": "No branch changes yet",
|
||||
|
||||
"session.files.selectToOpen": "Select a file to open",
|
||||
"session.files.all": "All files",
|
||||
@@ -674,6 +676,8 @@ export const dict = {
|
||||
"sidebar.project.recentSessions": "Recent sessions",
|
||||
"sidebar.project.viewAllSessions": "View all sessions",
|
||||
"sidebar.project.clearNotifications": "Clear notifications",
|
||||
"sidebar.empty.title": "No projects open",
|
||||
"sidebar.empty.description": "Open a project to get started",
|
||||
|
||||
"debugBar.ariaLabel": "Development performance diagnostics",
|
||||
"debugBar.na": "n/a",
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
batch,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
For,
|
||||
on,
|
||||
onCleanup,
|
||||
@@ -200,20 +201,13 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
onMount(() => {
|
||||
const stop = () => setState("sizing", false)
|
||||
const sync = () => {
|
||||
if (!window.matchMedia("(min-width: 1280px)").matches) return
|
||||
layout.mobileSidebar.hide()
|
||||
}
|
||||
window.addEventListener("pointerup", stop)
|
||||
window.addEventListener("pointercancel", stop)
|
||||
window.addEventListener("blur", stop)
|
||||
window.addEventListener("resize", sync)
|
||||
sync()
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("pointerup", stop)
|
||||
window.removeEventListener("pointercancel", stop)
|
||||
window.removeEventListener("blur", stop)
|
||||
window.removeEventListener("resize", sync)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -284,16 +278,6 @@ export default function Layout(props: ParentProps) {
|
||||
setHoverProject(undefined)
|
||||
})
|
||||
|
||||
const autoselecting = createMemo(() => {
|
||||
if (params.dir) return false
|
||||
if (!state.autoselect) return false
|
||||
if (!pageReady()) return true
|
||||
if (!layoutReady()) return true
|
||||
const list = layout.projects.list()
|
||||
if (list.length > 0) return true
|
||||
return !!server.projects.last()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!state.autoselect) return
|
||||
const dir = params.dir
|
||||
@@ -579,33 +563,22 @@ export default function Layout(props: ParentProps) {
|
||||
return projects.find((p) => p.worktree === root)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }),
|
||||
(value) => {
|
||||
if (!value.ready) return
|
||||
if (!value.layoutReady) return
|
||||
if (!state.autoselect) return
|
||||
if (value.dir) return
|
||||
const [autoselecting] = createResource(async () => {
|
||||
await ready.promise
|
||||
await layout.ready.promise
|
||||
|
||||
const last = server.projects.last()
|
||||
const list = layout.projects.list()
|
||||
const last = server.projects.last()
|
||||
|
||||
if (value.list.length === 0) {
|
||||
if (!last) return
|
||||
setState("autoselect", false)
|
||||
openProject(last, false)
|
||||
navigateToProject(last)
|
||||
return
|
||||
}
|
||||
|
||||
const next = value.list.find((project) => project.worktree === last) ?? value.list[0]
|
||||
if (!next) return
|
||||
setState("autoselect", false)
|
||||
openProject(next.worktree, false)
|
||||
navigateToProject(next.worktree)
|
||||
},
|
||||
),
|
||||
)
|
||||
if (list.length === 0) {
|
||||
if (!last) return
|
||||
await openProject(last, true)
|
||||
} else {
|
||||
const next = list.find((project) => project.worktree === last) ?? list[0]
|
||||
if (!next) return
|
||||
await openProject(next.worktree, true)
|
||||
}
|
||||
})
|
||||
|
||||
const workspaceName = (directory: string, projectId?: string, branch?: string) => {
|
||||
const key = workspaceKey(directory)
|
||||
@@ -1318,7 +1291,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
function openProject(directory: string, navigate = true) {
|
||||
layout.projects.open(directory)
|
||||
if (navigate) navigateToProject(directory)
|
||||
if (navigate) return navigateToProject(directory)
|
||||
}
|
||||
|
||||
const handleDeepLinks = (urls: string[]) => {
|
||||
@@ -1966,6 +1939,7 @@ export default function Layout(props: ParentProps) {
|
||||
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
|
||||
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
|
||||
const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened())
|
||||
const empty = createMemo(() => !params.dir && layout.projects.list().length === 0)
|
||||
const projectName = createMemo(() => {
|
||||
const item = project()
|
||||
if (!item) return ""
|
||||
@@ -2018,7 +1992,26 @@ export default function Layout(props: ParentProps) {
|
||||
width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`,
|
||||
}}
|
||||
>
|
||||
<Show when={project()}>
|
||||
<Show
|
||||
when={project()}
|
||||
fallback={
|
||||
<Show when={empty()}>
|
||||
<div class="flex-1 min-h-0 -mt-4 flex items-center justify-center px-6 pb-64 text-center">
|
||||
<div class="mt-8 flex max-w-60 flex-col items-center gap-6 text-center">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-14-medium text-text-strong">{language.t("sidebar.empty.title")}</div>
|
||||
<div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
|
||||
{language.t("sidebar.empty.description")}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="large" icon="folder-add-left" onClick={chooseProject}>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<div class="shrink-0 pl-1 py-1">
|
||||
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
|
||||
@@ -2249,7 +2242,6 @@ export default function Layout(props: ParentProps) {
|
||||
<SidebarContent
|
||||
mobile={mobile}
|
||||
opened={() => layout.sidebar.opened()}
|
||||
hasPanel={() => !!currentProject()}
|
||||
aimMove={aim.move}
|
||||
projects={projects}
|
||||
renderProject={(project) => (
|
||||
@@ -2268,13 +2260,7 @@ export default function Layout(props: ParentProps) {
|
||||
helpLabel={() => language.t("sidebar.help")}
|
||||
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
renderPanel={() =>
|
||||
mobile ? (
|
||||
<SidebarPanel project={currentProject} mobile />
|
||||
) : (
|
||||
<Show when={currentProject()}>
|
||||
<SidebarPanel project={currentProject} merged />
|
||||
</Show>
|
||||
)
|
||||
mobile ? <SidebarPanel project={currentProject} mobile /> : <SidebarPanel project={currentProject} merged />
|
||||
}
|
||||
/>
|
||||
)
|
||||
@@ -2348,9 +2334,7 @@ export default function Layout(props: ParentProps) {
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-mobile"
|
||||
classList={{
|
||||
"@container fixed top-10 bottom-0 left-0 z-50 overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
|
||||
"w-16": !currentProject(),
|
||||
"w-full max-w-[400px]": !!currentProject(),
|
||||
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
|
||||
"translate-x-0": layout.mobileSidebar.opened(),
|
||||
"-translate-x-full": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
@@ -2377,7 +2361,7 @@ export default function Layout(props: ParentProps) {
|
||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
|
||||
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
|
||||
@@ -15,7 +15,6 @@ import { type LocalProject } from "@/context/layout"
|
||||
export const SidebarContent = (props: {
|
||||
mobile?: boolean
|
||||
opened: Accessor<boolean>
|
||||
hasPanel?: Accessor<boolean>
|
||||
aimMove: (event: MouseEvent) => void
|
||||
projects: Accessor<LocalProject[]>
|
||||
renderProject: (project: LocalProject) => JSX.Element
|
||||
@@ -34,7 +33,6 @@ export const SidebarContent = (props: {
|
||||
renderPanel: () => JSX.Element
|
||||
}): JSX.Element => {
|
||||
const expanded = createMemo(() => !!props.mobile || props.opened())
|
||||
const hasPanel = createMemo(() => props.hasPanel?.() ?? true)
|
||||
const placement = () => (props.mobile ? "bottom" : "right")
|
||||
let panel: HTMLDivElement | undefined
|
||||
|
||||
@@ -113,17 +111,15 @@ export const SidebarContent = (props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={hasPanel()}>
|
||||
<div
|
||||
ref={(el) => {
|
||||
panel = el
|
||||
}}
|
||||
classList={{ "flex-1 flex h-full min-h-0 min-w-0 overflow-hidden": true, "pointer-events-none": !expanded() }}
|
||||
aria-hidden={!expanded()}
|
||||
>
|
||||
{props.renderPanel()}
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
ref={(el) => {
|
||||
panel = el
|
||||
}}
|
||||
classList={{ "flex-1 flex h-full min-h-0 min-w-0 overflow-hidden": true, "pointer-events-none": !expanded() }}
|
||||
aria-hidden={!expanded()}
|
||||
>
|
||||
{props.renderPanel()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import {
|
||||
batch,
|
||||
@@ -57,6 +57,9 @@ import { formatServerError } from "@/utils/server-errors"
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
const emptyFollowups: (FollowupDraft & { id: string })[] = []
|
||||
|
||||
type ChangeMode = "git" | "branch" | "session" | "turn"
|
||||
type VcsMode = "git" | "branch"
|
||||
|
||||
type SessionHistoryWindowInput = {
|
||||
sessionID: () => string | undefined
|
||||
messagesReady: () => boolean
|
||||
@@ -415,15 +418,16 @@ export default function Page() {
|
||||
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||
const hasReview = createMemo(() => reviewCount() > 0)
|
||||
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||
const hasSessionReview = createMemo(() => sessionCount() > 0)
|
||||
const canReview = createMemo(() => !!params.id)
|
||||
const reviewTab = createMemo(() => isDesktop())
|
||||
const tabState = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab,
|
||||
review: reviewTab,
|
||||
hasReview,
|
||||
hasReview: canReview,
|
||||
})
|
||||
const contextOpen = tabState.contextOpen
|
||||
const openedTabs = tabState.openedTabs
|
||||
@@ -499,11 +503,22 @@ export default function Page() {
|
||||
const [store, setStore] = createStore({
|
||||
messageId: undefined as string | undefined,
|
||||
mobileTab: "session" as "session" | "changes",
|
||||
changes: "session" as "session" | "turn",
|
||||
changes: "git" as ChangeMode,
|
||||
newSessionWorktree: "main",
|
||||
deferRender: false,
|
||||
})
|
||||
|
||||
const [vcs, setVcs] = createStore({
|
||||
diff: {
|
||||
git: [] as FileDiff[],
|
||||
branch: [] as FileDiff[],
|
||||
},
|
||||
ready: {
|
||||
git: false,
|
||||
branch: false,
|
||||
},
|
||||
})
|
||||
|
||||
const [followup, setFollowup] = createStore({
|
||||
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
|
||||
sending: {} as Record<string, string | undefined>,
|
||||
@@ -531,6 +546,40 @@ export default function Page() {
|
||||
let refreshTimer: number | undefined
|
||||
let diffFrame: number | undefined
|
||||
let diffTimer: number | undefined
|
||||
const vcsTask = new Map<VcsMode, Promise<void>>()
|
||||
|
||||
const resetVcs = () => {
|
||||
vcsTask.clear()
|
||||
setVcs({
|
||||
diff: { git: [], branch: [] },
|
||||
ready: { git: false, branch: false },
|
||||
})
|
||||
}
|
||||
|
||||
const loadVcs = (mode: VcsMode, force = false) => {
|
||||
if (sync.project?.vcs !== "git") return Promise.resolve()
|
||||
if (vcs.ready[mode] && !force) return Promise.resolve()
|
||||
const current = vcsTask.get(mode)
|
||||
if (current) return current
|
||||
|
||||
const task = sdk.client.vcs
|
||||
.diff({ mode })
|
||||
.then((result) => {
|
||||
setVcs("diff", mode, result.data ?? [])
|
||||
setVcs("ready", mode, true)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.debug("[session-review] failed to load vcs diff", { mode, error })
|
||||
setVcs("diff", mode, [])
|
||||
setVcs("ready", mode, true)
|
||||
})
|
||||
.finally(() => {
|
||||
vcsTask.delete(mode)
|
||||
})
|
||||
|
||||
vcsTask.set(mode, task)
|
||||
return task
|
||||
}
|
||||
|
||||
createComputed((prev) => {
|
||||
const open = desktopReviewOpen()
|
||||
@@ -546,7 +595,38 @@ export default function Page() {
|
||||
}, desktopReviewOpen())
|
||||
|
||||
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
||||
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
|
||||
const changesOptions = createMemo<ChangeMode[]>(() => {
|
||||
const list: ChangeMode[] = []
|
||||
const git = sync.project?.vcs === "git"
|
||||
if (git) list.push("git")
|
||||
if (git && sync.data.vcs?.branch && sync.data.vcs?.default_branch && sync.data.vcs.branch !== sync.data.vcs.default_branch) {
|
||||
list.push("branch")
|
||||
}
|
||||
list.push("session", "turn")
|
||||
return list
|
||||
})
|
||||
const vcsMode = createMemo<VcsMode | undefined>(() => {
|
||||
if (store.changes === "git" || store.changes === "branch") return store.changes
|
||||
})
|
||||
const reviewDiffs = createMemo(() => {
|
||||
if (store.changes === "git") return vcs.diff.git
|
||||
if (store.changes === "branch") return vcs.diff.branch
|
||||
if (store.changes === "session") return diffs()
|
||||
return turnDiffs()
|
||||
})
|
||||
const reviewCount = createMemo(() => {
|
||||
if (store.changes === "git") return vcs.diff.git.length
|
||||
if (store.changes === "branch") return vcs.diff.branch.length
|
||||
if (store.changes === "session") return sessionCount()
|
||||
return turnDiffs().length
|
||||
})
|
||||
const hasReview = createMemo(() => reviewCount() > 0)
|
||||
const reviewReady = createMemo(() => {
|
||||
if (store.changes === "git") return vcs.ready.git
|
||||
if (store.changes === "branch") return vcs.ready.branch
|
||||
if (store.changes === "session") return !hasSessionReview() || diffsReady()
|
||||
return true
|
||||
})
|
||||
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (store.newSessionWorktree === "create") return "create"
|
||||
@@ -615,10 +695,10 @@ export default function Page() {
|
||||
const diffsReady = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return true
|
||||
if (!hasReview()) return true
|
||||
if (!hasSessionReview()) return true
|
||||
return sync.data.session_diff[id] !== undefined
|
||||
})
|
||||
const reviewEmptyKey = createMemo(() => {
|
||||
const sessionEmptyKey = createMemo(() => {
|
||||
const project = sync.project
|
||||
if (project && !project.vcs) return "session.review.noVcs"
|
||||
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
||||
@@ -741,13 +821,23 @@ export default function Page() {
|
||||
sessionKey,
|
||||
() => {
|
||||
setStore("messageId", undefined)
|
||||
setStore("changes", "session")
|
||||
setStore("changes", "git")
|
||||
setUi("pendingMessage", undefined)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => sdk.directory,
|
||||
() => {
|
||||
resetVcs()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => params.dir,
|
||||
@@ -870,6 +960,38 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
|
||||
const wantsReview = createMemo(() =>
|
||||
isDesktop() ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") : store.mobileTab === "changes",
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const list = changesOptions()
|
||||
if (list.includes(store.changes)) return
|
||||
const next = list[0]
|
||||
if (!next) return
|
||||
setStore("changes", next)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const mode = vcsMode()
|
||||
if (!mode) return
|
||||
if (!wantsReview()) return
|
||||
void loadVcs(mode)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => sync.data.session_status[params.id ?? ""]?.type,
|
||||
(next, prev) => {
|
||||
const mode = vcsMode()
|
||||
if (!mode) return
|
||||
if (!wantsReview()) return
|
||||
if (next !== "idle" || prev === undefined || prev === "idle") return
|
||||
void loadVcs(mode, true)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const fileTreeTab = () => layout.fileTree.tab()
|
||||
const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
|
||||
@@ -916,21 +1038,23 @@ export default function Page() {
|
||||
loadFile: file.load,
|
||||
})
|
||||
|
||||
const changesOptions = ["session", "turn"] as const
|
||||
const changesOptionsList = [...changesOptions]
|
||||
|
||||
const changesTitle = () => {
|
||||
if (!hasReview()) {
|
||||
if (!canReview()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const label = (option: ChangeMode) => {
|
||||
if (option === "git") return language.t("ui.sessionReview.title.git")
|
||||
if (option === "branch") return language.t("ui.sessionReview.title.branch")
|
||||
if (option === "session") return language.t("ui.sessionReview.title")
|
||||
return language.t("ui.sessionReview.title.lastTurn")
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
options={changesOptionsList}
|
||||
options={changesOptions()}
|
||||
current={store.changes}
|
||||
label={(option) =>
|
||||
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
|
||||
}
|
||||
label={label}
|
||||
onSelect={(option) => option && setStore("changes", option)}
|
||||
variant="ghost"
|
||||
size="small"
|
||||
@@ -939,20 +1063,34 @@ export default function Page() {
|
||||
)
|
||||
}
|
||||
|
||||
const emptyTurn = () => (
|
||||
const empty = (text: string) => (
|
||||
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
|
||||
<div class="text-14-regular text-text-weak max-w-56">{text}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
|
||||
if (store.changes === "turn") return emptyTurn()
|
||||
const reviewEmptyText = createMemo(() => {
|
||||
if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
|
||||
if (store.changes === "branch") return language.t("session.review.noBranchChanges")
|
||||
if (store.changes === "turn") return language.t("session.review.noChanges")
|
||||
return language.t(sessionEmptyKey())
|
||||
})
|
||||
|
||||
if (hasReview() && !diffsReady()) {
|
||||
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
|
||||
if (store.changes === "git" || store.changes === "branch") {
|
||||
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
||||
return empty(reviewEmptyText())
|
||||
}
|
||||
|
||||
if (store.changes === "turn") {
|
||||
return empty(reviewEmptyText())
|
||||
}
|
||||
|
||||
if (hasSessionReview() && !diffsReady()) {
|
||||
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
||||
}
|
||||
|
||||
if (reviewEmptyKey() === "session.review.noVcs") {
|
||||
if (sessionEmptyKey() === "session.review.noVcs") {
|
||||
return (
|
||||
<div class={input.emptyClass}>
|
||||
<div class="flex flex-col gap-3">
|
||||
@@ -972,7 +1110,7 @@ export default function Page() {
|
||||
|
||||
return (
|
||||
<div class={input.emptyClass}>
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
||||
<div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1076,7 +1214,7 @@ export default function Page() {
|
||||
const pending = tree.pendingDiff
|
||||
if (!pending) return
|
||||
if (!tree.reviewScroll) return
|
||||
if (!diffsReady()) return
|
||||
if (!reviewReady()) return
|
||||
|
||||
const attempt = (count: number) => {
|
||||
if (tree.pendingDiff !== pending) return
|
||||
@@ -1808,6 +1946,12 @@ export default function Page() {
|
||||
</div>
|
||||
|
||||
<SessionSidePanel
|
||||
canReview={canReview}
|
||||
diffs={reviewDiffs}
|
||||
diffsReady={reviewReady}
|
||||
empty={reviewEmptyText}
|
||||
hasReview={hasReview}
|
||||
reviewCount={reviewCount}
|
||||
reviewPanel={reviewPanel}
|
||||
activeDiff={tree.activeDiff}
|
||||
focusReviewDiff={focusReviewDiff}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
import type { FileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
|
||||
@@ -19,7 +20,6 @@ import { useCommand } from "@/context/command"
|
||||
import { useFile, type SelectedLineRange } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
||||
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
|
||||
@@ -27,6 +27,12 @@ import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
canReview: () => boolean
|
||||
diffs: () => FileDiff[]
|
||||
diffsReady: () => boolean
|
||||
empty: () => string
|
||||
hasReview: () => boolean
|
||||
reviewCount: () => number
|
||||
reviewPanel: () => JSX.Element
|
||||
activeDiff?: string
|
||||
focusReviewDiff: (path: string) => void
|
||||
@@ -34,12 +40,11 @@ export function SessionSidePanel(props: {
|
||||
size: Sizing
|
||||
}) {
|
||||
const layout = useLayout()
|
||||
const sync = useSync()
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const dialog = useDialog()
|
||||
const { params, sessionKey, tabs, view } = useSessionLayout()
|
||||
const { sessionKey, tabs, view } = useSessionLayout()
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
|
||||
@@ -54,24 +59,7 @@ export function SessionSidePanel(props: {
|
||||
})
|
||||
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
|
||||
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||
const hasReview = createMemo(() => reviewCount() > 0)
|
||||
const diffsReady = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return true
|
||||
if (!hasReview()) return true
|
||||
return sync.data.session_diff[id] !== undefined
|
||||
})
|
||||
|
||||
const reviewEmptyKey = createMemo(() => {
|
||||
if (sync.project && !sync.project.vcs) return "session.review.noVcs"
|
||||
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
||||
return "session.review.noChanges"
|
||||
})
|
||||
|
||||
const diffFiles = createMemo(() => diffs().map((d) => d.file))
|
||||
const diffFiles = createMemo(() => props.diffs().map((d) => d.file))
|
||||
const kinds = createMemo(() => {
|
||||
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
||||
if (!a) return b
|
||||
@@ -82,7 +70,7 @@ export function SessionSidePanel(props: {
|
||||
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
|
||||
|
||||
const out = new Map<string, "add" | "del" | "mix">()
|
||||
for (const diff of diffs()) {
|
||||
for (const diff of props.diffs()) {
|
||||
const file = normalize(diff.file)
|
||||
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
|
||||
|
||||
@@ -136,7 +124,7 @@ export function SessionSidePanel(props: {
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab,
|
||||
review: reviewTab,
|
||||
hasReview,
|
||||
hasReview: props.canReview,
|
||||
})
|
||||
const contextOpen = tabState.contextOpen
|
||||
const openedTabs = tabState.openedTabs
|
||||
@@ -241,12 +229,12 @@ export function SessionSidePanel(props: {
|
||||
onCleanup(stop)
|
||||
}}
|
||||
>
|
||||
<Show when={reviewTab()}>
|
||||
<Show when={reviewTab() && props.canReview()}>
|
||||
<Tabs.Trigger value="review">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div>{language.t("session.tab.review")}</div>
|
||||
<Show when={hasReview()}>
|
||||
<div>{reviewCount()}</div>
|
||||
<Show when={props.hasReview()}>
|
||||
<div>{props.reviewCount()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
@@ -303,7 +291,7 @@ export function SessionSidePanel(props: {
|
||||
</Tabs.List>
|
||||
</div>
|
||||
|
||||
<Show when={reviewTab()}>
|
||||
<Show when={reviewTab() && props.canReview()}>
|
||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
|
||||
</Tabs.Content>
|
||||
@@ -377,8 +365,8 @@ export function SessionSidePanel(props: {
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{reviewCount()}{" "}
|
||||
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
|
||||
{props.reviewCount()}{" "}
|
||||
{language.t(props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{language.t("session.files.all")}
|
||||
@@ -386,9 +374,9 @@ export function SessionSidePanel(props: {
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={hasReview()}>
|
||||
<Match when={props.hasReview() || !props.diffsReady()}>
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
when={props.diffsReady()}
|
||||
fallback={
|
||||
<div class="px-2 py-2 text-12-regular text-text-weak">
|
||||
{language.t("common.loading")}
|
||||
@@ -408,9 +396,7 @@ export function SessionSidePanel(props: {
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
{empty(
|
||||
language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
|
||||
)}
|
||||
{empty(props.empty())}
|
||||
</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
|
||||
@@ -56,11 +56,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
if (!id) return
|
||||
return sync.session.get(id)
|
||||
}
|
||||
const hasReview = () => {
|
||||
const id = params.id
|
||||
if (!id) return false
|
||||
return Math.max(info()?.summary?.files ?? 0, (sync.data.session_diff[id] ?? []).length) > 0
|
||||
}
|
||||
const hasReview = () => !!params.id
|
||||
const normalizeTab = (tab: string) => {
|
||||
if (!tab.startsWith("file://")) return tab
|
||||
return file.tab(tab)
|
||||
|
||||
@@ -5,7 +5,12 @@ import { createResource, type Accessor } from "solid-js"
|
||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||
|
||||
type InitType = Promise<string> | string | null
|
||||
type PersistedWithReady<T> = [Store<T>, SetStoreFunction<T>, InitType, Accessor<boolean>]
|
||||
type PersistedWithReady<T> = [
|
||||
Store<T>,
|
||||
SetStoreFunction<T>,
|
||||
InitType,
|
||||
Accessor<boolean> & { promise: undefined | Promise<any> },
|
||||
]
|
||||
|
||||
type PersistTarget = {
|
||||
storage?: string
|
||||
@@ -460,5 +465,12 @@ export function persisted<T>(
|
||||
{ initialValue: !isAsync },
|
||||
)
|
||||
|
||||
return [state, setState, init, () => ready() === true]
|
||||
return [
|
||||
state,
|
||||
setState,
|
||||
init,
|
||||
Object.assign(() => ready() === true, {
|
||||
promise: init instanceof Promise ? init : undefined,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -191,6 +191,33 @@ export function IconGemini(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function IconXiaomi(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0C8.016 0 4.756.255 2.493 2.516.23 4.776 0 8.033 0 12.012c0 3.98.23 7.235 2.494 9.497C4.757 23.77 8.017 24 12 24c3.983 0 7.243-.23 9.506-2.491C23.77 19.247 24 15.99 24 12.012c0-3.984-.233-7.243-2.502-9.504C19.234.252 15.978 0 12 0zM4.906 7.405h5.624c1.47 0 3.007.068 3.764.827.746.746.827 2.233.83 3.676v4.54a.15.15 0 0 1-.152.147h-1.947a.15.15 0 0 1-.152-.148V11.83c-.002-.806-.048-1.634-.464-2.051-.358-.36-1.026-.441-1.72-.458H7.158a.15.15 0 0 0-.151.147v6.98a.15.15 0 0 1-.152.148H4.906a.15.15 0 0 1-.15-.148V7.554a.15.15 0 0 1 .15-.149zm12.131 0h1.949a.15.15 0 0 1 .15.15v8.892a.15.15 0 0 1-.15.148h-1.949a.15.15 0 0 1-.151-.148V7.554a.15.15 0 0 1 .151-.149zM8.92 10.948h2.046c.083 0 .15.066.15.147v5.352a.15.15 0 0 1-.15.148H8.92a.15.15 0 0 1-.152-.148v-5.352a.15.15 0 0 1 .152-.147Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconNvidia(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8.948 8.798v-1.43a6.7 6.7 0 0 1 .424-.018c3.922-.124 6.493 3.374 6.493 3.374s-2.774 3.851-5.75 3.851c-.398 0-.787-.062-1.158-.185v-4.346c1.528.185 1.837.857 2.747 2.385l2.04-1.714s-1.492-1.952-4-1.952a6.016 6.016 0 0 0-.796.035m0-4.735v2.138l.424-.027c5.45-.185 9.01 4.47 9.01 4.47s-4.08 4.964-8.33 4.964c-.37 0-.733-.035-1.095-.097v1.325c.3.035.61.062.91.062 3.957 0 6.82-2.023 9.593-4.408.459.371 2.34 1.263 2.73 1.652-2.633 2.208-8.772 3.984-12.253 3.984-.335 0-.653-.018-.971-.053v1.864H24V4.063zm0 10.326v1.131c-3.657-.654-4.673-4.46-4.673-4.46s1.758-1.944 4.673-2.262v1.237H8.94c-1.528-.186-2.73 1.245-2.73 1.245s.68 2.412 2.739 3.11M2.456 10.9s2.164-3.197 6.5-3.533V6.201C4.153 6.59 0 10.653 0 10.653s2.35 6.802 8.948 7.42v-1.237c-4.84-.6-6.492-5.936-6.492-5.936z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconArcee(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 37 32" fill="currentColor">
|
||||
<path d="M20.4062 3.66602L4.24121 31.5928H0L18.2881 0L20.4062 3.66602Z" />
|
||||
<path d="M25.8838 13.1553L11.0752 31.5928H6.36719L23.9131 9.74512L25.8838 13.1553Z" />
|
||||
<path d="M36.5352 31.5928H21.6611L34.6191 28.2783L36.5352 31.5928Z" />
|
||||
<path d="M31.2627 22.4648L19.1699 31.5898H13.0762L29.4131 19.2617L31.2627 22.4648Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconStealth(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 18" fill="none">
|
||||
|
||||
@@ -688,8 +688,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "جيف بيزوس",
|
||||
"enterprise.form.role.label": "المنصب",
|
||||
"enterprise.form.role.placeholder": "رئيس مجلس الإدارة التنفيذي",
|
||||
"enterprise.form.company.label": "الشركة",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "البريد الإلكتروني للشركة",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "رقم الهاتف",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "ما المشكلة التي تحاول حلها؟",
|
||||
"enterprise.form.message.placeholder": "نحتاج مساعدة في...",
|
||||
"enterprise.form.send": "إرسال",
|
||||
|
||||
@@ -700,8 +700,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "Jeff Bezos",
|
||||
"enterprise.form.role.label": "Cargo",
|
||||
"enterprise.form.role.placeholder": "Presidente Executivo",
|
||||
"enterprise.form.company.label": "Empresa",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "E-mail corporativo",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "Telefone",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "Qual problema você está tentando resolver?",
|
||||
"enterprise.form.message.placeholder": "Precisamos de ajuda com...",
|
||||
"enterprise.form.send": "Enviar",
|
||||
|
||||
@@ -694,8 +694,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "Jeff Bezos",
|
||||
"enterprise.form.role.label": "Rolle",
|
||||
"enterprise.form.role.placeholder": "Bestyrelsesformand",
|
||||
"enterprise.form.company.label": "Virksomhed",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "Firma-e-mail",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "Telefonnummer",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "Hvilket problem prøver du at løse?",
|
||||
"enterprise.form.message.placeholder": "Vi har brug for hjælp med...",
|
||||
"enterprise.form.send": "Send",
|
||||
|
||||
@@ -699,8 +699,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "Jeff Bezos",
|
||||
"enterprise.form.role.label": "Rolle",
|
||||
"enterprise.form.role.placeholder": "Executive Chairman",
|
||||
"enterprise.form.company.label": "Unternehmen",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "Firmen-E-Mail",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "Telefonnummer",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "Welches Problem versuchen Sie zu lösen?",
|
||||
"enterprise.form.message.placeholder": "Wir brauchen Hilfe bei...",
|
||||
"enterprise.form.send": "Senden",
|
||||
|
||||
@@ -689,8 +689,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "Jeff Bezos",
|
||||
"enterprise.form.role.label": "Role",
|
||||
"enterprise.form.role.placeholder": "Executive Chairman",
|
||||
"enterprise.form.company.label": "Company",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "Company email",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "Phone number",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "What problem are you trying to solve?",
|
||||
"enterprise.form.message.placeholder": "We need help with...",
|
||||
"enterprise.form.send": "Send",
|
||||
|
||||
@@ -699,8 +699,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "Jeff Bezos",
|
||||
"enterprise.form.role.label": "Rol",
|
||||
"enterprise.form.role.placeholder": "Presidente Ejecutivo",
|
||||
"enterprise.form.company.label": "Empresa",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "Correo de empresa",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "Teléfono",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "¿Qué problema estás intentando resolver?",
|
||||
"enterprise.form.message.placeholder": "Necesitamos ayuda con...",
|
||||
"enterprise.form.send": "Enviar",
|
||||
|
||||
@@ -706,8 +706,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "Jeff Bezos",
|
||||
"enterprise.form.role.label": "Poste",
|
||||
"enterprise.form.role.placeholder": "Président exécutif",
|
||||
"enterprise.form.company.label": "Entreprise",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "E-mail professionnel",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "Téléphone",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "Quel problème essayez-vous de résoudre ?",
|
||||
"enterprise.form.message.placeholder": "Nous avons besoin d'aide pour...",
|
||||
"enterprise.form.send": "Envoyer",
|
||||
|
||||
@@ -696,8 +696,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "Jeff Bezos",
|
||||
"enterprise.form.role.label": "Ruolo",
|
||||
"enterprise.form.role.placeholder": "Presidente Esecutivo",
|
||||
"enterprise.form.company.label": "Azienda",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "Email aziendale",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "Numero di telefono",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "Quale problema stai cercando di risolvere?",
|
||||
"enterprise.form.message.placeholder": "Abbiamo bisogno di aiuto con...",
|
||||
"enterprise.form.send": "Invia",
|
||||
|
||||
@@ -697,8 +697,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "ジェフ・ベゾス",
|
||||
"enterprise.form.role.label": "役職",
|
||||
"enterprise.form.role.placeholder": "会長",
|
||||
"enterprise.form.company.label": "会社名",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "会社メールアドレス",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "電話番号",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "どのような課題を解決したいですか?",
|
||||
"enterprise.form.message.placeholder": "これについて支援が必要です...",
|
||||
"enterprise.form.send": "送信",
|
||||
|
||||
@@ -688,8 +688,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "홍길동",
|
||||
"enterprise.form.role.label": "직책",
|
||||
"enterprise.form.role.placeholder": "CTO / 개발 팀장",
|
||||
"enterprise.form.company.label": "회사",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "회사 이메일",
|
||||
"enterprise.form.email.placeholder": "name@company.com",
|
||||
"enterprise.form.phone.label": "전화번호",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "어떤 문제를 해결하고 싶으신가요?",
|
||||
"enterprise.form.message.placeholder": "도움이 필요한 부분은...",
|
||||
"enterprise.form.send": "전송",
|
||||
|
||||
@@ -695,8 +695,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "Jeff Bezos",
|
||||
"enterprise.form.role.label": "Rolle",
|
||||
"enterprise.form.role.placeholder": "Styreleder",
|
||||
"enterprise.form.company.label": "Selskap",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "Bedrifts-e-post",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "Telefonnummer",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "Hvilket problem prøver dere å løse?",
|
||||
"enterprise.form.message.placeholder": "Vi trenger hjelp med...",
|
||||
"enterprise.form.send": "Send",
|
||||
|
||||
@@ -698,8 +698,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "Jeff Bezos",
|
||||
"enterprise.form.role.label": "Rola",
|
||||
"enterprise.form.role.placeholder": "Prezes Zarządu",
|
||||
"enterprise.form.company.label": "Firma",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "E-mail firmowy",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "Numer telefonu",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "Jaki problem próbujesz rozwiązać?",
|
||||
"enterprise.form.message.placeholder": "Potrzebujemy pomocy z...",
|
||||
"enterprise.form.send": "Wyślij",
|
||||
|
||||
@@ -703,8 +703,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "Джефф Безос",
|
||||
"enterprise.form.role.label": "Роль",
|
||||
"enterprise.form.role.placeholder": "Исполнительный председатель",
|
||||
"enterprise.form.company.label": "Компания",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "Корпоративная почта",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "Номер телефона",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "Какую проблему вы пытаетесь решить?",
|
||||
"enterprise.form.message.placeholder": "Нам нужна помощь с...",
|
||||
"enterprise.form.send": "Отправить",
|
||||
|
||||
@@ -691,8 +691,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "Jeff Bezos",
|
||||
"enterprise.form.role.label": "ตำแหน่ง",
|
||||
"enterprise.form.role.placeholder": "ประธานกรรมการบริหาร",
|
||||
"enterprise.form.company.label": "บริษัท",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "อีเมลบริษัท",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "หมายเลขโทรศัพท์",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "คุณกำลังพยายามแก้ปัญหาอะไร?",
|
||||
"enterprise.form.message.placeholder": "เราต้องการความช่วยเหลือเรื่อง...",
|
||||
"enterprise.form.send": "ส่ง",
|
||||
|
||||
@@ -700,8 +700,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "Jeff Bezos",
|
||||
"enterprise.form.role.label": "Rol",
|
||||
"enterprise.form.role.placeholder": "Yönetim Kurulu Başkanı",
|
||||
"enterprise.form.company.label": "Şirket",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "Şirket e-postası",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "Telefon numarası",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "Hangi problemi çözmeye çalışıyorsunuz?",
|
||||
"enterprise.form.message.placeholder": "Şu konuda yardıma ihtiyacımız var...",
|
||||
"enterprise.form.send": "Gönder",
|
||||
|
||||
@@ -669,8 +669,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "Jeff Bezos",
|
||||
"enterprise.form.role.label": "角色",
|
||||
"enterprise.form.role.placeholder": "执行主席",
|
||||
"enterprise.form.company.label": "公司",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "公司邮箱",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "电话号码",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "您想解决什么问题?",
|
||||
"enterprise.form.message.placeholder": "我们需要帮助...",
|
||||
"enterprise.form.send": "发送",
|
||||
|
||||
@@ -668,8 +668,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "傑夫·貝佐斯",
|
||||
"enterprise.form.role.label": "職稱",
|
||||
"enterprise.form.role.placeholder": "執行董事長",
|
||||
"enterprise.form.company.label": "公司",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "公司 Email",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "電話號碼",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "你想解決什麼問題?",
|
||||
"enterprise.form.message.placeholder": "我們需要幫助來...",
|
||||
"enterprise.form.send": "傳送",
|
||||
|
||||
81
packages/console/app/src/lib/salesforce.ts
Normal file
81
packages/console/app/src/lib/salesforce.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
|
||||
async function login() {
|
||||
const url = Resource.SALESFORCE_INSTANCE_URL.value.replace(/\/$/, "")
|
||||
const clientId = Resource.SALESFORCE_CLIENT_ID.value
|
||||
const clientSecret = Resource.SALESFORCE_CLIENT_SECRET.value
|
||||
|
||||
const params = new URLSearchParams({
|
||||
grant_type: "client_credentials",
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
})
|
||||
|
||||
const res = await fetch(`${url}/services/oauth2/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: params.toString(),
|
||||
}).catch((err) => {
|
||||
console.error("Failed to fetch Salesforce access token:", err)
|
||||
})
|
||||
|
||||
if (!res) return
|
||||
|
||||
if (!res.ok) {
|
||||
console.error("Failed to fetch Salesforce access token:", res.status, await res.text())
|
||||
return
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { access_token?: string; instance_url?: string }
|
||||
if (!data.access_token) {
|
||||
console.error("Salesforce auth response did not include an access token")
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
token: data.access_token,
|
||||
url: data.instance_url ?? url,
|
||||
}
|
||||
}
|
||||
|
||||
export interface SalesforceLeadInput {
|
||||
name: string
|
||||
role: string
|
||||
company?: string
|
||||
email: string
|
||||
phone?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export async function createLead(input: SalesforceLeadInput): Promise<boolean> {
|
||||
const auth = await login()
|
||||
if (!auth) return false
|
||||
|
||||
const res = await fetch(`${auth.url}/services/data/v59.0/sobjects/Lead`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
LastName: input.name,
|
||||
Company: input.company?.trim() || "Website",
|
||||
Email: input.email,
|
||||
Phone: input.phone ?? null,
|
||||
Title: input.role,
|
||||
Description: input.message,
|
||||
LeadSource: "Website",
|
||||
}),
|
||||
}).catch((err) => {
|
||||
console.error("Failed to create Salesforce lead:", err)
|
||||
})
|
||||
|
||||
if (!res) return false
|
||||
|
||||
if (!res.ok) {
|
||||
console.error("Failed to create Salesforce lead:", res.status, await res.text())
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -2,11 +2,15 @@ import type { APIEvent } from "@solidjs/start/server"
|
||||
import { AWS } from "@opencode-ai/console-core/aws.js"
|
||||
import { i18n } from "~/i18n"
|
||||
import { localeFromRequest } from "~/lib/language"
|
||||
import { createLead } from "~/lib/salesforce"
|
||||
|
||||
interface EnterpriseFormData {
|
||||
name: string
|
||||
role: string
|
||||
company?: string
|
||||
email: string
|
||||
phone?: string
|
||||
alias?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
@@ -14,33 +18,56 @@ export async function POST(event: APIEvent) {
|
||||
const dict = i18n(localeFromRequest(event.request))
|
||||
try {
|
||||
const body = (await event.request.json()) as EnterpriseFormData
|
||||
const trap = typeof body.alias === "string" ? body.alias.trim() : ""
|
||||
|
||||
if (trap) {
|
||||
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!body.name || !body.role || !body.email || !body.message) {
|
||||
return Response.json({ error: dict["enterprise.form.error.allFieldsRequired"] }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(body.email)) {
|
||||
return Response.json({ error: dict["enterprise.form.error.invalidEmailFormat"] }, { status: 400 })
|
||||
}
|
||||
|
||||
// Create email content
|
||||
const emailContent = `
|
||||
${body.message}<br><br>
|
||||
--<br>
|
||||
${body.name}<br>
|
||||
${body.role}<br>
|
||||
${body.email}`.trim()
|
||||
${body.company ? `${body.company}<br>` : ""}${body.email}<br>
|
||||
${body.phone ? `${body.phone}<br>` : ""}`.trim()
|
||||
|
||||
// Send email using AWS SES
|
||||
await AWS.sendEmail({
|
||||
to: "contact@anoma.ly",
|
||||
subject: `Enterprise Inquiry from ${body.name}`,
|
||||
body: emailContent,
|
||||
replyTo: body.email,
|
||||
})
|
||||
const [lead, mail] = await Promise.all([
|
||||
createLead({
|
||||
name: body.name,
|
||||
role: body.role,
|
||||
company: body.company,
|
||||
email: body.email,
|
||||
phone: body.phone,
|
||||
message: body.message,
|
||||
}),
|
||||
AWS.sendEmail({
|
||||
to: "contact@anoma.ly",
|
||||
subject: `Enterprise Inquiry from ${body.name}`,
|
||||
body: emailContent,
|
||||
replyTo: body.email,
|
||||
}).then(
|
||||
() => true,
|
||||
(err) => {
|
||||
console.error("Failed to send enterprise email:", err)
|
||||
return false
|
||||
},
|
||||
),
|
||||
])
|
||||
|
||||
if (!lead && !mail) {
|
||||
console.error("Enterprise inquiry delivery failed", { email: body.email })
|
||||
return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
|
||||
}
|
||||
|
||||
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
|
||||
} catch (error) {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
--color-text-strong: hsl(0, 5%, 12%);
|
||||
--color-text-inverted: hsl(0, 20%, 99%);
|
||||
--color-text-success: hsl(119, 100%, 35%);
|
||||
--color-text-error: hsl(4, 72%, 45%);
|
||||
|
||||
--color-border: hsl(30, 2%, 81%);
|
||||
--color-border-weak: hsl(0, 1%, 85%);
|
||||
@@ -50,6 +51,7 @@
|
||||
--color-text-strong: hsl(0, 15%, 94%);
|
||||
--color-text-inverted: hsl(0, 9%, 7%);
|
||||
--color-text-success: hsl(119, 60%, 72%);
|
||||
--color-text-error: hsl(4, 76%, 72%);
|
||||
|
||||
--color-border: hsl(0, 3%, 28%);
|
||||
--color-border-weak: hsl(0, 4%, 23%);
|
||||
@@ -454,6 +456,13 @@
|
||||
color: var(--color-text-success);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
[data-component="error-message"] {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem 0;
|
||||
color: var(--color-text-error);
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,11 +13,15 @@ export default function Enterprise() {
|
||||
const [formData, setFormData] = createSignal({
|
||||
name: "",
|
||||
role: "",
|
||||
company: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
alias: "",
|
||||
message: "",
|
||||
})
|
||||
const [isSubmitting, setIsSubmitting] = createSignal(false)
|
||||
const [showSuccess, setShowSuccess] = createSignal(false)
|
||||
const [error, setError] = createSignal("")
|
||||
|
||||
const handleInputChange = (field: string) => (e: Event) => {
|
||||
const target = e.target as HTMLInputElement | HTMLTextAreaElement
|
||||
@@ -26,6 +30,8 @@ export default function Enterprise() {
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
setShowSuccess(false)
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
@@ -42,13 +48,21 @@ export default function Enterprise() {
|
||||
setFormData({
|
||||
name: "",
|
||||
role: "",
|
||||
company: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
alias: "",
|
||||
message: "",
|
||||
})
|
||||
setTimeout(() => setShowSuccess(false), 5000)
|
||||
return
|
||||
}
|
||||
|
||||
const data = (await response.json().catch(() => null)) as { error?: string } | null
|
||||
setError(data?.error ?? i18n.t("enterprise.form.error.internalServer"))
|
||||
} catch (error) {
|
||||
console.error("Failed to submit form:", error)
|
||||
setError(i18n.t("enterprise.form.error.internalServer"))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
@@ -147,6 +161,19 @@ export default function Enterprise() {
|
||||
<div data-component="enterprise-column-2">
|
||||
<div data-component="enterprise-form">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div class="sr-only" aria-hidden="true">
|
||||
<input
|
||||
type="text"
|
||||
name="alias"
|
||||
tabIndex={-1}
|
||||
autocomplete="new-password"
|
||||
inputmode="none"
|
||||
spellcheck={false}
|
||||
value={formData().alias}
|
||||
onInput={handleInputChange("alias")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div data-component="form-group">
|
||||
<label for="name">{i18n.t("enterprise.form.name.label")}</label>
|
||||
<input
|
||||
@@ -171,6 +198,17 @@ export default function Enterprise() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div data-component="form-group">
|
||||
<label for="company">{i18n.t("enterprise.form.company.label")}</label>
|
||||
<input
|
||||
id="company"
|
||||
type="text"
|
||||
value={formData().company}
|
||||
onInput={handleInputChange("company")}
|
||||
placeholder={i18n.t("enterprise.form.company.placeholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div data-component="form-group">
|
||||
<label for="email">{i18n.t("enterprise.form.email.label")}</label>
|
||||
<input
|
||||
@@ -183,6 +221,17 @@ export default function Enterprise() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div data-component="form-group">
|
||||
<label for="phone">{i18n.t("enterprise.form.phone.label")}</label>
|
||||
<input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={formData().phone}
|
||||
onInput={handleInputChange("phone")}
|
||||
placeholder={i18n.t("enterprise.form.phone.placeholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div data-component="form-group">
|
||||
<label for="message">{i18n.t("enterprise.form.message.label")}</label>
|
||||
<textarea
|
||||
@@ -201,6 +250,7 @@ export default function Enterprise() {
|
||||
</form>
|
||||
|
||||
{showSuccess() && <div data-component="success-message">{i18n.t("enterprise.form.success")}</div>}
|
||||
{error() && <div data-component="error-message">{error()}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,12 +8,15 @@ import { querySessionInfo } from "../common"
|
||||
import {
|
||||
IconAlibaba,
|
||||
IconAnthropic,
|
||||
IconArcee,
|
||||
IconGemini,
|
||||
IconMiniMax,
|
||||
IconMoonshotAI,
|
||||
IconNvidia,
|
||||
IconOpenAI,
|
||||
IconStealth,
|
||||
IconXai,
|
||||
IconXiaomi,
|
||||
IconZai,
|
||||
} from "~/component/icon"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
@@ -29,6 +32,9 @@ const getModelLab = (modelId: string) => {
|
||||
if (modelId.startsWith("qwen")) return "Alibaba"
|
||||
if (modelId.startsWith("minimax")) return "MiniMax"
|
||||
if (modelId.startsWith("grok")) return "xAI"
|
||||
if (modelId.startsWith("mimo")) return "Xiaomi"
|
||||
if (modelId.startsWith("nemotron")) return "NVIDIA"
|
||||
if (modelId.startsWith("trinity")) return "Arcee"
|
||||
return "Stealth"
|
||||
}
|
||||
|
||||
@@ -139,6 +145,12 @@ export function ModelSection() {
|
||||
return <IconXai width={16} height={16} />
|
||||
case "MiniMax":
|
||||
return <IconMiniMax width={16} height={16} />
|
||||
case "Xiaomi":
|
||||
return <IconXiaomi width={16} height={16} />
|
||||
case "NVIDIA":
|
||||
return <IconNvidia width={16} height={16} />
|
||||
case "Arcee":
|
||||
return <IconArcee width={16} height={16} />
|
||||
default:
|
||||
return <IconStealth width={16} height={16} />
|
||||
}
|
||||
|
||||
@@ -74,8 +74,9 @@ export async function handler(
|
||||
const dict = i18n(localeFromRequest(input.request))
|
||||
const t = (key: Key, params?: Record<string, string | number>) => resolve(dict[key], params)
|
||||
const ADMIN_WORKSPACES = [
|
||||
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
|
||||
"wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench
|
||||
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // anomaly
|
||||
"wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // benchmark
|
||||
"wrk_01KKZDKDWCS1VTJF8QTX62DD50", // contributors
|
||||
]
|
||||
|
||||
try {
|
||||
@@ -97,8 +98,8 @@ export async function handler(
|
||||
const zenData = ZenData.list(opts.modelList)
|
||||
const modelInfo = validateModel(zenData, model)
|
||||
const dataDumper = createDataDumper(sessionId, requestId, projectId)
|
||||
const trialLimiter = createTrialLimiter(modelInfo.trialProvider, ip)
|
||||
const trialProvider = await trialLimiter?.check()
|
||||
const trialLimiter = createTrialLimiter(modelInfo.trialProviders, ip)
|
||||
const trialProviders = await trialLimiter?.check()
|
||||
const rateLimiter = createRateLimiter(
|
||||
modelInfo.id,
|
||||
modelInfo.allowAnonymous,
|
||||
@@ -120,7 +121,7 @@ export async function handler(
|
||||
authInfo,
|
||||
modelInfo,
|
||||
sessionId,
|
||||
trialProvider,
|
||||
trialProviders,
|
||||
retry,
|
||||
stickyProvider,
|
||||
)
|
||||
@@ -330,6 +331,7 @@ export async function handler(
|
||||
logger.metric({
|
||||
"error.type": error.constructor.name,
|
||||
"error.message": error.message,
|
||||
"error.cause": error.cause?.toString(),
|
||||
})
|
||||
|
||||
// Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.
|
||||
@@ -401,7 +403,7 @@ export async function handler(
|
||||
authInfo: AuthInfo,
|
||||
modelInfo: ModelInfo,
|
||||
sessionId: string,
|
||||
trialProvider: string | undefined,
|
||||
trialProviders: string[] | undefined,
|
||||
retry: RetryOptions,
|
||||
stickyProvider: string | undefined,
|
||||
) {
|
||||
@@ -410,15 +412,17 @@ export async function handler(
|
||||
return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider)
|
||||
}
|
||||
|
||||
if (trialProvider) {
|
||||
return modelInfo.providers.find((provider) => provider.id === trialProvider)
|
||||
}
|
||||
|
||||
if (stickyProvider) {
|
||||
const provider = modelInfo.providers.find((provider) => provider.id === stickyProvider)
|
||||
if (provider) return provider
|
||||
}
|
||||
|
||||
if (trialProviders) {
|
||||
const trialProvider = trialProviders[Math.floor(Math.random() * trialProviders.length)]
|
||||
const provider = modelInfo.providers.find((provider) => provider.id === trialProvider)
|
||||
if (provider) return provider
|
||||
}
|
||||
|
||||
if (retry.retryCount !== MAX_FAILOVER_RETRIES) {
|
||||
const providers = modelInfo.providers
|
||||
.filter((provider) => !provider.disabled)
|
||||
|
||||
@@ -175,7 +175,8 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
||||
outputTokens: usage.output_tokens ?? 0,
|
||||
reasoningTokens: undefined,
|
||||
cacheReadTokens: usage.cache_read_input_tokens ?? undefined,
|
||||
cacheWrite5mTokens: usage.cache_creation?.ephemeral_5m_input_tokens ?? undefined,
|
||||
cacheWrite5mTokens:
|
||||
usage.cache_creation?.ephemeral_5m_input_tokens ?? usage.cache_creation_input_tokens ?? undefined,
|
||||
cacheWrite1hTokens: usage.cache_creation?.ephemeral_1h_input_tokens ?? undefined,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { IpTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
||||
import { UsageInfo } from "./provider/provider"
|
||||
import { Subscription } from "@opencode-ai/console-core/subscription.js"
|
||||
|
||||
export function createTrialLimiter(trialProvider: string | undefined, ip: string) {
|
||||
if (!trialProvider) return
|
||||
export function createTrialLimiter(trialProviders: string[] | undefined, ip: string) {
|
||||
if (!trialProviders) return
|
||||
if (!ip) return
|
||||
|
||||
const limit = Subscription.getFreeLimits().promoTokens
|
||||
@@ -24,7 +24,7 @@ export function createTrialLimiter(trialProvider: string | undefined, ip: string
|
||||
)
|
||||
|
||||
_isTrial = (data?.usage ?? 0) < limit
|
||||
return _isTrial ? trialProvider : undefined
|
||||
return _isTrial ? trialProviders : undefined
|
||||
},
|
||||
track: async (usageInfo: UsageInfo) => {
|
||||
if (!_isTrial) return
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AuthTable } from "../src/schema/auth.sql.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable, BlackPlans, UsageTable } from "../src/schema/billing.sql.js"
|
||||
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
|
||||
import { KeyTable } from "../src/schema/key.sql.js"
|
||||
import { BlackData } from "../src/black.js"
|
||||
import { centsToMicroCents } from "../src/util/price.js"
|
||||
import { getWeekBounds } from "../src/util/date.js"
|
||||
@@ -10,13 +11,46 @@ import { getWeekBounds } from "../src/util/date.js"
|
||||
// get input from command line
|
||||
const identifier = process.argv[2]
|
||||
if (!identifier) {
|
||||
console.error("Usage: bun lookup-user.ts <email|workspaceID>")
|
||||
console.error("Usage: bun lookup-user.ts <email|workspaceID|apiKey>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// loop up by workspace ID
|
||||
if (identifier.startsWith("wrk_")) {
|
||||
await printWorkspace(identifier)
|
||||
} else {
|
||||
}
|
||||
// lookup by API key ID
|
||||
else if (identifier.startsWith("key_")) {
|
||||
const key = await Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(KeyTable)
|
||||
.where(eq(KeyTable.id, identifier))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (!key) {
|
||||
console.error("API key not found")
|
||||
process.exit(1)
|
||||
}
|
||||
await printWorkspace(key.workspaceID)
|
||||
}
|
||||
// lookup by API key value
|
||||
else if (identifier.startsWith("sk-")) {
|
||||
const key = await Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(KeyTable)
|
||||
.where(eq(KeyTable.key, identifier))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (!key) {
|
||||
console.error("API key not found")
|
||||
process.exit(1)
|
||||
}
|
||||
await printWorkspace(key.workspaceID)
|
||||
}
|
||||
// lookup by email
|
||||
else {
|
||||
const authData = await Database.use(async (tx) =>
|
||||
tx.select().from(AuthTable).where(eq(AuthTable.subject, identifier)),
|
||||
)
|
||||
|
||||
@@ -26,7 +26,7 @@ export namespace ZenData {
|
||||
allowAnonymous: z.boolean().optional(),
|
||||
byokProvider: z.enum(["openai", "anthropic", "google"]).optional(),
|
||||
stickyProvider: z.enum(["strict", "prefer"]).optional(),
|
||||
trialProvider: z.string().optional(),
|
||||
trialProviders: z.array(z.string()).optional(),
|
||||
fallbackProvider: z.string().optional(),
|
||||
rateLimit: z.number().optional(),
|
||||
providers: z.array(
|
||||
|
||||
12
packages/console/core/sst-env.d.ts
vendored
12
packages/console/core/sst-env.d.ts
vendored
@@ -95,6 +95,18 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_INSTANCE_URL": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_PUBLISHABLE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
12
packages/console/function/sst-env.d.ts
vendored
12
packages/console/function/sst-env.d.ts
vendored
@@ -95,6 +95,18 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_INSTANCE_URL": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_PUBLISHABLE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
12
packages/console/resource/sst-env.d.ts
vendored
12
packages/console/resource/sst-env.d.ts
vendored
@@ -95,6 +95,18 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_INSTANCE_URL": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_PUBLISHABLE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
12
packages/enterprise/sst-env.d.ts
vendored
12
packages/enterprise/sst-env.d.ts
vendored
@@ -95,6 +95,18 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_INSTANCE_URL": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_PUBLISHABLE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
12
packages/function/sst-env.d.ts
vendored
12
packages/function/sst-env.d.ts
vendored
@@ -95,6 +95,18 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_INSTANCE_URL": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_PUBLISHABLE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -11,46 +11,52 @@ const seed = async () => {
|
||||
const { Instance } = await import("../src/project/instance")
|
||||
const { InstanceBootstrap } = await import("../src/project/bootstrap")
|
||||
const { Config } = await import("../src/config/config")
|
||||
const { disposeRuntime } = await import("../src/effect/runtime")
|
||||
const { Session } = await import("../src/session")
|
||||
const { MessageID, PartID } = await import("../src/session/schema")
|
||||
const { Project } = await import("../src/project/project")
|
||||
const { ModelID, ProviderID } = await import("../src/provider/schema")
|
||||
const { ToolRegistry } = await import("../src/tool/registry")
|
||||
|
||||
await Instance.provide({
|
||||
directory: dir,
|
||||
init: InstanceBootstrap,
|
||||
fn: async () => {
|
||||
await Config.waitForDependencies()
|
||||
await ToolRegistry.ids()
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: dir,
|
||||
init: InstanceBootstrap,
|
||||
fn: async () => {
|
||||
await Config.waitForDependencies()
|
||||
await ToolRegistry.ids()
|
||||
|
||||
const session = await Session.create({ title })
|
||||
const messageID = MessageID.ascending()
|
||||
const partID = PartID.ascending()
|
||||
const message = {
|
||||
id: messageID,
|
||||
sessionID: session.id,
|
||||
role: "user" as const,
|
||||
time: { created: now },
|
||||
agent: "build",
|
||||
model: {
|
||||
providerID: ProviderID.make(providerID),
|
||||
modelID: ModelID.make(modelID),
|
||||
},
|
||||
}
|
||||
const part = {
|
||||
id: partID,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
type: "text" as const,
|
||||
text,
|
||||
time: { start: now },
|
||||
}
|
||||
await Session.updateMessage(message)
|
||||
await Session.updatePart(part)
|
||||
await Project.update({ projectID: Instance.project.id, name: "E2E Project" })
|
||||
},
|
||||
})
|
||||
const session = await Session.create({ title })
|
||||
const messageID = MessageID.ascending()
|
||||
const partID = PartID.ascending()
|
||||
const message = {
|
||||
id: messageID,
|
||||
sessionID: session.id,
|
||||
role: "user" as const,
|
||||
time: { created: now },
|
||||
agent: "build",
|
||||
model: {
|
||||
providerID: ProviderID.make(providerID),
|
||||
modelID: ModelID.make(modelID),
|
||||
},
|
||||
}
|
||||
const part = {
|
||||
id: partID,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
type: "text" as const,
|
||||
text,
|
||||
time: { start: now },
|
||||
}
|
||||
await Session.updateMessage(message)
|
||||
await Session.updatePart(part)
|
||||
await Project.update({ projectID: Instance.project.id, name: "E2E Project" })
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
await Instance.disposeAll().catch(() => {})
|
||||
await disposeRuntime().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
await seed()
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { generateObject, streamObject, type ModelMessage } from "ai"
|
||||
import { SystemPrompt } from "../session/system"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Truncate } from "../tool/truncation"
|
||||
import { Truncate } from "../tool/truncate"
|
||||
import { Auth } from "../auth"
|
||||
import { ProviderTransform } from "../provider/transform"
|
||||
|
||||
@@ -14,7 +14,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
||||
import PROMPT_EXPLORE from "./prompt/explore.txt"
|
||||
import PROMPT_SUMMARY from "./prompt/summary.txt"
|
||||
import PROMPT_TITLE from "./prompt/title.txt"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { mergeDeep, pipe, sortBy, values } from "remeda"
|
||||
import { Global } from "@/global"
|
||||
import path from "path"
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { MessageV2 } from "../../../session/message-v2"
|
||||
import { MessageID, PartID } from "../../../session/schema"
|
||||
import { ToolRegistry } from "../../../tool/registry"
|
||||
import { Instance } from "../../../project/instance"
|
||||
import { PermissionNext } from "../../../permission/next"
|
||||
import { PermissionNext } from "../../../permission"
|
||||
import { iife } from "../../../util/iife"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
@@ -11,7 +11,7 @@ import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart
|
||||
import { Server } from "../../server/server"
|
||||
import { Provider } from "../../provider/provider"
|
||||
import { Agent } from "../../agent/agent"
|
||||
import { PermissionNext } from "../../permission/next"
|
||||
import { PermissionNext } from "../../permission"
|
||||
import { Tool } from "../../tool/tool"
|
||||
import { GlobTool } from "../../tool/glob"
|
||||
import { GrepTool } from "../../tool/grep"
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Project } from "@/project/project"
|
||||
export declare namespace InstanceContext {
|
||||
export interface Shape {
|
||||
readonly directory: string
|
||||
readonly worktree: string
|
||||
readonly project: Project.Info
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
|
||||
import { registerDisposer } from "./instance-registry"
|
||||
import { InstanceContext } from "./instance-context"
|
||||
import { FileService } from "@/file"
|
||||
import { FileTimeService } from "@/file/time"
|
||||
import { FileWatcherService } from "@/file/watcher"
|
||||
import { FormatService } from "@/format"
|
||||
import { PermissionEffect } from "@/permission/service"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { VcsService } from "@/project/vcs"
|
||||
import { ProviderAuthService } from "@/provider/auth-service"
|
||||
import { QuestionService } from "@/question/service"
|
||||
import { PermissionService } from "@/permission/service"
|
||||
import { FileWatcherService } from "@/file/watcher"
|
||||
import { VcsService } from "@/project/vcs"
|
||||
import { FileTimeService } from "@/file/time"
|
||||
import { FormatService } from "@/format"
|
||||
import { FileService } from "@/file"
|
||||
import { SkillService } from "@/skill/skill"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { SnapshotService } from "@/snapshot"
|
||||
import { InstanceContext } from "./instance-context"
|
||||
import { registerDisposer } from "./instance-registry"
|
||||
|
||||
export { InstanceContext } from "./instance-context"
|
||||
|
||||
export type InstanceServices =
|
||||
| QuestionService
|
||||
| PermissionService
|
||||
| PermissionEffect.Service
|
||||
| ProviderAuthService
|
||||
| FileWatcherService
|
||||
| VcsService
|
||||
@@ -24,13 +25,19 @@ export type InstanceServices =
|
||||
| FormatService
|
||||
| FileService
|
||||
| SkillService
|
||||
| SnapshotService
|
||||
|
||||
function lookup(directory: string) {
|
||||
const project = Instance.project
|
||||
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of({ directory, project }))
|
||||
// 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(QuestionService.layer),
|
||||
Layer.fresh(PermissionService.layer),
|
||||
Layer.fresh(PermissionEffect.layer),
|
||||
Layer.fresh(ProviderAuthService.layer),
|
||||
Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie),
|
||||
Layer.fresh(VcsService.layer),
|
||||
@@ -38,6 +45,7 @@ function lookup(directory: string) {
|
||||
Layer.fresh(FormatService.layer),
|
||||
Layer.fresh(FileService.layer),
|
||||
Layer.fresh(SkillService.layer),
|
||||
Layer.fresh(SnapshotService.layer),
|
||||
).pipe(Layer.provide(ctx))
|
||||
}
|
||||
|
||||
@@ -47,7 +55,9 @@ export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<s
|
||||
static readonly layer = Layer.effect(
|
||||
Instances,
|
||||
Effect.gen(function* () {
|
||||
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
|
||||
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)
|
||||
@@ -57,8 +67,4 @@ export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<s
|
||||
static get(directory: string): Layer.Layer<InstanceServices, never, Instances> {
|
||||
return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory))))
|
||||
}
|
||||
|
||||
static invalidate(directory: string): Effect.Effect<void, never, Instances> {
|
||||
return Instances.use((map) => map.invalidate(directory))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,21 @@ import { AccountService } from "@/account/service"
|
||||
import { AuthService } from "@/auth/service"
|
||||
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(AccountService.defaultLayer, Instances.layer).pipe(Layer.provideMerge(AuthService.defaultLayer)),
|
||||
Layer.mergeAll(
|
||||
AccountService.defaultLayer, //
|
||||
TruncateEffect.defaultLayer,
|
||||
Instances.layer,
|
||||
).pipe(Layer.provideMerge(AuthService.defaultLayer)),
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Config } from "@/config/config"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Wildcard } from "@/util/wildcard"
|
||||
import os from "os"
|
||||
import * as S from "./service"
|
||||
import { PermissionEffect as S } from "./service"
|
||||
|
||||
export namespace PermissionNext {
|
||||
function expand(pattern: string): string {
|
||||
@@ -26,7 +26,7 @@ export namespace PermissionNext {
|
||||
export type Reply = S.Reply
|
||||
export const Approval = S.Approval
|
||||
export const Event = S.Event
|
||||
export const Service = S.PermissionService
|
||||
export const Service = S.Service
|
||||
export const RejectedError = S.RejectedError
|
||||
export const CorrectedError = S.CorrectedError
|
||||
export const DeniedError = S.DeniedError
|
||||
@@ -53,23 +53,21 @@ export namespace PermissionNext {
|
||||
return rulesets.flat()
|
||||
}
|
||||
|
||||
export const ask = fn(S.AskInput, async (input) =>
|
||||
runPromiseInstance(S.PermissionService.use((service) => service.ask(input))),
|
||||
)
|
||||
export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((service) => service.ask(input))))
|
||||
|
||||
export const reply = fn(S.ReplyInput, async (input) =>
|
||||
runPromiseInstance(S.PermissionService.use((service) => service.reply(input))),
|
||||
runPromiseInstance(S.Service.use((service) => service.reply(input))),
|
||||
)
|
||||
|
||||
export async function list() {
|
||||
return runPromiseInstance(S.PermissionService.use((service) => service.list()))
|
||||
return runPromiseInstance(S.Service.use((service) => service.list()))
|
||||
}
|
||||
|
||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||
return S.evaluate(permission, pattern, ...rulesets)
|
||||
}
|
||||
|
||||
const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"]
|
||||
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
|
||||
|
||||
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
|
||||
const result = new Set<string>()
|
||||
@@ -11,121 +11,128 @@ import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||
import z from "zod"
|
||||
import { PermissionID } from "./schema"
|
||||
|
||||
const log = Log.create({ service: "permission" })
|
||||
export namespace PermissionEffect {
|
||||
const log = Log.create({ service: "permission" })
|
||||
|
||||
export const Action = z.enum(["allow", "deny", "ask"]).meta({
|
||||
ref: "PermissionAction",
|
||||
})
|
||||
export type Action = z.infer<typeof Action>
|
||||
|
||||
export const Rule = z
|
||||
.object({
|
||||
permission: z.string(),
|
||||
pattern: z.string(),
|
||||
action: Action,
|
||||
export const Action = z.enum(["allow", "deny", "ask"]).meta({
|
||||
ref: "PermissionAction",
|
||||
})
|
||||
.meta({
|
||||
ref: "PermissionRule",
|
||||
export type Action = z.infer<typeof Action>
|
||||
|
||||
export const Rule = z
|
||||
.object({
|
||||
permission: z.string(),
|
||||
pattern: z.string(),
|
||||
action: Action,
|
||||
})
|
||||
.meta({
|
||||
ref: "PermissionRule",
|
||||
})
|
||||
export type Rule = z.infer<typeof Rule>
|
||||
|
||||
export const Ruleset = Rule.array().meta({
|
||||
ref: "PermissionRuleset",
|
||||
})
|
||||
export type Rule = z.infer<typeof Rule>
|
||||
export type Ruleset = z.infer<typeof Ruleset>
|
||||
|
||||
export const Ruleset = Rule.array().meta({
|
||||
ref: "PermissionRuleset",
|
||||
})
|
||||
export type Ruleset = z.infer<typeof Ruleset>
|
||||
|
||||
export const Request = z
|
||||
.object({
|
||||
id: PermissionID.zod,
|
||||
sessionID: SessionID.zod,
|
||||
permission: z.string(),
|
||||
patterns: z.string().array(),
|
||||
metadata: z.record(z.string(), z.any()),
|
||||
always: z.string().array(),
|
||||
tool: z
|
||||
.object({
|
||||
messageID: MessageID.zod,
|
||||
callID: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "PermissionRequest",
|
||||
})
|
||||
export type Request = z.infer<typeof Request>
|
||||
|
||||
export const Reply = z.enum(["once", "always", "reject"])
|
||||
export type Reply = z.infer<typeof Reply>
|
||||
|
||||
export const Approval = z.object({
|
||||
projectID: ProjectID.zod,
|
||||
patterns: z.string().array(),
|
||||
})
|
||||
|
||||
export const Event = {
|
||||
Asked: BusEvent.define("permission.asked", Request),
|
||||
Replied: BusEvent.define(
|
||||
"permission.replied",
|
||||
z.object({
|
||||
export const Request = z
|
||||
.object({
|
||||
id: PermissionID.zod,
|
||||
sessionID: SessionID.zod,
|
||||
requestID: PermissionID.zod,
|
||||
reply: Reply,
|
||||
}),
|
||||
),
|
||||
}
|
||||
permission: z.string(),
|
||||
patterns: z.string().array(),
|
||||
metadata: z.record(z.string(), z.any()),
|
||||
always: z.string().array(),
|
||||
tool: z
|
||||
.object({
|
||||
messageID: MessageID.zod,
|
||||
callID: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "PermissionRequest",
|
||||
})
|
||||
export type Request = z.infer<typeof Request>
|
||||
|
||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
|
||||
override get message() {
|
||||
return "The user rejected permission to use this specific tool call."
|
||||
export const Reply = z.enum(["once", "always", "reject"])
|
||||
export type Reply = z.infer<typeof Reply>
|
||||
|
||||
export const Approval = z.object({
|
||||
projectID: ProjectID.zod,
|
||||
patterns: z.string().array(),
|
||||
})
|
||||
|
||||
export const Event = {
|
||||
Asked: BusEvent.define("permission.asked", Request),
|
||||
Replied: BusEvent.define(
|
||||
"permission.replied",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
requestID: PermissionID.zod,
|
||||
reply: Reply,
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
|
||||
feedback: Schema.String,
|
||||
}) {
|
||||
override get message() {
|
||||
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
|
||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
|
||||
override get message() {
|
||||
return "The user rejected permission to use this specific tool call."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
|
||||
ruleset: Schema.Any,
|
||||
}) {
|
||||
override get message() {
|
||||
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
|
||||
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
|
||||
feedback: Schema.String,
|
||||
}) {
|
||||
override get message() {
|
||||
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type PermissionError = DeniedError | RejectedError | CorrectedError
|
||||
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
|
||||
ruleset: Schema.Any,
|
||||
}) {
|
||||
override get message() {
|
||||
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
|
||||
}
|
||||
}
|
||||
|
||||
interface PendingEntry {
|
||||
info: Request
|
||||
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
||||
}
|
||||
export type Error = DeniedError | RejectedError | CorrectedError
|
||||
|
||||
export const AskInput = Request.partial({ id: true }).extend({
|
||||
ruleset: Ruleset,
|
||||
})
|
||||
export const AskInput = Request.partial({ id: true }).extend({
|
||||
ruleset: Ruleset,
|
||||
})
|
||||
|
||||
export const ReplyInput = z.object({
|
||||
requestID: PermissionID.zod,
|
||||
reply: Reply,
|
||||
message: z.string().optional(),
|
||||
})
|
||||
export const ReplyInput = z.object({
|
||||
requestID: PermissionID.zod,
|
||||
reply: Reply,
|
||||
message: z.string().optional(),
|
||||
})
|
||||
|
||||
export declare namespace PermissionService {
|
||||
export interface Api {
|
||||
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, PermissionError>
|
||||
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
|
||||
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Request[]>
|
||||
}
|
||||
}
|
||||
|
||||
export class PermissionService extends ServiceMap.Service<PermissionService, PermissionService.Api>()(
|
||||
"@opencode/PermissionNext",
|
||||
) {
|
||||
static readonly layer = Layer.effect(
|
||||
PermissionService,
|
||||
interface PendingEntry {
|
||||
info: Request
|
||||
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
||||
}
|
||||
|
||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||
const rules = rulesets.flat()
|
||||
log.info("evaluate", { permission, pattern, ruleset: rules })
|
||||
const match = rules.findLast(
|
||||
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
|
||||
)
|
||||
return match ?? { action: "ask", permission, pattern: "*" }
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Api>()("@opencode/PermissionNext") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const { project } = yield* InstanceContext
|
||||
const row = Database.use((db) =>
|
||||
@@ -225,27 +232,13 @@ export class PermissionService extends ServiceMap.Service<PermissionService, Per
|
||||
})
|
||||
yield* Deferred.succeed(item.deferred, undefined)
|
||||
}
|
||||
|
||||
// TODO: we don't save the permission ruleset to disk yet until there's
|
||||
// UI to manage it
|
||||
// db().insert(PermissionTable).values({ projectID: Instance.project.id, data: s.approved })
|
||||
// .onConflictDoUpdate({ target: PermissionTable.projectID, set: { data: s.approved } }).run()
|
||||
})
|
||||
|
||||
const list = Effect.fn("PermissionService.list")(function* () {
|
||||
return Array.from(pending.values(), (item) => item.info)
|
||||
})
|
||||
|
||||
return PermissionService.of({ ask, reply, list })
|
||||
return Service.of({ ask, reply, list })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||
const merged = rulesets.flat()
|
||||
log.info("evaluate", { permission, pattern, ruleset: merged })
|
||||
const match = merged.findLast(
|
||||
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
|
||||
)
|
||||
return match ?? { action: "ask", permission, pattern: "*" }
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ import { Instance } from "./instance"
|
||||
import { VcsService } from "./vcs"
|
||||
import { Log } from "@/util/log"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import { Truncate } from "../tool/truncation"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
|
||||
export async function InstanceBootstrap() {
|
||||
@@ -23,8 +21,6 @@ export async function InstanceBootstrap() {
|
||||
await runPromiseInstance(FileWatcherService.use((service) => service.init()))
|
||||
File.init()
|
||||
await runPromiseInstance(VcsService.use((s) => s.init()))
|
||||
Snapshot.init()
|
||||
Truncate.init()
|
||||
|
||||
Bus.subscribe(Command.Event.Executed, async (payload) => {
|
||||
if (payload.properties.name === Command.Default.INIT) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { disposeInstance } from "@/effect/instance-registry"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Log } from "@/util/log"
|
||||
import { Context } from "../util/context"
|
||||
import { Project } from "./project"
|
||||
import { State } from "./state"
|
||||
import { iife } from "@/util/iife"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { disposeInstance } from "@/effect/instance-registry"
|
||||
|
||||
interface Context {
|
||||
directory: string
|
||||
@@ -80,6 +80,9 @@ export const Instance = {
|
||||
return input.fn()
|
||||
})
|
||||
},
|
||||
get current() {
|
||||
return context.use()
|
||||
},
|
||||
get directory() {
|
||||
return context.use().directory
|
||||
},
|
||||
|
||||
@@ -6,11 +6,278 @@ import { Instance } from "./instance"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { git } from "@/util/git"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import path from "path"
|
||||
|
||||
const log = Log.create({ service: "vcs" })
|
||||
const cfg = [
|
||||
"--no-optional-locks",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
] as const
|
||||
|
||||
type Base = { name: string; ref: string }
|
||||
type Item = { file: string; code: string; status: Snapshot.FileDiff["status"] }
|
||||
|
||||
async function mapLimit<T, R>(list: T[], limit: number, fn: (item: T) => Promise<R>) {
|
||||
const size = Math.max(1, limit)
|
||||
const out: R[] = new Array(list.length)
|
||||
let idx = 0
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(size, list.length) }, async () => {
|
||||
while (true) {
|
||||
const i = idx
|
||||
idx += 1
|
||||
if (i >= list.length) return
|
||||
out[i] = await fn(list[i]!)
|
||||
}
|
||||
}),
|
||||
)
|
||||
return out
|
||||
}
|
||||
|
||||
function out(result: { text(): string }) {
|
||||
return result.text().trim()
|
||||
}
|
||||
|
||||
async function run(cwd: string, args: string[]) {
|
||||
return git([...cfg, ...args], { cwd })
|
||||
}
|
||||
|
||||
async function branch(cwd: string) {
|
||||
const result = await run(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
if (result.exitCode !== 0) return
|
||||
const text = out(result)
|
||||
return text || undefined
|
||||
}
|
||||
|
||||
async function prefix(cwd: string) {
|
||||
const result = await run(cwd, ["rev-parse", "--show-prefix"])
|
||||
if (result.exitCode !== 0) return ""
|
||||
return out(result)
|
||||
}
|
||||
|
||||
async function branches(cwd: string) {
|
||||
const result = await run(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads"])
|
||||
if (result.exitCode !== 0) return []
|
||||
return out(result)
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
async function configured(cwd: string, list: string[]) {
|
||||
const result = await run(cwd, ["config", "init.defaultBranch"])
|
||||
if (result.exitCode !== 0) return
|
||||
const name = out(result)
|
||||
if (!name || !list.includes(name)) return
|
||||
const ref = await run(cwd, ["rev-parse", "--verify", name])
|
||||
if (ref.exitCode !== 0) return
|
||||
return { name, ref: name } satisfies Base
|
||||
}
|
||||
|
||||
async function remoteHead(cwd: string, remote: string) {
|
||||
const result = await run(cwd, ["ls-remote", "--symref", remote, "HEAD"])
|
||||
if (result.exitCode !== 0) return
|
||||
for (const line of result.text().split("\n")) {
|
||||
const match = /^ref: refs\/heads\/(.+)\tHEAD$/.exec(line.trim())
|
||||
if (!match?.[1]) continue
|
||||
return { name: match[1], ref: `${remote}/${match[1]}` } satisfies Base
|
||||
}
|
||||
}
|
||||
|
||||
async function primary(cwd: string) {
|
||||
const result = await run(cwd, ["remote"])
|
||||
const list =
|
||||
result.exitCode !== 0
|
||||
? []
|
||||
: out(result)
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
if (list.includes("origin")) return "origin"
|
||||
if (list.length === 1) return list[0]
|
||||
if (list.includes("upstream")) return "upstream"
|
||||
return list[0]
|
||||
}
|
||||
|
||||
async function base(cwd: string) {
|
||||
const remote = await primary(cwd)
|
||||
if (remote) {
|
||||
const head = await run(cwd, ["symbolic-ref", `refs/remotes/${remote}/HEAD`])
|
||||
if (head.exitCode === 0) {
|
||||
const ref = out(head).replace(/^refs\/remotes\//, "")
|
||||
const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : ""
|
||||
if (name) return { name, ref } satisfies Base
|
||||
}
|
||||
|
||||
const next = await remoteHead(cwd, remote)
|
||||
if (next) return next
|
||||
}
|
||||
|
||||
const list = await branches(cwd)
|
||||
const next = await configured(cwd, list)
|
||||
if (next) return next
|
||||
for (const name of ["main", "master"]) {
|
||||
if (list.includes(name)) return { name, ref: name }
|
||||
}
|
||||
}
|
||||
|
||||
async function head(cwd: string) {
|
||||
const result = await run(cwd, ["rev-parse", "--verify", "HEAD"])
|
||||
return result.exitCode === 0
|
||||
}
|
||||
|
||||
async function work(cwd: string, file: string) {
|
||||
const full = path.join(cwd, file)
|
||||
if (!(await Filesystem.exists(full))) return ""
|
||||
const buf = await Filesystem.readBytes(full).catch(() => Buffer.alloc(0))
|
||||
if (buf.includes(0)) return ""
|
||||
return buf.toString("utf8")
|
||||
}
|
||||
|
||||
async function show(cwd: string, ref: string | undefined, file: string, base: string) {
|
||||
if (!ref) return ""
|
||||
const target = base ? `${base}${file}` : file
|
||||
const result = await run(cwd, ["show", `${ref}:${target}`])
|
||||
if (result.exitCode !== 0) return ""
|
||||
return result.text()
|
||||
}
|
||||
|
||||
function kind(code: string | undefined): "added" | "deleted" | "modified" {
|
||||
if (code === "??") return "added"
|
||||
if (code?.includes("U")) return "modified"
|
||||
if (code?.includes("A") && !code.includes("D")) return "added"
|
||||
if (code?.includes("D") && !code.includes("A")) return "deleted"
|
||||
return "modified"
|
||||
}
|
||||
|
||||
function count(text: string) {
|
||||
if (!text) return 0
|
||||
if (!text.endsWith("\n")) return text.split("\n").length
|
||||
return text.slice(0, -1).split("\n").length
|
||||
}
|
||||
|
||||
function split(text: string) {
|
||||
return text.split("\0").filter(Boolean)
|
||||
}
|
||||
|
||||
function parseStatus(text: string) {
|
||||
return split(text).flatMap((item) => {
|
||||
const file = item.slice(3)
|
||||
if (!file) return []
|
||||
const code = item.slice(0, 2)
|
||||
return [{ file, code, status: kind(code) } satisfies Item]
|
||||
})
|
||||
}
|
||||
|
||||
function parseNames(text: string) {
|
||||
const list = split(text)
|
||||
const out: Item[] = []
|
||||
for (let i = 0; i < list.length; i += 2) {
|
||||
const code = list[i]
|
||||
const file = list[i + 1]
|
||||
if (!code || !file) continue
|
||||
out.push({ file, code, status: kind(code) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function parseNums(text: string) {
|
||||
const out = new Map<string, { additions: number; deletions: number }>()
|
||||
for (const item of split(text)) {
|
||||
const a = item.indexOf("\t")
|
||||
const b = item.indexOf("\t", a + 1)
|
||||
if (a === -1 || b === -1) continue
|
||||
const file = item.slice(b + 1)
|
||||
if (!file) continue
|
||||
const adds = item.slice(0, a)
|
||||
const dels = item.slice(a + 1, b)
|
||||
const additions = adds === "-" ? 0 : Number.parseInt(adds || "0", 10)
|
||||
const deletions = dels === "-" ? 0 : Number.parseInt(dels || "0", 10)
|
||||
out.set(file, {
|
||||
additions: Number.isFinite(additions) ? additions : 0,
|
||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function merge(...lists: Item[][]) {
|
||||
const out = new Map<string, Item>()
|
||||
for (const list of lists) {
|
||||
for (const item of list) {
|
||||
if (!out.has(item.file)) out.set(item.file, item)
|
||||
}
|
||||
}
|
||||
return [...out.values()]
|
||||
}
|
||||
|
||||
async function files(cwd: string, ref: string | undefined, list: Item[], nums: Map<string, { additions: number; deletions: number }>) {
|
||||
const base = ref ? await prefix(cwd) : ""
|
||||
const next = await mapLimit(list, 8, async (item) => {
|
||||
const before = item.status === "added" ? "" : await show(cwd, ref, item.file, base)
|
||||
const after = item.status === "deleted" ? "" : await work(cwd, item.file)
|
||||
const stat = nums.get(item.file)
|
||||
return {
|
||||
file: item.file,
|
||||
before,
|
||||
after,
|
||||
additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
|
||||
deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
|
||||
status: item.status,
|
||||
} satisfies Snapshot.FileDiff
|
||||
})
|
||||
return next.toSorted((a, b) => a.file.localeCompare(b.file))
|
||||
}
|
||||
|
||||
async function status(cwd: string) {
|
||||
const result = await run(cwd, ["status", "--porcelain=v1", "--untracked-files=all", "--no-renames", "-z", "--", "."])
|
||||
return parseStatus(result.text())
|
||||
}
|
||||
|
||||
async function stats(cwd: string, ref: string) {
|
||||
const result = await run(cwd, ["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."])
|
||||
return parseNums(result.text())
|
||||
}
|
||||
|
||||
async function diff(cwd: string, ref: string) {
|
||||
const result = await run(cwd, ["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."])
|
||||
return parseNames(result.text())
|
||||
}
|
||||
|
||||
async function track(cwd: string, ref: string | undefined) {
|
||||
const [list, nums] = ref ? await Promise.all([status(cwd), stats(cwd, ref)]) : [await status(cwd), new Map()]
|
||||
return files(cwd, ref, list, nums)
|
||||
}
|
||||
|
||||
async function compare(cwd: string, ref: string) {
|
||||
const [list, nums, extra] = await Promise.all([diff(cwd, ref), stats(cwd, ref), status(cwd)])
|
||||
return files(
|
||||
cwd,
|
||||
ref,
|
||||
merge(
|
||||
list,
|
||||
extra.filter((item) => item.code === "??"),
|
||||
),
|
||||
nums,
|
||||
)
|
||||
}
|
||||
|
||||
export namespace Vcs {
|
||||
export const Mode = z.enum(["git", "branch"])
|
||||
export type Mode = z.infer<typeof Mode>
|
||||
|
||||
export const Event = {
|
||||
BranchUpdated: BusEvent.define(
|
||||
"vcs.branch.updated",
|
||||
@@ -22,7 +289,8 @@ export namespace Vcs {
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
branch: z.string(),
|
||||
branch: z.string().optional(),
|
||||
default_branch: z.string().optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "VcsInfo",
|
||||
@@ -34,6 +302,8 @@ export namespace VcsService {
|
||||
export interface Service {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly branch: () => Effect.Effect<string | undefined>
|
||||
readonly defaultBranch: () => Effect.Effect<string | undefined>
|
||||
readonly diff: (mode: Vcs.Mode) => Effect.Effect<Snapshot.FileDiff[]>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,19 +313,15 @@ export class VcsService extends ServiceMap.Service<VcsService, VcsService.Servic
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
let current: string | undefined
|
||||
let root: Base | undefined
|
||||
|
||||
if (instance.project.vcs === "git") {
|
||||
const currentBranch = async () => {
|
||||
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
cwd: instance.project.worktree,
|
||||
})
|
||||
if (result.exitCode !== 0) return undefined
|
||||
const text = result.text().trim()
|
||||
return text || undefined
|
||||
return branch(instance.directory)
|
||||
}
|
||||
|
||||
current = yield* Effect.promise(() => currentBranch())
|
||||
log.info("initialized", { branch: current })
|
||||
;[current, root] = yield* Effect.promise(() => Promise.all([currentBranch(), base(instance.directory)]))
|
||||
log.info("initialized", { branch: current, default_branch: root?.name })
|
||||
|
||||
const unsubscribe = Bus.subscribe(
|
||||
FileWatcher.Event.Updated,
|
||||
@@ -78,6 +344,24 @@ export class VcsService extends ServiceMap.Service<VcsService, VcsService.Servic
|
||||
branch: Effect.fn("VcsService.branch")(function* () {
|
||||
return current
|
||||
}),
|
||||
defaultBranch: Effect.fn("VcsService.defaultBranch")(function* () {
|
||||
return root?.name
|
||||
}),
|
||||
diff: Effect.fn("VcsService.diff")(function* (mode: Vcs.Mode) {
|
||||
if (instance.project.vcs !== "git") return []
|
||||
if (mode === "git") {
|
||||
const ok = yield* Effect.promise(() => head(instance.directory))
|
||||
return yield* Effect.promise(() => track(instance.directory, ok ? "HEAD" : undefined))
|
||||
}
|
||||
|
||||
if (!root) return []
|
||||
if (current && current === root.name) return []
|
||||
const ref = yield* Effect.promise(() => run(instance.directory, ["merge-base", root.ref, "HEAD"]))
|
||||
if (ref.exitCode !== 0) return []
|
||||
const text = out(ref)
|
||||
if (!text) return []
|
||||
return yield* Effect.promise(() => compare(instance.directory, text))
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ export namespace ProviderError {
|
||||
/input token count.*exceeds the maximum/i, // Google (Gemini)
|
||||
/maximum prompt length is \d+/i, // xAI (Grok)
|
||||
/reduce the length of the messages/i, // Groq
|
||||
/maximum context length is \d+ tokens/i, // OpenRouter, DeepSeek
|
||||
/maximum context length is \d+ tokens/i, // OpenRouter, DeepSeek, vLLM
|
||||
/exceeds the limit of \d+/i, // GitHub Copilot
|
||||
/exceeds the available context size/i, // llama.cpp server
|
||||
/greater than the context length/i, // LM Studio
|
||||
@@ -21,6 +21,8 @@ export namespace ProviderError {
|
||||
/exceeded model token limit/i, // Kimi For Coding, Moonshot
|
||||
/context[_ ]length[_ ]exceeded/i, // Generic fallback
|
||||
/request entity too large/i, // HTTP 413
|
||||
/context length is only \d+ tokens/i, // vLLM
|
||||
/input length.*exceeds.*context length/i, // vLLM
|
||||
]
|
||||
|
||||
function isOpenAiErrorRetryable(e: APICallError) {
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Instance } from "../project/instance"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
export namespace Scheduler {
|
||||
const log = Log.create({ service: "scheduler" })
|
||||
|
||||
export type Task = {
|
||||
id: string
|
||||
interval: number
|
||||
run: () => Promise<void>
|
||||
scope?: "instance" | "global"
|
||||
}
|
||||
|
||||
type Timer = ReturnType<typeof setInterval>
|
||||
type Entry = {
|
||||
tasks: Map<string, Task>
|
||||
timers: Map<string, Timer>
|
||||
}
|
||||
|
||||
const create = (): Entry => {
|
||||
const tasks = new Map<string, Task>()
|
||||
const timers = new Map<string, Timer>()
|
||||
return { tasks, timers }
|
||||
}
|
||||
|
||||
const shared = create()
|
||||
|
||||
const state = Instance.state(
|
||||
() => create(),
|
||||
async (entry) => {
|
||||
for (const timer of entry.timers.values()) {
|
||||
clearInterval(timer)
|
||||
}
|
||||
entry.tasks.clear()
|
||||
entry.timers.clear()
|
||||
},
|
||||
)
|
||||
|
||||
export function register(task: Task) {
|
||||
const scope = task.scope ?? "instance"
|
||||
const entry = scope === "global" ? shared : state()
|
||||
const current = entry.timers.get(task.id)
|
||||
if (current && scope === "global") return
|
||||
if (current) clearInterval(current)
|
||||
|
||||
entry.tasks.set(task.id, task)
|
||||
void run(task)
|
||||
const timer = setInterval(() => {
|
||||
void run(task)
|
||||
}, task.interval)
|
||||
timer.unref()
|
||||
entry.timers.set(task.id, timer)
|
||||
}
|
||||
|
||||
async function run(task: Task) {
|
||||
log.info("run", { id: task.id })
|
||||
await task.run().catch((error) => {
|
||||
log.error("run failed", { id: task.id, error })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { PermissionID } from "@/permission/schema"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Todo } from "../../session/todo"
|
||||
import { Agent } from "../../agent/agent"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Log } from "../../util/log"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { PermissionID } from "@/permission/schema"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { errors } from "../error"
|
||||
|
||||
@@ -41,6 +41,7 @@ import { websocket } from "hono/bun"
|
||||
import { HTTPException } from "hono/http-exception"
|
||||
import { errors } from "./error"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { QuestionRoutes } from "./routes/question"
|
||||
import { PermissionRoutes } from "./routes/permission"
|
||||
import { GlobalRoutes } from "./routes/global"
|
||||
@@ -331,10 +332,39 @@ export namespace Server {
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const branch = await runPromiseInstance(VcsService.use((s) => s.branch()))
|
||||
return c.json({
|
||||
branch,
|
||||
})
|
||||
const [branch, default_branch] = await Promise.all([
|
||||
runPromiseInstance(VcsService.use((s) => s.branch())),
|
||||
runPromiseInstance(VcsService.use((s) => s.defaultBranch())),
|
||||
])
|
||||
return c.json({ branch, default_branch })
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/vcs/diff",
|
||||
describeRoute({
|
||||
summary: "Get VCS diff",
|
||||
description: "Retrieve the current git diff for the working tree or against the default branch.",
|
||||
operationId: "vcs.diff",
|
||||
responses: {
|
||||
200: {
|
||||
description: "VCS diff",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Snapshot.FileDiff.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
mode: Vcs.Mode,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const mode = c.req.valid("query").mode
|
||||
return c.json(await runPromiseInstance(VcsService.use((s) => s.diff(mode))))
|
||||
},
|
||||
)
|
||||
.get(
|
||||
|
||||
@@ -28,7 +28,7 @@ import { SessionID, MessageID, PartID } from "./schema"
|
||||
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Global } from "@/global"
|
||||
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
|
||||
import { iife } from "@/util/iife"
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { MessageV2 } from "./message-v2"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { SystemPrompt } from "./system"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Auth } from "@/auth"
|
||||
|
||||
export namespace LLM {
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { Provider } from "@/provider/provider"
|
||||
import { LLM } from "./llm"
|
||||
import { Config } from "@/config/config"
|
||||
import { SessionCompaction } from "./compaction"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Question } from "@/question"
|
||||
import { PartID } from "./schema"
|
||||
import type { SessionID, MessageID } from "./schema"
|
||||
|
||||
@@ -41,12 +41,12 @@ import { fn } from "@/util/fn"
|
||||
import { SessionProcessor } from "./processor"
|
||||
import { TaskTool } from "@/tool/task"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { SessionStatus } from "./status"
|
||||
import { LLM } from "./llm"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Truncate } from "@/tool/truncation"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { decodeDataUrl } from "@/util/data-url"
|
||||
|
||||
// @ts-ignore
|
||||
@@ -1318,6 +1318,31 @@ export namespace SessionPrompt {
|
||||
},
|
||||
)
|
||||
|
||||
const parsedInfo = MessageV2.Info.safeParse(info)
|
||||
if (!parsedInfo.success) {
|
||||
log.error("invalid user message before save", {
|
||||
sessionID: input.sessionID,
|
||||
messageID: info.id,
|
||||
agent: info.agent,
|
||||
model: info.model,
|
||||
issues: parsedInfo.error.issues,
|
||||
})
|
||||
}
|
||||
|
||||
parts.forEach((part, index) => {
|
||||
const parsedPart = MessageV2.Part.safeParse(part)
|
||||
if (parsedPart.success) return
|
||||
log.error("invalid user part before save", {
|
||||
sessionID: input.sessionID,
|
||||
messageID: info.id,
|
||||
partID: part.id,
|
||||
partType: part.type,
|
||||
index,
|
||||
issues: parsedPart.error.issues,
|
||||
part,
|
||||
})
|
||||
})
|
||||
|
||||
await Session.updateMessage(info)
|
||||
for (const part of parts) {
|
||||
await Session.updatePart(part)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlit
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import type { MessageV2 } from "./message-v2"
|
||||
import type { Snapshot } from "../snapshot"
|
||||
import type { PermissionNext } from "../permission/next"
|
||||
import type { PermissionNext } from "../permission"
|
||||
import type { ProjectID } from "../project/schema"
|
||||
import type { SessionID, MessageID, PartID } from "./schema"
|
||||
import type { WorkspaceID } from "../control-plane/schema"
|
||||
|
||||
@@ -11,7 +11,7 @@ import PROMPT_CODEX from "./prompt/codex_header.txt"
|
||||
import PROMPT_TRINITY from "./prompt/trinity.txt"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import type { Agent } from "@/agent/agent"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Skill } from "@/skill"
|
||||
|
||||
export namespace SystemPrompt {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { DiscoveryService } from "./discovery"
|
||||
import { Glob } from "../util/glob"
|
||||
import { pathToFileURL } from "url"
|
||||
import type { Agent } from "@/agent/agent"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
|
||||
@@ -1,257 +1,35 @@
|
||||
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Log } from "../util/log"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Global } from "../global"
|
||||
import z from "zod"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { Config } from "../config/config"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Scheduler } from "../scheduler"
|
||||
import { Process } from "@/util/process"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
const log = Log.create({ service: "snapshot" })
|
||||
const PRUNE = "7.days"
|
||||
|
||||
// Common git config flags shared across snapshot operations
|
||||
const GIT_CORE = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
|
||||
const GIT_CFG = ["-c", "core.autocrlf=false", ...GIT_CORE]
|
||||
const GIT_CFG_QUOTE = [...GIT_CFG, "-c", "core.quotepath=false"]
|
||||
|
||||
interface GitResult {
|
||||
readonly code: ChildProcessSpawner.ExitCode
|
||||
readonly text: string
|
||||
readonly stderr: string
|
||||
}
|
||||
|
||||
export namespace Snapshot {
|
||||
const log = Log.create({ service: "snapshot" })
|
||||
const hour = 60 * 60 * 1000
|
||||
const prune = "7.days"
|
||||
|
||||
function args(git: string, cmd: string[]) {
|
||||
return ["--git-dir", git, "--work-tree", Instance.worktree, ...cmd]
|
||||
}
|
||||
|
||||
export function init() {
|
||||
Scheduler.register({
|
||||
id: "snapshot.cleanup",
|
||||
interval: hour,
|
||||
run: cleanup,
|
||||
scope: "instance",
|
||||
})
|
||||
}
|
||||
|
||||
export async function cleanup() {
|
||||
if (Instance.project.vcs !== "git") return
|
||||
const cfg = await Config.get()
|
||||
if (cfg.snapshot === false) return
|
||||
const git = gitdir()
|
||||
const exists = await fs
|
||||
.stat(git)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!exists) return
|
||||
const result = await Process.run(["git", ...args(git, ["gc", `--prune=${prune}`])], {
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
log.warn("cleanup failed", {
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr.toString(),
|
||||
stdout: result.stdout.toString(),
|
||||
})
|
||||
return
|
||||
}
|
||||
log.info("cleanup", { prune })
|
||||
}
|
||||
|
||||
export async function track() {
|
||||
if (Instance.project.vcs !== "git") return
|
||||
const cfg = await Config.get()
|
||||
if (cfg.snapshot === false) return
|
||||
const git = gitdir()
|
||||
if (await fs.mkdir(git, { recursive: true })) {
|
||||
await Process.run(["git", "init"], {
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_DIR: git,
|
||||
GIT_WORK_TREE: Instance.worktree,
|
||||
},
|
||||
nothrow: true,
|
||||
})
|
||||
|
||||
// Configure git to not convert line endings on Windows
|
||||
await Process.run(["git", "--git-dir", git, "config", "core.autocrlf", "false"], { nothrow: true })
|
||||
await Process.run(["git", "--git-dir", git, "config", "core.longpaths", "true"], { nothrow: true })
|
||||
await Process.run(["git", "--git-dir", git, "config", "core.symlinks", "true"], { nothrow: true })
|
||||
await Process.run(["git", "--git-dir", git, "config", "core.fsmonitor", "false"], { nothrow: true })
|
||||
log.info("initialized")
|
||||
}
|
||||
await add(git)
|
||||
const hash = await Process.text(["git", ...args(git, ["write-tree"])], {
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
}).then((x) => x.text)
|
||||
log.info("tracking", { hash, cwd: Instance.directory, git })
|
||||
return hash.trim()
|
||||
}
|
||||
|
||||
export const Patch = z.object({
|
||||
hash: z.string(),
|
||||
files: z.string().array(),
|
||||
})
|
||||
export type Patch = z.infer<typeof Patch>
|
||||
|
||||
export async function patch(hash: string): Promise<Patch> {
|
||||
const git = gitdir()
|
||||
await add(git)
|
||||
const result = await Process.text(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
...args(git, ["diff", "--no-ext-diff", "--name-only", hash, "--", "."]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
|
||||
// If git diff fails, return empty patch
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||
return { hash, files: [] }
|
||||
}
|
||||
|
||||
const files = result.text
|
||||
return {
|
||||
hash,
|
||||
files: files
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((x) => path.join(Instance.worktree, x).replaceAll("\\", "/")),
|
||||
}
|
||||
}
|
||||
|
||||
export async function restore(snapshot: string) {
|
||||
log.info("restore", { commit: snapshot })
|
||||
const git = gitdir()
|
||||
const result = await Process.run(
|
||||
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["read-tree", snapshot])],
|
||||
{
|
||||
cwd: Instance.worktree,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
if (result.code === 0) {
|
||||
const checkout = await Process.run(
|
||||
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["checkout-index", "-a", "-f"])],
|
||||
{
|
||||
cwd: Instance.worktree,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
if (checkout.code === 0) return
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: checkout.code,
|
||||
stderr: checkout.stderr.toString(),
|
||||
stdout: checkout.stdout.toString(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr.toString(),
|
||||
stdout: result.stdout.toString(),
|
||||
})
|
||||
}
|
||||
|
||||
export async function revert(patches: Patch[]) {
|
||||
const files = new Set<string>()
|
||||
const git = gitdir()
|
||||
for (const item of patches) {
|
||||
for (const file of item.files) {
|
||||
if (files.has(file)) continue
|
||||
log.info("reverting", { file, hash: item.hash })
|
||||
const result = await Process.run(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
...args(git, ["checkout", item.hash, "--", file]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.worktree,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
if (result.code !== 0) {
|
||||
const relativePath = path.relative(Instance.worktree, file)
|
||||
const checkTree = await Process.text(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
...args(git, ["ls-tree", item.hash, "--", relativePath]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.worktree,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
if (checkTree.code === 0 && checkTree.text.trim()) {
|
||||
log.info("file existed in snapshot but checkout failed, keeping", {
|
||||
file,
|
||||
})
|
||||
} else {
|
||||
log.info("file did not exist in snapshot, deleting", { file })
|
||||
await fs.unlink(file).catch(() => {})
|
||||
}
|
||||
}
|
||||
files.add(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function diff(hash: string) {
|
||||
const git = gitdir()
|
||||
await add(git)
|
||||
const result = await Process.text(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
...args(git, ["diff", "--no-ext-diff", hash, "--", "."]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.worktree,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", {
|
||||
hash,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr.toString(),
|
||||
stdout: result.stdout.toString(),
|
||||
})
|
||||
return ""
|
||||
}
|
||||
|
||||
return result.text.trim()
|
||||
}
|
||||
|
||||
export const FileDiff = z
|
||||
.object({
|
||||
file: z.string(),
|
||||
@@ -265,152 +43,339 @@ export namespace Snapshot {
|
||||
ref: "FileDiff",
|
||||
})
|
||||
export type FileDiff = z.infer<typeof FileDiff>
|
||||
export async function diffFull(from: string, to: string): Promise<FileDiff[]> {
|
||||
const git = gitdir()
|
||||
const result: FileDiff[] = []
|
||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||
|
||||
const statuses = await Process.text(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
...args(git, ["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
},
|
||||
).then((x) => x.text)
|
||||
|
||||
for (const line of statuses.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [code, file] = line.split("\t")
|
||||
if (!code || !file) continue
|
||||
const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified"
|
||||
status.set(file, kind)
|
||||
}
|
||||
|
||||
for (const line of await Process.lines(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
...args(git, ["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
},
|
||||
)) {
|
||||
if (!line) continue
|
||||
const [additions, deletions, file] = line.split("\t")
|
||||
const isBinaryFile = additions === "-" && deletions === "-"
|
||||
const before = isBinaryFile
|
||||
? ""
|
||||
: await Process.text(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
...args(git, ["show", `${from}:${file}`]),
|
||||
],
|
||||
{ nothrow: true },
|
||||
).then((x) => x.text)
|
||||
const after = isBinaryFile
|
||||
? ""
|
||||
: await Process.text(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
...args(git, ["show", `${to}:${file}`]),
|
||||
],
|
||||
{ nothrow: true },
|
||||
).then((x) => x.text)
|
||||
const added = isBinaryFile ? 0 : parseInt(additions)
|
||||
const deleted = isBinaryFile ? 0 : parseInt(deletions)
|
||||
result.push({
|
||||
file,
|
||||
before,
|
||||
after,
|
||||
additions: Number.isFinite(added) ? added : 0,
|
||||
deletions: Number.isFinite(deleted) ? deleted : 0,
|
||||
status: status.get(file) ?? "modified",
|
||||
})
|
||||
}
|
||||
return result
|
||||
// Promise facade — existing callers use these
|
||||
export function init() {
|
||||
void runPromiseInstance(SnapshotService.use((s) => s.init()))
|
||||
}
|
||||
|
||||
function gitdir() {
|
||||
const project = Instance.project
|
||||
return path.join(Global.Path.data, "snapshot", project.id)
|
||||
export async function cleanup() {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.cleanup()))
|
||||
}
|
||||
|
||||
async function add(git: string) {
|
||||
await syncExclude(git)
|
||||
await Process.run(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
...args(git, ["add", "."]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
export async function track() {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.track()))
|
||||
}
|
||||
|
||||
async function syncExclude(git: string) {
|
||||
const file = await excludes()
|
||||
const target = path.join(git, "info", "exclude")
|
||||
await fs.mkdir(path.join(git, "info"), { recursive: true })
|
||||
if (!file) {
|
||||
await Filesystem.write(target, "")
|
||||
return
|
||||
}
|
||||
const text = await Filesystem.readText(file).catch(() => "")
|
||||
|
||||
await Filesystem.write(target, text)
|
||||
export async function patch(hash: string) {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.patch(hash)))
|
||||
}
|
||||
|
||||
async function excludes() {
|
||||
const file = await Process.text(["git", "rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
|
||||
cwd: Instance.worktree,
|
||||
nothrow: true,
|
||||
}).then((x) => x.text)
|
||||
if (!file.trim()) return
|
||||
const exists = await fs
|
||||
.stat(file.trim())
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!exists) return
|
||||
return file.trim()
|
||||
export async function restore(snapshot: string) {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.restore(snapshot)))
|
||||
}
|
||||
|
||||
export async function revert(patches: Patch[]) {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.revert(patches)))
|
||||
}
|
||||
|
||||
export async function diff(hash: string) {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.diff(hash)))
|
||||
}
|
||||
|
||||
export async function diffFull(from: string, to: string) {
|
||||
return runPromiseInstance(SnapshotService.use((s) => s.diffFull(from, to)))
|
||||
}
|
||||
}
|
||||
|
||||
export namespace SnapshotService {
|
||||
export interface Service {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly cleanup: () => Effect.Effect<void>
|
||||
readonly track: () => Effect.Effect<string | undefined>
|
||||
readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
|
||||
readonly restore: (snapshot: string) => Effect.Effect<void>
|
||||
readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>
|
||||
readonly diff: (hash: string) => Effect.Effect<string>
|
||||
readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
|
||||
}
|
||||
}
|
||||
|
||||
export class SnapshotService extends ServiceMap.Service<SnapshotService, SnapshotService.Service>()(
|
||||
"@opencode/Snapshot",
|
||||
) {
|
||||
static readonly layer = Layer.effect(
|
||||
SnapshotService,
|
||||
Effect.gen(function* () {
|
||||
const ctx = yield* InstanceContext
|
||||
const fileSystem = yield* FileSystem.FileSystem
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const { directory, worktree, project } = ctx
|
||||
const isGit = project.vcs === "git"
|
||||
const snapshotGit = path.join(Global.Path.data, "snapshot", project.id)
|
||||
|
||||
const gitArgs = (cmd: string[]) => ["--git-dir", snapshotGit, "--work-tree", worktree, ...cmd]
|
||||
|
||||
// Run git with nothrow semantics — always returns a result, never fails
|
||||
const git = (args: string[], opts?: { cwd?: string; env?: Record<string, string> }): Effect.Effect<GitResult> =>
|
||||
Effect.gen(function* () {
|
||||
const command = ChildProcess.make("git", args, {
|
||||
cwd: opts?.cwd,
|
||||
env: opts?.env,
|
||||
extendEnv: true,
|
||||
})
|
||||
const handle = yield* spawner.spawn(command)
|
||||
const [text, stderr] = yield* Effect.all(
|
||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const code = yield* handle.exitCode
|
||||
return { code, text, stderr }
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.catch((err) =>
|
||||
Effect.succeed({
|
||||
code: ChildProcessSpawner.ExitCode(1),
|
||||
text: "",
|
||||
stderr: String(err),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// FileSystem helpers — orDie converts PlatformError to defects
|
||||
const exists = (p: string) => fileSystem.exists(p).pipe(Effect.orDie)
|
||||
const mkdir = (p: string) => fileSystem.makeDirectory(p, { recursive: true }).pipe(Effect.orDie)
|
||||
const writeFile = (p: string, content: string) => fileSystem.writeFileString(p, content).pipe(Effect.orDie)
|
||||
const readFile = (p: string) => fileSystem.readFileString(p).pipe(Effect.catch(() => Effect.succeed("")))
|
||||
const removeFile = (p: string) => fileSystem.remove(p).pipe(Effect.catch(() => Effect.void))
|
||||
|
||||
// --- internal Effect helpers ---
|
||||
|
||||
const isEnabled = Effect.gen(function* () {
|
||||
if (!isGit) return false
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
return cfg.snapshot !== false
|
||||
})
|
||||
|
||||
const excludesPath = Effect.gen(function* () {
|
||||
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
|
||||
cwd: worktree,
|
||||
})
|
||||
const file = result.text.trim()
|
||||
if (!file) return undefined
|
||||
if (!(yield* exists(file))) return undefined
|
||||
return file
|
||||
})
|
||||
|
||||
const syncExclude = Effect.gen(function* () {
|
||||
const file = yield* excludesPath
|
||||
const target = path.join(snapshotGit, "info", "exclude")
|
||||
yield* mkdir(path.join(snapshotGit, "info"))
|
||||
if (!file) {
|
||||
yield* writeFile(target, "")
|
||||
return
|
||||
}
|
||||
const text = yield* readFile(file)
|
||||
yield* writeFile(target, text)
|
||||
})
|
||||
|
||||
const add = Effect.gen(function* () {
|
||||
yield* syncExclude
|
||||
yield* git([...GIT_CFG, ...gitArgs(["add", "."])], { cwd: directory })
|
||||
})
|
||||
|
||||
// --- service methods ---
|
||||
|
||||
const cleanup = Effect.fn("SnapshotService.cleanup")(function* () {
|
||||
if (!(yield* isEnabled)) return
|
||||
if (!(yield* exists(snapshotGit))) return
|
||||
const result = yield* git(gitArgs(["gc", `--prune=${PRUNE}`]), {
|
||||
cwd: directory,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
log.warn("cleanup failed", {
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.info("cleanup", { prune: PRUNE })
|
||||
})
|
||||
|
||||
const track = Effect.fn("SnapshotService.track")(function* () {
|
||||
if (!(yield* isEnabled)) return undefined
|
||||
const existed = yield* exists(snapshotGit)
|
||||
yield* mkdir(snapshotGit)
|
||||
if (!existed) {
|
||||
yield* git(["init"], {
|
||||
env: { GIT_DIR: snapshotGit, GIT_WORK_TREE: worktree },
|
||||
})
|
||||
yield* git(["--git-dir", snapshotGit, "config", "core.autocrlf", "false"])
|
||||
yield* git(["--git-dir", snapshotGit, "config", "core.longpaths", "true"])
|
||||
yield* git(["--git-dir", snapshotGit, "config", "core.symlinks", "true"])
|
||||
yield* git(["--git-dir", snapshotGit, "config", "core.fsmonitor", "false"])
|
||||
log.info("initialized")
|
||||
}
|
||||
yield* add
|
||||
const result = yield* git(gitArgs(["write-tree"]), { cwd: directory })
|
||||
const hash = result.text.trim()
|
||||
log.info("tracking", { hash, cwd: directory, git: snapshotGit })
|
||||
return hash
|
||||
})
|
||||
|
||||
const patch = Effect.fn("SnapshotService.patch")(function* (hash: string) {
|
||||
yield* add
|
||||
const result = yield* git(
|
||||
[...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])],
|
||||
{ cwd: directory },
|
||||
)
|
||||
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||
return { hash, files: [] } as Snapshot.Patch
|
||||
}
|
||||
|
||||
return {
|
||||
hash,
|
||||
files: result.text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((x: string) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((x: string) => path.join(worktree, x).replaceAll("\\", "/")),
|
||||
} as Snapshot.Patch
|
||||
})
|
||||
|
||||
const restore = Effect.fn("SnapshotService.restore")(function* (snapshot: string) {
|
||||
log.info("restore", { commit: snapshot })
|
||||
const result = yield* git([...GIT_CORE, ...gitArgs(["read-tree", snapshot])], { cwd: worktree })
|
||||
if (result.code === 0) {
|
||||
const checkout = yield* git([...GIT_CORE, ...gitArgs(["checkout-index", "-a", "-f"])], { cwd: worktree })
|
||||
if (checkout.code === 0) return
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: checkout.code,
|
||||
stderr: checkout.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
})
|
||||
|
||||
const revert = Effect.fn("SnapshotService.revert")(function* (patches: Snapshot.Patch[]) {
|
||||
const seen = new Set<string>()
|
||||
for (const item of patches) {
|
||||
for (const file of item.files) {
|
||||
if (seen.has(file)) continue
|
||||
log.info("reverting", { file, hash: item.hash })
|
||||
const result = yield* git([...GIT_CORE, ...gitArgs(["checkout", item.hash, "--", file])], {
|
||||
cwd: worktree,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
const relativePath = path.relative(worktree, file)
|
||||
const checkTree = yield* git([...GIT_CORE, ...gitArgs(["ls-tree", item.hash, "--", relativePath])], {
|
||||
cwd: worktree,
|
||||
})
|
||||
if (checkTree.code === 0 && checkTree.text.trim()) {
|
||||
log.info("file existed in snapshot but checkout failed, keeping", { file })
|
||||
} else {
|
||||
log.info("file did not exist in snapshot, deleting", { file })
|
||||
yield* removeFile(file)
|
||||
}
|
||||
}
|
||||
seen.add(file)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const diff = Effect.fn("SnapshotService.diff")(function* (hash: string) {
|
||||
yield* add
|
||||
const result = yield* git([...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", hash, "--", "."])], {
|
||||
cwd: worktree,
|
||||
})
|
||||
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", {
|
||||
hash,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
return ""
|
||||
}
|
||||
|
||||
return result.text.trim()
|
||||
})
|
||||
|
||||
const diffFull = Effect.fn("SnapshotService.diffFull")(function* (from: string, to: string) {
|
||||
const result: Snapshot.FileDiff[] = []
|
||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||
|
||||
const statuses = yield* git(
|
||||
[
|
||||
...GIT_CFG_QUOTE,
|
||||
...gitArgs(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
|
||||
],
|
||||
{ cwd: directory },
|
||||
)
|
||||
|
||||
for (const line of statuses.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [code, file] = line.split("\t")
|
||||
if (!code || !file) continue
|
||||
const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified"
|
||||
status.set(file, kind)
|
||||
}
|
||||
|
||||
const numstat = yield* git(
|
||||
[...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
|
||||
{ cwd: directory },
|
||||
)
|
||||
|
||||
for (const line of numstat.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [additions, deletions, file] = line.split("\t")
|
||||
const isBinaryFile = additions === "-" && deletions === "-"
|
||||
const [before, after] = isBinaryFile
|
||||
? ["", ""]
|
||||
: yield* Effect.all(
|
||||
[
|
||||
git([...GIT_CFG, ...gitArgs(["show", `${from}:${file}`])]).pipe(Effect.map((r) => r.text)),
|
||||
git([...GIT_CFG, ...gitArgs(["show", `${to}:${file}`])]).pipe(Effect.map((r) => r.text)),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const added = isBinaryFile ? 0 : parseInt(additions!)
|
||||
const deleted = isBinaryFile ? 0 : parseInt(deletions!)
|
||||
result.push({
|
||||
file: file!,
|
||||
before,
|
||||
after,
|
||||
additions: Number.isFinite(added) ? added : 0,
|
||||
deletions: Number.isFinite(deleted) ? deleted : 0,
|
||||
status: status.get(file!) ?? "modified",
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// Start hourly cleanup fiber — scoped to instance lifetime
|
||||
yield* cleanup().pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return SnapshotService.of({
|
||||
init: Effect.fn("SnapshotService.init")(function* () {}),
|
||||
cleanup,
|
||||
track,
|
||||
patch,
|
||||
restore,
|
||||
revert,
|
||||
diff,
|
||||
diffFull,
|
||||
})
|
||||
}),
|
||||
).pipe(
|
||||
Layer.provide(NodeChildProcessSpawner.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Flag } from "@/flag/flag.ts"
|
||||
import { Shell } from "@/shell/shell"
|
||||
|
||||
import { BashArity } from "@/permission/arity"
|
||||
import { Truncate } from "./truncation"
|
||||
import { Truncate } from "./truncate"
|
||||
import { Plugin } from "@/plugin"
|
||||
|
||||
const MAX_METADATA_LENGTH = 30_000
|
||||
|
||||
@@ -26,7 +26,7 @@ import { CodeSearchTool } from "./codesearch"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Log } from "@/util/log"
|
||||
import { LspTool } from "./lsp"
|
||||
import { Truncate } from "./truncation"
|
||||
import { Truncate } from "./truncate"
|
||||
|
||||
import { ApplyPatchTool } from "./apply_patch"
|
||||
import { Glob } from "../util/glob"
|
||||
|
||||
@@ -10,7 +10,7 @@ import { SessionPrompt } from "../session/prompt"
|
||||
import { iife } from "@/util/iife"
|
||||
import { defer } from "@/util/defer"
|
||||
import { Config } from "../config/config"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
import { PermissionNext } from "@/permission"
|
||||
|
||||
const parameters = z.object({
|
||||
description: z.string().describe("A short (3-5 words) description of the task"),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import z from "zod"
|
||||
import type { MessageV2 } from "../session/message-v2"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import type { PermissionNext } from "../permission/next"
|
||||
import type { PermissionNext } from "../permission"
|
||||
import type { SessionID, MessageID } from "../session/schema"
|
||||
import { Truncate } from "./truncation"
|
||||
import { Truncate } from "./truncate"
|
||||
|
||||
export namespace Tool {
|
||||
interface Metadata {
|
||||
|
||||
140
packages/opencode/src/tool/truncate-effect.ts
Normal file
140
packages/opencode/src/tool/truncate-effect.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap } from "effect"
|
||||
import path from "path"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import { PermissionEffect } from "../permission/service"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Log } from "../util/log"
|
||||
import { ToolID } from "./schema"
|
||||
import { TRUNCATION_DIR } from "./truncation-dir"
|
||||
|
||||
export namespace TruncateEffect {
|
||||
const log = Log.create({ service: "truncation" })
|
||||
const RETENTION = Duration.days(7)
|
||||
|
||||
export const MAX_LINES = 2000
|
||||
export const MAX_BYTES = 50 * 1024
|
||||
export const DIR = TRUNCATION_DIR
|
||||
export const GLOB = path.join(TRUNCATION_DIR, "*")
|
||||
|
||||
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
|
||||
|
||||
export interface Options {
|
||||
maxLines?: number
|
||||
maxBytes?: number
|
||||
direction?: "head" | "tail"
|
||||
}
|
||||
|
||||
function hasTaskTool(agent?: Agent.Info) {
|
||||
if (!agent?.permission) return false
|
||||
return PermissionEffect.evaluate("task", "*", agent.permission).action !== "deny"
|
||||
}
|
||||
|
||||
export interface Api {
|
||||
readonly cleanup: () => Effect.Effect<void>
|
||||
/**
|
||||
* Returns output unchanged when it fits within the limits, otherwise writes the full text
|
||||
* to the truncation directory and returns a preview plus a hint to inspect the saved file.
|
||||
*/
|
||||
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Api>()("@opencode/Truncate") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
|
||||
const cleanup = Effect.fn("TruncateEffect.cleanup")(function* () {
|
||||
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
|
||||
const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
|
||||
Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
|
||||
Effect.catch(() => Effect.succeed([])),
|
||||
)
|
||||
for (const entry of entries) {
|
||||
if (Identifier.timestamp(entry) >= cutoff) continue
|
||||
yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void))
|
||||
}
|
||||
})
|
||||
|
||||
const output = Effect.fn("TruncateEffect.output")(function* (
|
||||
text: string,
|
||||
options: Options = {},
|
||||
agent?: Agent.Info,
|
||||
) {
|
||||
const maxLines = options.maxLines ?? MAX_LINES
|
||||
const maxBytes = options.maxBytes ?? MAX_BYTES
|
||||
const direction = options.direction ?? "head"
|
||||
const lines = text.split("\n")
|
||||
const totalBytes = Buffer.byteLength(text, "utf-8")
|
||||
|
||||
if (lines.length <= maxLines && totalBytes <= maxBytes) {
|
||||
return { content: text, truncated: false } as const
|
||||
}
|
||||
|
||||
const out: string[] = []
|
||||
let i = 0
|
||||
let bytes = 0
|
||||
let hitBytes = false
|
||||
|
||||
if (direction === "head") {
|
||||
for (i = 0; i < lines.length && i < maxLines; i++) {
|
||||
const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
|
||||
if (bytes + size > maxBytes) {
|
||||
hitBytes = true
|
||||
break
|
||||
}
|
||||
out.push(lines[i])
|
||||
bytes += size
|
||||
}
|
||||
} else {
|
||||
for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
|
||||
const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
|
||||
if (bytes + size > maxBytes) {
|
||||
hitBytes = true
|
||||
break
|
||||
}
|
||||
out.unshift(lines[i])
|
||||
bytes += size
|
||||
}
|
||||
}
|
||||
|
||||
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
|
||||
const unit = hitBytes ? "bytes" : "lines"
|
||||
const preview = out.join("\n")
|
||||
const file = path.join(TRUNCATION_DIR, ToolID.ascending())
|
||||
|
||||
yield* fs.makeDirectory(TRUNCATION_DIR, { recursive: true }).pipe(Effect.orDie)
|
||||
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
|
||||
|
||||
const hint = hasTaskTool(agent)
|
||||
? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
|
||||
: `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
|
||||
|
||||
return {
|
||||
content:
|
||||
direction === "head"
|
||||
? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
|
||||
: `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
|
||||
truncated: true,
|
||||
outputPath: file,
|
||||
} as const
|
||||
})
|
||||
|
||||
yield* cleanup().pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("truncation cleanup failed", { cause: Cause.pretty(cause) })
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
||||
Effect.delay(Duration.minutes(1)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return Service.of({ cleanup, output })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
|
||||
}
|
||||
18
packages/opencode/src/tool/truncate.ts
Normal file
18
packages/opencode/src/tool/truncate.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Agent } from "../agent/agent"
|
||||
import { runtime } from "@/effect/runtime"
|
||||
import { TruncateEffect as S } from "./truncate-effect"
|
||||
|
||||
export namespace Truncate {
|
||||
export const MAX_LINES = S.MAX_LINES
|
||||
export const MAX_BYTES = S.MAX_BYTES
|
||||
export const DIR = S.DIR
|
||||
export const GLOB = S.GLOB
|
||||
|
||||
export type Result = S.Result
|
||||
|
||||
export type Options = S.Options
|
||||
|
||||
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
|
||||
return runtime.runPromise(S.Service.use((s) => s.output(text, options, agent)))
|
||||
}
|
||||
}
|
||||
4
packages/opencode/src/tool/truncation-dir.ts
Normal file
4
packages/opencode/src/tool/truncation-dir.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
|
||||
export const TRUNCATION_DIR = path.join(Global.Path.data, "tool-output")
|
||||
@@ -1,108 +0,0 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
import { Identifier } from "../id/id"
|
||||
import { PermissionNext } from "../permission/next"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import { Scheduler } from "../scheduler"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Glob } from "../util/glob"
|
||||
import { ToolID } from "./schema"
|
||||
|
||||
export namespace Truncate {
|
||||
export const MAX_LINES = 2000
|
||||
export const MAX_BYTES = 50 * 1024
|
||||
export const DIR = path.join(Global.Path.data, "tool-output")
|
||||
export const GLOB = path.join(DIR, "*")
|
||||
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
const HOUR_MS = 60 * 60 * 1000
|
||||
|
||||
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
|
||||
|
||||
export interface Options {
|
||||
maxLines?: number
|
||||
maxBytes?: number
|
||||
direction?: "head" | "tail"
|
||||
}
|
||||
|
||||
export function init() {
|
||||
Scheduler.register({
|
||||
id: "tool.truncation.cleanup",
|
||||
interval: HOUR_MS,
|
||||
run: cleanup,
|
||||
scope: "global",
|
||||
})
|
||||
}
|
||||
|
||||
export async function cleanup() {
|
||||
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - RETENTION_MS))
|
||||
const entries = await Glob.scan("tool_*", { cwd: DIR, include: "file" }).catch(() => [] as string[])
|
||||
for (const entry of entries) {
|
||||
if (Identifier.timestamp(entry) >= cutoff) continue
|
||||
await fs.unlink(path.join(DIR, entry)).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function hasTaskTool(agent?: Agent.Info): boolean {
|
||||
if (!agent?.permission) return false
|
||||
const rule = PermissionNext.evaluate("task", "*", agent.permission)
|
||||
return rule.action !== "deny"
|
||||
}
|
||||
|
||||
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
|
||||
const maxLines = options.maxLines ?? MAX_LINES
|
||||
const maxBytes = options.maxBytes ?? MAX_BYTES
|
||||
const direction = options.direction ?? "head"
|
||||
const lines = text.split("\n")
|
||||
const totalBytes = Buffer.byteLength(text, "utf-8")
|
||||
|
||||
if (lines.length <= maxLines && totalBytes <= maxBytes) {
|
||||
return { content: text, truncated: false }
|
||||
}
|
||||
|
||||
const out: string[] = []
|
||||
let i = 0
|
||||
let bytes = 0
|
||||
let hitBytes = false
|
||||
|
||||
if (direction === "head") {
|
||||
for (i = 0; i < lines.length && i < maxLines; i++) {
|
||||
const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
|
||||
if (bytes + size > maxBytes) {
|
||||
hitBytes = true
|
||||
break
|
||||
}
|
||||
out.push(lines[i])
|
||||
bytes += size
|
||||
}
|
||||
} else {
|
||||
for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
|
||||
const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
|
||||
if (bytes + size > maxBytes) {
|
||||
hitBytes = true
|
||||
break
|
||||
}
|
||||
out.unshift(lines[i])
|
||||
bytes += size
|
||||
}
|
||||
}
|
||||
|
||||
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
|
||||
const unit = hitBytes ? "bytes" : "lines"
|
||||
const preview = out.join("\n")
|
||||
|
||||
const id = ToolID.ascending()
|
||||
const filepath = path.join(DIR, id)
|
||||
await Filesystem.write(filepath, text)
|
||||
|
||||
const hint = hasTaskTool(agent)
|
||||
? `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
|
||||
: `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
|
||||
const message =
|
||||
direction === "head"
|
||||
? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
|
||||
: `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`
|
||||
|
||||
return { content: message, truncated: true, outputPath: filepath }
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ export function fn<T extends z.ZodType, Result>(schema: T, cb: (input: z.infer<T
|
||||
parsed = schema.parse(input)
|
||||
} catch (e) {
|
||||
console.trace("schema validation failure stack trace:")
|
||||
if (e instanceof z.ZodError) {
|
||||
console.error("schema validation issues:", JSON.stringify(e.issues, null, 2))
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Effect, Layer, Option } from "effect"
|
||||
import { AccountRepo } from "../../src/account/repo"
|
||||
import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema"
|
||||
import { Database } from "../../src/storage/db"
|
||||
import { testEffect } from "../fixture/effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const truncate = Layer.effectDiscard(
|
||||
Effect.sync(() => {
|
||||
@@ -16,24 +16,21 @@ const truncate = Layer.effectDiscard(
|
||||
|
||||
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
|
||||
|
||||
it.effect(
|
||||
"list returns empty when no accounts exist",
|
||||
it.effect("list returns empty when no accounts exist", () =>
|
||||
Effect.gen(function* () {
|
||||
const accounts = yield* AccountRepo.use((r) => r.list())
|
||||
expect(accounts).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"active returns none when no accounts exist",
|
||||
it.effect("active returns none when no accounts exist", () =>
|
||||
Effect.gen(function* () {
|
||||
const active = yield* AccountRepo.use((r) => r.active())
|
||||
expect(Option.isNone(active)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"persistAccount inserts and getRow retrieves",
|
||||
it.effect("persistAccount inserts and getRow retrieves", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
yield* AccountRepo.use((r) =>
|
||||
@@ -59,8 +56,7 @@ it.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"persistAccount sets the active account and org",
|
||||
it.effect("persistAccount sets the active account and org", () =>
|
||||
Effect.gen(function* () {
|
||||
const id1 = AccountID.make("user-1")
|
||||
const id2 = AccountID.make("user-2")
|
||||
@@ -97,8 +93,7 @@ it.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"list returns all accounts",
|
||||
it.effect("list returns all accounts", () =>
|
||||
Effect.gen(function* () {
|
||||
const id1 = AccountID.make("user-1")
|
||||
const id2 = AccountID.make("user-2")
|
||||
@@ -133,8 +128,7 @@ it.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"remove deletes an account",
|
||||
it.effect("remove deletes an account", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
@@ -157,8 +151,7 @@ it.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"use stores the selected org and marks the account active",
|
||||
it.effect("use stores the selected org and marks the account active", () =>
|
||||
Effect.gen(function* () {
|
||||
const id1 = AccountID.make("user-1")
|
||||
const id2 = AccountID.make("user-2")
|
||||
@@ -198,8 +191,7 @@ it.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"persistToken updates token fields",
|
||||
it.effect("persistToken updates token fields", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
@@ -233,8 +225,7 @@ it.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"persistToken with no expiry sets token_expiry to null",
|
||||
it.effect("persistToken with no expiry sets token_expiry to null", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
@@ -264,8 +255,7 @@ it.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"persistAccount upserts on conflict",
|
||||
it.effect("persistAccount upserts on conflict", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
@@ -305,8 +295,7 @@ it.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"remove clears active state when deleting the active account",
|
||||
it.effect("remove clears active state when deleting the active account", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
@@ -329,8 +318,7 @@ it.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"getRow returns none for nonexistent account",
|
||||
it.effect("getRow returns none for nonexistent account", () =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope")))
|
||||
expect(Option.isNone(row)).toBe(true)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Duration, Effect, Layer, Option, Ref, Schema } from "effect"
|
||||
import { Duration, Effect, Layer, Option, Schema } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
|
||||
import { AccountRepo } from "../../src/account/repo"
|
||||
import { AccountService } from "../../src/account/service"
|
||||
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
|
||||
import { Database } from "../../src/storage/db"
|
||||
import { testEffect } from "../fixture/effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const truncate = Layer.effectDiscard(
|
||||
Effect.sync(() => {
|
||||
@@ -34,8 +34,7 @@ const encodeOrg = Schema.encodeSync(Org)
|
||||
|
||||
const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name }))
|
||||
|
||||
it.effect(
|
||||
"orgsByAccount groups orgs per account",
|
||||
it.effect("orgsByAccount groups orgs per account", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* AccountRepo.use((r) =>
|
||||
r.persistAccount({
|
||||
@@ -61,10 +60,10 @@ it.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
const seen = yield* Ref.make<string[]>([])
|
||||
const seen: Array<string> = []
|
||||
const client = HttpClient.make((req) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.update(seen, (xs) => [...xs, `${req.method} ${req.url}`])
|
||||
seen.push(`${req.method} ${req.url}`)
|
||||
|
||||
if (req.url === "https://one.example.com/api/orgs") {
|
||||
return json(req, [org("org-1", "One")])
|
||||
@@ -84,15 +83,11 @@ it.effect(
|
||||
[AccountID.make("user-1"), [OrgID.make("org-1")]],
|
||||
[AccountID.make("user-2"), [OrgID.make("org-2"), OrgID.make("org-3")]],
|
||||
])
|
||||
expect(yield* Ref.get(seen)).toEqual([
|
||||
"GET https://one.example.com/api/orgs",
|
||||
"GET https://two.example.com/api/orgs",
|
||||
])
|
||||
expect(seen).toEqual(["GET https://one.example.com/api/orgs", "GET https://two.example.com/api/orgs"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"token refresh persists the new token",
|
||||
it.effect("token refresh persists the new token", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
@@ -133,8 +128,7 @@ it.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"config sends the selected org header",
|
||||
it.effect("config sends the selected org header", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
@@ -150,13 +144,11 @@ it.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
const seen = yield* Ref.make<{ auth?: string; org?: string }>({})
|
||||
const seen: { auth?: string; org?: string } = {}
|
||||
const client = HttpClient.make((req) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.set(seen, {
|
||||
auth: req.headers.authorization,
|
||||
org: req.headers["x-org-id"],
|
||||
})
|
||||
seen.auth = req.headers.authorization
|
||||
seen.org = req.headers["x-org-id"]
|
||||
|
||||
if (req.url === "https://one.example.com/api/config") {
|
||||
return json(req, { config: { theme: "light", seats: 5 } })
|
||||
@@ -169,15 +161,14 @@ it.effect(
|
||||
const cfg = yield* AccountService.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
|
||||
|
||||
expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
|
||||
expect(yield* Ref.get(seen)).toEqual({
|
||||
expect(seen).toEqual({
|
||||
auth: "Bearer at_1",
|
||||
org: "org-9",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"poll stores the account and first org on success",
|
||||
it.effect("poll stores the account and first org on success", () =>
|
||||
Effect.gen(function* () {
|
||||
const login = new Login({
|
||||
code: DeviceCode.make("device-code"),
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "path"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { PermissionNext } from "../../src/permission/next"
|
||||
import { PermissionNext } from "../../src/permission"
|
||||
|
||||
// Helper to evaluate permission for a tool with wildcard pattern
|
||||
function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined {
|
||||
@@ -76,7 +76,7 @@ test("explore agent denies edit and write", async () => {
|
||||
})
|
||||
|
||||
test("explore agent asks for external directories and allows Truncate.GLOB", async () => {
|
||||
const { Truncate } = await import("../../src/tool/truncation")
|
||||
const { Truncate } = await import("../../src/tool/truncate")
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
@@ -463,7 +463,7 @@ test("legacy tools config maps write/edit/patch/multiedit to edit permission", a
|
||||
})
|
||||
|
||||
test("Truncate.GLOB is allowed even when user denies external_directory globally", async () => {
|
||||
const { Truncate } = await import("../../src/tool/truncation")
|
||||
const { Truncate } = await import("../../src/tool/truncate")
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
permission: {
|
||||
@@ -483,7 +483,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally
|
||||
})
|
||||
|
||||
test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => {
|
||||
const { Truncate } = await import("../../src/tool/truncation")
|
||||
const { Truncate } = await import("../../src/tool/truncate")
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
agent: {
|
||||
@@ -507,7 +507,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen
|
||||
})
|
||||
|
||||
test("explicit Truncate.GLOB deny is respected", async () => {
|
||||
const { Truncate } = await import("../../src/tool/truncation")
|
||||
const { Truncate } = await import("../../src/tool/truncate")
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
permission: {
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { test } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
|
||||
export const testEffect = <R, E>(layer: Layer.Layer<R, E, never>) => ({
|
||||
effect: <A, E2>(name: string, value: Effect.Effect<A, E2, R>) =>
|
||||
test(name, () => Effect.runPromise(value.pipe(Effect.provide(layer)))),
|
||||
})
|
||||
@@ -28,7 +28,11 @@ export function withServices<S>(
|
||||
directory,
|
||||
fn: async () => {
|
||||
const ctx = Layer.sync(InstanceContext, () =>
|
||||
InstanceContext.of({ directory: Instance.directory, project: Instance.project }),
|
||||
InstanceContext.of({
|
||||
directory: Instance.directory,
|
||||
worktree: Instance.worktree,
|
||||
project: Instance.project,
|
||||
}),
|
||||
)
|
||||
let resolved: Layer.Layer<S> = Layer.fresh(layer).pipe(Layer.provide(ctx)) as any
|
||||
if (options?.provide) {
|
||||
|
||||
37
packages/opencode/test/lib/effect.ts
Normal file
37
packages/opencode/test/lib/effect.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { test, type TestOptions } from "bun:test"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import type * as Scope from "effect/Scope"
|
||||
import * as TestConsole from "effect/testing/TestConsole"
|
||||
|
||||
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
|
||||
const env = TestConsole.layer
|
||||
|
||||
const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
|
||||
|
||||
const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2, never>) =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit)
|
||||
if (Exit.isFailure(exit)) {
|
||||
for (const err of Cause.prettyErrors(exit.cause)) {
|
||||
yield* Effect.logError(err)
|
||||
}
|
||||
}
|
||||
return yield* exit
|
||||
}).pipe(Effect.runPromise)
|
||||
|
||||
const make = <R, E>(layer: Layer.Layer<R, E, never>) => {
|
||||
const effect = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
||||
test(name, () => run(value, layer), opts)
|
||||
|
||||
effect.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
||||
test.only(name, () => run(value, layer), opts)
|
||||
|
||||
effect.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
||||
test.skip(name, () => run(value, layer), opts)
|
||||
|
||||
return { effect }
|
||||
}
|
||||
|
||||
export const it = make(env)
|
||||
|
||||
export const testEffect = <R, E>(layer: Layer.Layer<R, E, never>) => make(Layer.provideMerge(layer, env))
|
||||
10
packages/opencode/test/lib/filesystem.ts
Normal file
10
packages/opencode/test/lib/filesystem.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import path from "path"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
|
||||
export const writeFileStringScoped = Effect.fn("test.writeFileStringScoped")(function* (file: string, text: string) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
|
||||
yield* fs.writeFileString(file, text)
|
||||
yield* Effect.addFinalizer(() => fs.remove(file, { force: true }).pipe(Effect.orDie))
|
||||
return file
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { PermissionNext } from "../src/permission/next"
|
||||
import { PermissionNext } from "../src/permission"
|
||||
import { Config } from "../src/config/config"
|
||||
import { Instance } from "../src/project/instance"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Effect } from "effect"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { runtime } from "../../src/effect/runtime"
|
||||
import { Instances } from "../../src/effect/instances"
|
||||
import { PermissionNext } from "../../src/permission/next"
|
||||
import { PermissionNext } from "../../src/permission"
|
||||
import * as S from "../../src/permission/service"
|
||||
import { PermissionID } from "../../src/permission/schema"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
@@ -395,9 +395,9 @@ test("disabled - disables tool when denied", () => {
|
||||
expect(result.has("read")).toBe(false)
|
||||
})
|
||||
|
||||
test("disabled - disables edit/write/patch/multiedit when edit denied", () => {
|
||||
test("disabled - disables edit/write/apply_patch/multiedit when edit denied", () => {
|
||||
const result = PermissionNext.disabled(
|
||||
["edit", "write", "patch", "multiedit", "bash"],
|
||||
["edit", "write", "apply_patch", "multiedit", "bash"],
|
||||
[
|
||||
{ permission: "*", pattern: "*", action: "allow" },
|
||||
{ permission: "edit", pattern: "*", action: "deny" },
|
||||
@@ -405,7 +405,7 @@ test("disabled - disables edit/write/patch/multiedit when edit denied", () => {
|
||||
)
|
||||
expect(result.has("edit")).toBe(true)
|
||||
expect(result.has("write")).toBe(true)
|
||||
expect(result.has("patch")).toBe(true)
|
||||
expect(result.has("apply_patch")).toBe(true)
|
||||
expect(result.has("multiedit")).toBe(true)
|
||||
expect(result.has("bash")).toBe(false)
|
||||
})
|
||||
@@ -1005,7 +1005,7 @@ test("ask - abort should clear pending request", async () => {
|
||||
fn: async () => {
|
||||
const ctl = new AbortController()
|
||||
const ask = runtime.runPromise(
|
||||
S.PermissionService.use((svc) =>
|
||||
S.PermissionEffect.Service.use((svc) =>
|
||||
svc.ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
|
||||
@@ -34,6 +34,10 @@ function withVcs(
|
||||
)
|
||||
}
|
||||
|
||||
function withVcsOnly(directory: string, body: (rt: ManagedRuntime.ManagedRuntime<VcsService, never>) => Promise<void>) {
|
||||
return withServices(directory, VcsService.layer, body)
|
||||
}
|
||||
|
||||
type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } }
|
||||
|
||||
/** Wait for a Vcs.Event.BranchUpdated event on GlobalBus */
|
||||
@@ -115,3 +119,104 @@ describeVcs("Vcs", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Vcs diff", () => {
|
||||
afterEach(() => Instance.disposeAll())
|
||||
|
||||
test("defaultBranch() falls back to main", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await $`git branch -M main`.cwd(tmp.path).quiet()
|
||||
|
||||
await withVcsOnly(tmp.path, async (rt) => {
|
||||
const branch = await rt.runPromise(VcsService.use((s) => s.defaultBranch()))
|
||||
expect(branch).toBe("main")
|
||||
})
|
||||
})
|
||||
|
||||
test("defaultBranch() uses init.defaultBranch when available", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await $`git branch -M trunk`.cwd(tmp.path).quiet()
|
||||
await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet()
|
||||
|
||||
await withVcsOnly(tmp.path, async (rt) => {
|
||||
const branch = await rt.runPromise(VcsService.use((s) => s.defaultBranch()))
|
||||
expect(branch).toBe("trunk")
|
||||
})
|
||||
})
|
||||
|
||||
test("detects current branch from the active worktree", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await using wt = await tmpdir()
|
||||
await $`git branch -M main`.cwd(tmp.path).quiet()
|
||||
const dir = path.join(wt.path, "feature")
|
||||
await $`git worktree add -b feature/test ${dir} HEAD`.cwd(tmp.path).quiet()
|
||||
|
||||
await withVcsOnly(dir, async (rt) => {
|
||||
const [branch, base] = await Promise.all([
|
||||
rt.runPromise(VcsService.use((s) => s.branch())),
|
||||
rt.runPromise(VcsService.use((s) => s.defaultBranch())),
|
||||
])
|
||||
expect(branch).toBe("feature/test")
|
||||
expect(base).toBe("main")
|
||||
})
|
||||
})
|
||||
|
||||
test("diff('git') returns uncommitted changes", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "original\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "changed\n", "utf-8")
|
||||
|
||||
await withVcsOnly(tmp.path, async (rt) => {
|
||||
const diff = await rt.runPromise(VcsService.use((s) => s.diff("git")))
|
||||
expect(diff).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
file: "file.txt",
|
||||
status: "modified",
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("diff('git') handles tabs in filenames", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const file = "tab\tfile.txt"
|
||||
await fs.writeFile(path.join(tmp.path, file), "hello\n", "utf-8")
|
||||
|
||||
await withVcsOnly(tmp.path, async (rt) => {
|
||||
const diff = await rt.runPromise(VcsService.use((s) => s.diff("git")))
|
||||
expect(diff).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
file,
|
||||
status: "added",
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("diff('branch') returns changes against default branch", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await $`git branch -M main`.cwd(tmp.path).quiet()
|
||||
await $`git checkout -b feature/test`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(path.join(tmp.path, "branch.txt"), "hello\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet()
|
||||
|
||||
await withVcsOnly(tmp.path, async (rt) => {
|
||||
const diff = await rt.runPromise(VcsService.use((s) => s.diff("branch")))
|
||||
expect(diff).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
file: "branch.txt",
|
||||
status: "added",
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Scheduler } from "../src/scheduler"
|
||||
import { Instance } from "../src/project/instance"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
describe("Scheduler.register", () => {
|
||||
const hour = 60 * 60 * 1000
|
||||
|
||||
test("defaults to instance scope per directory", async () => {
|
||||
await using one = await tmpdir({ git: true })
|
||||
await using two = await tmpdir({ git: true })
|
||||
const runs = { count: 0 }
|
||||
const id = "scheduler.instance." + Math.random().toString(36).slice(2)
|
||||
const task = {
|
||||
id,
|
||||
interval: hour,
|
||||
run: async () => {
|
||||
runs.count += 1
|
||||
},
|
||||
}
|
||||
|
||||
await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: async () => {
|
||||
Scheduler.register(task)
|
||||
await Instance.dispose()
|
||||
},
|
||||
})
|
||||
expect(runs.count).toBe(1)
|
||||
|
||||
await Instance.provide({
|
||||
directory: two.path,
|
||||
fn: async () => {
|
||||
Scheduler.register(task)
|
||||
await Instance.dispose()
|
||||
},
|
||||
})
|
||||
expect(runs.count).toBe(2)
|
||||
})
|
||||
|
||||
test("global scope runs once across instances", async () => {
|
||||
await using one = await tmpdir({ git: true })
|
||||
await using two = await tmpdir({ git: true })
|
||||
const runs = { count: 0 }
|
||||
const id = "scheduler.global." + Math.random().toString(36).slice(2)
|
||||
const task = {
|
||||
id,
|
||||
interval: hour,
|
||||
run: async () => {
|
||||
runs.count += 1
|
||||
},
|
||||
scope: "global" as const,
|
||||
}
|
||||
|
||||
await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: async () => {
|
||||
Scheduler.register(task)
|
||||
await Instance.dispose()
|
||||
},
|
||||
})
|
||||
expect(runs.count).toBe(1)
|
||||
|
||||
await Instance.provide({
|
||||
directory: two.path,
|
||||
fn: async () => {
|
||||
Scheduler.register(task)
|
||||
await Instance.dispose()
|
||||
},
|
||||
})
|
||||
expect(runs.count).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -5,8 +5,8 @@ import { BashTool } from "../../src/tool/bash"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import type { PermissionNext } from "../../src/permission/next"
|
||||
import { Truncate } from "../../src/tool/truncation"
|
||||
import type { PermissionNext } from "../../src/permission"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
|
||||
const ctx = {
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "path"
|
||||
import type { Tool } from "../../src/tool/tool"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { assertExternalDirectory } from "../../src/tool/external-directory"
|
||||
import type { PermissionNext } from "../../src/permission/next"
|
||||
import type { PermissionNext } from "../../src/permission"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
|
||||
const baseCtx: Omit<Tool.Context, "ask"> = {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ReadTool } from "../../src/tool/read"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { PermissionNext } from "../../src/permission/next"
|
||||
import { PermissionNext } from "../../src/permission"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import type { PermissionNext } from "../../src/permission/next"
|
||||
import type { PermissionNext } from "../../src/permission"
|
||||
import type { Tool } from "../../src/tool/tool"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { SkillTool } from "../../src/tool/skill"
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { describe, test, expect, afterAll } from "bun:test"
|
||||
import { Truncate } from "../../src/tool/truncation"
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Effect, FileSystem, Layer } from "effect"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { TruncateEffect } from "../../src/tool/truncate-effect"
|
||||
import { Identifier } from "../../src/id/id"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { writeFileStringScoped } from "../lib/filesystem"
|
||||
|
||||
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
||||
|
||||
@@ -125,36 +129,24 @@ describe("Truncate", () => {
|
||||
|
||||
describe("cleanup", () => {
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
let oldFile: string
|
||||
let recentFile: string
|
||||
const it = testEffect(Layer.mergeAll(TruncateEffect.defaultLayer, NodeFileSystem.layer))
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.unlink(oldFile).catch(() => {})
|
||||
await fs.unlink(recentFile).catch(() => {})
|
||||
})
|
||||
it.effect("deletes files older than 7 days and preserves recent files", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
|
||||
test("deletes files older than 7 days and preserves recent files", async () => {
|
||||
await fs.mkdir(Truncate.DIR, { recursive: true })
|
||||
yield* fs.makeDirectory(Truncate.DIR, { recursive: true })
|
||||
|
||||
// Create an old file (10 days ago)
|
||||
const oldTimestamp = Date.now() - 10 * DAY_MS
|
||||
const oldId = Identifier.create("tool", false, oldTimestamp)
|
||||
oldFile = path.join(Truncate.DIR, oldId)
|
||||
await Filesystem.write(oldFile, "old content")
|
||||
const old = path.join(Truncate.DIR, Identifier.create("tool", false, Date.now() - 10 * DAY_MS))
|
||||
const recent = path.join(Truncate.DIR, Identifier.create("tool", false, Date.now() - 3 * DAY_MS))
|
||||
|
||||
// Create a recent file (3 days ago)
|
||||
const recentTimestamp = Date.now() - 3 * DAY_MS
|
||||
const recentId = Identifier.create("tool", false, recentTimestamp)
|
||||
recentFile = path.join(Truncate.DIR, recentId)
|
||||
await Filesystem.write(recentFile, "recent content")
|
||||
yield* writeFileStringScoped(old, "old content")
|
||||
yield* writeFileStringScoped(recent, "recent content")
|
||||
yield* TruncateEffect.Service.use((s) => s.cleanup())
|
||||
|
||||
await Truncate.cleanup()
|
||||
|
||||
// Old file should be deleted
|
||||
expect(await Filesystem.exists(oldFile)).toBe(false)
|
||||
|
||||
// Recent file should still exist
|
||||
expect(await Filesystem.exists(recentFile)).toBe(true)
|
||||
})
|
||||
expect(yield* fs.exists(old)).toBe(false)
|
||||
expect(yield* fs.exists(recent)).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -172,6 +172,7 @@ import type {
|
||||
TuiSelectSessionResponses,
|
||||
TuiShowToastResponses,
|
||||
TuiSubmitPromptResponses,
|
||||
VcsDiffResponses,
|
||||
VcsGetResponses,
|
||||
WorktreeCreateErrors,
|
||||
WorktreeCreateInput,
|
||||
@@ -3657,6 +3658,38 @@ export class Vcs extends HeyApiClient {
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VCS diff
|
||||
*
|
||||
* Retrieve the current git diff for the working tree or against the default branch.
|
||||
*/
|
||||
public diff<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
mode: "git" | "branch"
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
{ in: "query", key: "mode" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<VcsDiffResponses, unknown, ThrowOnError>({
|
||||
url: "/vcs/diff",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Command extends HeyApiClient {
|
||||
|
||||
@@ -47,6 +47,13 @@ export type EventProjectUpdated = {
|
||||
properties: Project
|
||||
}
|
||||
|
||||
export type EventFileEdited = {
|
||||
type: "file.edited"
|
||||
properties: {
|
||||
file: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventServerInstanceDisposed = {
|
||||
type: "server.instance.disposed"
|
||||
properties: {
|
||||
@@ -54,6 +61,50 @@ export type EventServerInstanceDisposed = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventFileWatcherUpdated = {
|
||||
type: "file.watcher.updated"
|
||||
properties: {
|
||||
file: string
|
||||
event: "add" | "change" | "unlink"
|
||||
}
|
||||
}
|
||||
|
||||
export type PermissionRequest = {
|
||||
id: string
|
||||
sessionID: string
|
||||
permission: string
|
||||
patterns: Array<string>
|
||||
metadata: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
always: Array<string>
|
||||
tool?: {
|
||||
messageID: string
|
||||
callID: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventPermissionAsked = {
|
||||
type: "permission.asked"
|
||||
properties: PermissionRequest
|
||||
}
|
||||
|
||||
export type EventPermissionReplied = {
|
||||
type: "permission.replied"
|
||||
properties: {
|
||||
sessionID: string
|
||||
requestID: string
|
||||
reply: "once" | "always" | "reject"
|
||||
}
|
||||
}
|
||||
|
||||
export type EventVcsBranchUpdated = {
|
||||
type: "vcs.branch.updated"
|
||||
properties: {
|
||||
branch?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type QuestionOption = {
|
||||
/**
|
||||
* Display text (1-5 words, concise)
|
||||
@@ -125,57 +176,6 @@ export type EventQuestionRejected = {
|
||||
}
|
||||
}
|
||||
|
||||
export type PermissionRequest = {
|
||||
id: string
|
||||
sessionID: string
|
||||
permission: string
|
||||
patterns: Array<string>
|
||||
metadata: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
always: Array<string>
|
||||
tool?: {
|
||||
messageID: string
|
||||
callID: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventPermissionAsked = {
|
||||
type: "permission.asked"
|
||||
properties: PermissionRequest
|
||||
}
|
||||
|
||||
export type EventPermissionReplied = {
|
||||
type: "permission.replied"
|
||||
properties: {
|
||||
sessionID: string
|
||||
requestID: string
|
||||
reply: "once" | "always" | "reject"
|
||||
}
|
||||
}
|
||||
|
||||
export type EventFileWatcherUpdated = {
|
||||
type: "file.watcher.updated"
|
||||
properties: {
|
||||
file: string
|
||||
event: "add" | "change" | "unlink"
|
||||
}
|
||||
}
|
||||
|
||||
export type EventVcsBranchUpdated = {
|
||||
type: "vcs.branch.updated"
|
||||
properties: {
|
||||
branch?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventFileEdited = {
|
||||
type: "file.edited"
|
||||
properties: {
|
||||
file: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventServerConnected = {
|
||||
type: "server.connected"
|
||||
properties: {
|
||||
@@ -961,15 +961,15 @@ export type Event =
|
||||
| EventInstallationUpdated
|
||||
| EventInstallationUpdateAvailable
|
||||
| EventProjectUpdated
|
||||
| EventFileEdited
|
||||
| EventServerInstanceDisposed
|
||||
| EventFileWatcherUpdated
|
||||
| EventPermissionAsked
|
||||
| EventPermissionReplied
|
||||
| EventVcsBranchUpdated
|
||||
| EventQuestionAsked
|
||||
| EventQuestionReplied
|
||||
| EventQuestionRejected
|
||||
| EventPermissionAsked
|
||||
| EventPermissionReplied
|
||||
| EventFileWatcherUpdated
|
||||
| EventVcsBranchUpdated
|
||||
| EventFileEdited
|
||||
| EventServerConnected
|
||||
| EventGlobalDisposed
|
||||
| EventLspClientDiagnostics
|
||||
@@ -1861,7 +1861,8 @@ export type Path = {
|
||||
}
|
||||
|
||||
export type VcsInfo = {
|
||||
branch: string
|
||||
branch?: string
|
||||
default_branch?: string
|
||||
}
|
||||
|
||||
export type Command = {
|
||||
@@ -4799,6 +4800,26 @@ export type VcsGetResponses = {
|
||||
|
||||
export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses]
|
||||
|
||||
export type VcsDiffData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
mode: "git" | "branch"
|
||||
}
|
||||
url: "/vcs/diff"
|
||||
}
|
||||
|
||||
export type VcsDiffResponses = {
|
||||
/**
|
||||
* VCS diff
|
||||
*/
|
||||
200: Array<FileDiff>
|
||||
}
|
||||
|
||||
export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses]
|
||||
|
||||
export type CommandListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
|
||||
@@ -7043,6 +7043,25 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.file.edited": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "file.edited"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["file"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.server.instance.disposed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -7062,6 +7081,149 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.file.watcher.updated": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "file.watcher.updated"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string"
|
||||
},
|
||||
"event": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"const": "add"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"const": "change"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"const": "unlink"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["file", "event"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"PermissionRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^per.*"
|
||||
},
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"pattern": "^ses.*"
|
||||
},
|
||||
"permission": {
|
||||
"type": "string"
|
||||
},
|
||||
"patterns": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"always": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"messageID": {
|
||||
"type": "string",
|
||||
"pattern": "^msg.*"
|
||||
},
|
||||
"callID": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["messageID", "callID"]
|
||||
}
|
||||
},
|
||||
"required": ["id", "sessionID", "permission", "patterns", "metadata", "always"]
|
||||
},
|
||||
"Event.permission.asked": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "permission.asked"
|
||||
},
|
||||
"properties": {
|
||||
"$ref": "#/components/schemas/PermissionRequest"
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.permission.replied": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "permission.replied"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"pattern": "^ses.*"
|
||||
},
|
||||
"requestID": {
|
||||
"type": "string",
|
||||
"pattern": "^per.*"
|
||||
},
|
||||
"reply": {
|
||||
"type": "string",
|
||||
"enum": ["once", "always", "reject"]
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "requestID", "reply"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.vcs.branch.updated": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "vcs.branch.updated"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branch": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"QuestionOption": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -7212,168 +7374,6 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"PermissionRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^per.*"
|
||||
},
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"pattern": "^ses.*"
|
||||
},
|
||||
"permission": {
|
||||
"type": "string"
|
||||
},
|
||||
"patterns": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"always": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"messageID": {
|
||||
"type": "string",
|
||||
"pattern": "^msg.*"
|
||||
},
|
||||
"callID": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["messageID", "callID"]
|
||||
}
|
||||
},
|
||||
"required": ["id", "sessionID", "permission", "patterns", "metadata", "always"]
|
||||
},
|
||||
"Event.permission.asked": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "permission.asked"
|
||||
},
|
||||
"properties": {
|
||||
"$ref": "#/components/schemas/PermissionRequest"
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.permission.replied": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "permission.replied"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"pattern": "^ses.*"
|
||||
},
|
||||
"requestID": {
|
||||
"type": "string",
|
||||
"pattern": "^per.*"
|
||||
},
|
||||
"reply": {
|
||||
"type": "string",
|
||||
"enum": ["once", "always", "reject"]
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "requestID", "reply"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.file.watcher.updated": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "file.watcher.updated"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string"
|
||||
},
|
||||
"event": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"const": "add"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"const": "change"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"const": "unlink"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["file", "event"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.vcs.branch.updated": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "vcs.branch.updated"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branch": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.file.edited": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "file.edited"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["file"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.server.connected": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9608,9 +9608,24 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.project.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.file.edited"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.server.instance.disposed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.file.watcher.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.permission.asked"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.permission.replied"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.vcs.branch.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.question.asked"
|
||||
},
|
||||
@@ -9620,21 +9635,6 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.question.rejected"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.permission.asked"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.permission.replied"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.file.watcher.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.vcs.branch.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.file.edited"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.server.connected"
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user