mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 09:10:47 -04:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f4934ef43 | |||
| 71c3a7c8f2 | |||
| ecdfff5a42 | |||
| 4b948c5d74 | |||
| cd56c51e2d | |||
| 43e39d7f68 | |||
| 7a17925495 | |||
| 5acb2530b4 | |||
| 44a6787359 | |||
| 42e6b7db32 | |||
| 2c02f8bace | |||
| 2ec20e576b | |||
| 20f47fec7a | |||
| 65210f2d97 | |||
| af0b7ffae7 |
@@ -83,6 +83,7 @@
|
|||||||
"@types/luxon": "catalog:",
|
"@types/luxon": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
|
"tw-animate-css": "1.4.0",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||||
@@ -990,6 +991,7 @@
|
|||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
|
"tw-animate-css": "1.4.0",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||||
@@ -5315,6 +5317,8 @@
|
|||||||
|
|
||||||
"turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="],
|
"turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="],
|
||||||
|
|
||||||
|
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||||
|
|
||||||
"tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="],
|
"tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="],
|
||||||
|
|
||||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-OiWvZ57vuyHwiIKNtW1n1KX+MLmOXVG3x4fLKvUoGQw=",
|
"x86_64-linux": "sha256-rB/CcrBUQVZ08nBFSYA8u2w88rQmTpKxKPkIreDEKgI=",
|
||||||
"aarch64-linux": "sha256-RnPLxVEg/UsL5IeIFWmXMSLUOG6rVrajYxhyDYj1vTA=",
|
"aarch64-linux": "sha256-ZRTphtic8Ip96MnILteFgZAUxjK9O4YfJu2O6u/0H8k=",
|
||||||
"aarch64-darwin": "sha256-KPIgcBA0pTFBPrCTSZgIbvEorbtWcMgXvyX9bFAypVs=",
|
"aarch64-darwin": "sha256-VK5XIzraP0HtqnPwPCejiDKer4ewtNtX1vxP5uuyjSk=",
|
||||||
"x86_64-darwin": "sha256-6jVU7/uVId0VD24MVQ8s8Ill5b6PsKdlBgHg+oceKRg="
|
"x86_64-darwin": "sha256-ZLPHqcCZB1EmxQk95cmUpiODTTKOyi7PSF0yr/rDk6Y="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"@types/luxon": "catalog:",
|
"@types/luxon": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
|
"tw-animate-css": "1.4.0",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||||
|
|||||||
+22
-12
@@ -38,7 +38,7 @@ import { HighlightsProvider } from "@/context/highlights"
|
|||||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||||
import { LayoutProvider } from "@/context/layout"
|
import { LayoutProvider } from "@/context/layout"
|
||||||
import { ModelsProvider } from "@/context/models"
|
import { ModelsProvider } from "@/context/models"
|
||||||
import { NotificationProvider } from "@/context/notification"
|
import { NotificationProvider, useNotification } from "@/context/notification"
|
||||||
import { PermissionProvider } from "@/context/permission"
|
import { PermissionProvider } from "@/context/permission"
|
||||||
import { PromptProvider } from "@/context/prompt"
|
import { PromptProvider } from "@/context/prompt"
|
||||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||||
@@ -316,9 +316,7 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
|
|||||||
return (
|
return (
|
||||||
<PermissionProvider directory={props.directory}>
|
<PermissionProvider directory={props.directory}>
|
||||||
<LayoutProvider>
|
<LayoutProvider>
|
||||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
|
||||||
</NotificationProvider>
|
|
||||||
</LayoutProvider>
|
</LayoutProvider>
|
||||||
</PermissionProvider>
|
</PermissionProvider>
|
||||||
)
|
)
|
||||||
@@ -345,13 +343,23 @@ function NewAppLayout(props: ParentProps) {
|
|||||||
function TargetServerScopedProviders(props: ServerScopedShellProps) {
|
function TargetServerScopedProviders(props: ServerScopedShellProps) {
|
||||||
return (
|
return (
|
||||||
<PermissionProvider directory={props.directory}>
|
<PermissionProvider directory={props.directory}>
|
||||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
|
||||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||||
</NotificationProvider>
|
|
||||||
</PermissionProvider>
|
</PermissionProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) {
|
||||||
|
const notification = useNotification()
|
||||||
|
createEffect(() => {
|
||||||
|
const sessionID = props.sessionID?.()
|
||||||
|
if (!notification.ready() || !sessionID) return
|
||||||
|
if (notification.session.unseenCount(sessionID) === 0) return
|
||||||
|
notification.session.markViewed(sessionID)
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function SessionProviders(props: ParentProps) {
|
function SessionProviders(props: ParentProps) {
|
||||||
return (
|
return (
|
||||||
<TerminalProvider>
|
<TerminalProvider>
|
||||||
@@ -560,11 +568,13 @@ export function AppInterface(props: {
|
|||||||
component={props.router ?? Router}
|
component={props.router ?? Router}
|
||||||
root={(routerProps) => (
|
root={(routerProps) => (
|
||||||
<TabsProvider>
|
<TabsProvider>
|
||||||
<ServerShell>
|
<NotificationProvider>
|
||||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
<ServerShell>
|
||||||
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||||
</Show>
|
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
||||||
</ServerShell>
|
</Show>
|
||||||
|
</ServerShell>
|
||||||
|
</NotificationProvider>
|
||||||
</TabsProvider>
|
</TabsProvider>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const ModelList: Component<{
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
placement="right-start"
|
placement="right-start"
|
||||||
gutter={12}
|
gutter={12}
|
||||||
|
openDelay={0}
|
||||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
|
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
|
||||||
>
|
>
|
||||||
{node}
|
{node}
|
||||||
|
|||||||
@@ -1343,9 +1343,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const agentsLoading = () => props.controls.agents.loading
|
const agentsLoading = () => props.controls.agents.loading
|
||||||
const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading())
|
const agentsShouldFadeIn = createMemo<boolean>((prev) => prev ?? agentsLoading())
|
||||||
const providersLoading = () => props.controls.model.loading
|
const providersLoading = () => props.controls.model.loading
|
||||||
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
|
const providersShouldFadeIn = createMemo<boolean>((prev) => prev ?? providersLoading())
|
||||||
|
|
||||||
const [promptReady] = createResource(
|
const [promptReady] = createResource(
|
||||||
() => prompt.ready.promise,
|
() => prompt.ready.promise,
|
||||||
@@ -1359,6 +1359,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const modelControlState = createMemo<ComposerModelControlState>(() => ({
|
const modelControlState = createMemo<ComposerModelControlState>(() => ({
|
||||||
loading: providersLoading(),
|
loading: providersLoading(),
|
||||||
|
shouldAnimate: providersShouldFadeIn(),
|
||||||
paid: props.controls.model.paid,
|
paid: props.controls.model.paid,
|
||||||
title: language.t("command.model.choose"),
|
title: language.t("command.model.choose"),
|
||||||
keybind: command.keybind("model.choose"),
|
keybind: command.keybind("model.choose"),
|
||||||
@@ -1519,10 +1520,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
{props.toolbar}
|
{props.toolbar}
|
||||||
<ComposerModelControl state={modelControlState()} />
|
<ComposerModelControl state={modelControlState()} />
|
||||||
<Show when={store.mode !== "shell" && showVariantControl()}>
|
<Show when={!providersLoading() && store.mode !== "shell" && showVariantControl()}>
|
||||||
<div
|
<div
|
||||||
data-component="prompt-variant-control"
|
data-component="prompt-variant-control"
|
||||||
classList={{
|
classList={{
|
||||||
|
"animate-in fade-in": providersShouldFadeIn(),
|
||||||
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
|
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
|
||||||
!props.controls.model.selection.variant.current() && !store.variantOpen,
|
!props.controls.model.selection.variant.current() && !store.variantOpen,
|
||||||
}}
|
}}
|
||||||
@@ -1765,7 +1767,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<Show when={!agentsLoading()}>
|
<Show when={!agentsLoading()}>
|
||||||
<div
|
<div
|
||||||
data-component="prompt-agent-control"
|
data-component="prompt-agent-control"
|
||||||
style={agentsShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
classList={{ "animate-in fade-in duration-300": agentsShouldFadeIn() }}
|
||||||
>
|
>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
@@ -1794,7 +1796,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<Show when={store.mode !== "shell"}>
|
<Show when={store.mode !== "shell"}>
|
||||||
<div
|
<div
|
||||||
data-component="prompt-model-control"
|
data-component="prompt-model-control"
|
||||||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={props.controls.model.paid}
|
when={props.controls.model.paid}
|
||||||
@@ -1873,7 +1875,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<Show when={showVariantControl()}>
|
<Show when={showVariantControl()}>
|
||||||
<div
|
<div
|
||||||
data-component="prompt-variant-control"
|
data-component="prompt-variant-control"
|
||||||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
|
||||||
>
|
>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
@@ -1923,6 +1925,7 @@ type ComposerAgentControlState = {
|
|||||||
|
|
||||||
type ComposerModelControlState = {
|
type ComposerModelControlState = {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
|
shouldAnimate: boolean
|
||||||
paid: boolean
|
paid: boolean
|
||||||
title: string
|
title: string
|
||||||
keybind: string
|
keybind: string
|
||||||
@@ -1970,6 +1973,7 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="normal"
|
size="normal"
|
||||||
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
|
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
|
||||||
|
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
|
||||||
style={props.state.style}
|
style={props.state.style}
|
||||||
onClick={props.state.onUnpaidClick}
|
onClick={props.state.onUnpaidClick}
|
||||||
>
|
>
|
||||||
@@ -2000,6 +2004,7 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
|||||||
style: props.state.style,
|
style: props.state.style,
|
||||||
class:
|
class:
|
||||||
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
|
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
|
||||||
|
classList: { "animate-in fade-in": props.state.shouldAnimate },
|
||||||
"data-action": "prompt-model",
|
"data-action": "prompt-model",
|
||||||
}}
|
}}
|
||||||
onClose={props.state.onClose}
|
onClose={props.state.onClose}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
|
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { useServerSDK } from "./server-sdk"
|
import type { ServerSDK } from "./server-sdk"
|
||||||
import { useServerSync } from "./server-sync"
|
import type { ServerSync } from "./server-sync"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
@@ -12,6 +12,11 @@ import { decode64 } from "@/utils/base64"
|
|||||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { playSoundById } from "@/utils/sound"
|
import { playSoundById } from "@/utils/sound"
|
||||||
|
import { useGlobal } from "./global"
|
||||||
|
import { ServerConnection, useServer } from "./server"
|
||||||
|
import { type DraftTab, useTabs } from "./tabs"
|
||||||
|
import { requireServerKey } from "@/utils/session-route"
|
||||||
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
type NotificationBase = {
|
type NotificationBase = {
|
||||||
directory?: string
|
directory?: string
|
||||||
@@ -107,267 +112,360 @@ function buildNotificationIndex(list: Notification[]) {
|
|||||||
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
||||||
name: "Notification",
|
name: "Notification",
|
||||||
gate: false,
|
gate: false,
|
||||||
init: (props: { directory?: Accessor<string | undefined>; sessionID?: Accessor<string | undefined> }) => {
|
init: () => {
|
||||||
const params = useParams()
|
const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
|
||||||
const serverSDK = useServerSDK()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const serverSync = useServerSync()
|
const global = useGlobal()
|
||||||
|
const server = useServer()
|
||||||
|
const tabs = useTabs()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const owner = getOwner()
|
||||||
|
const states = new Map<ServerScope, { dispose: () => void; state: NotificationState }>()
|
||||||
|
|
||||||
const empty: Notification[] = []
|
const activeServer = createMemo(() => {
|
||||||
|
if (params.serverKey) return requireServerKey(params.serverKey)
|
||||||
const currentDirectory = createMemo(() => {
|
if (search.draftId) {
|
||||||
return props.directory?.() ?? decode64(params.dir)
|
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
|
||||||
|
if (draft) return draft.server
|
||||||
|
}
|
||||||
|
return server.key
|
||||||
})
|
})
|
||||||
|
const activeDirectory = createMemo(() => decode64(params.dir))
|
||||||
|
const activeSession = createMemo(() => params.id)
|
||||||
|
|
||||||
const currentSession = createMemo(() => props.sessionID?.() ?? params.id)
|
const ensure = (key: ServerConnection.Key) => {
|
||||||
|
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
|
||||||
const [store, setStore, _, ready] = persisted(
|
if (!conn) throw new Error(`Notification server not found: ${key}`)
|
||||||
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
|
const ctx = global.ensureServerCtx(conn)
|
||||||
createStore({
|
const existing = states.get(ctx.sdk.scope)
|
||||||
list: [] as Notification[],
|
if (existing) return existing.state
|
||||||
}),
|
const root = createRoot(
|
||||||
)
|
(dispose) => ({
|
||||||
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
|
dispose,
|
||||||
|
state: createServerNotificationState({
|
||||||
const meta = { pruned: false, disposed: false }
|
sdk: ctx.sdk,
|
||||||
|
sync: ctx.sync,
|
||||||
const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
|
active: () => server.scope(activeServer()) === ctx.sdk.scope,
|
||||||
setIndex(scope, "unseen", key, unseen)
|
directory: activeDirectory,
|
||||||
setIndex(scope, "unseenCount", key, unseen.length)
|
sessionID: activeSession,
|
||||||
setIndex(
|
platform,
|
||||||
scope,
|
settings,
|
||||||
"unseenHasError",
|
language,
|
||||||
key,
|
}),
|
||||||
unseen.some((notification) => notification.type === "error"),
|
}),
|
||||||
|
owner ?? undefined,
|
||||||
)
|
)
|
||||||
}
|
states.set(ctx.sdk.scope, root)
|
||||||
|
return root.state
|
||||||
const appendToIndex = (notification: Notification) => {
|
|
||||||
if (notification.session) {
|
|
||||||
setIndex("session", "all", notification.session, (all = []) => [...all, notification])
|
|
||||||
if (!notification.viewed) {
|
|
||||||
setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
|
|
||||||
setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
|
|
||||||
if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notification.directory) {
|
|
||||||
setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
|
|
||||||
if (!notification.viewed) {
|
|
||||||
setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
|
|
||||||
setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
|
|
||||||
if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeFromIndex = (notification: Notification) => {
|
|
||||||
if (notification.session) {
|
|
||||||
setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
|
|
||||||
if (!notification.viewed) {
|
|
||||||
const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
|
|
||||||
updateUnseen("session", notification.session, unseen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notification.directory) {
|
|
||||||
setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
|
|
||||||
if (!notification.viewed) {
|
|
||||||
const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
|
|
||||||
updateUnseen("project", notification.directory, unseen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!ready()) return
|
global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
|
||||||
if (meta.pruned) return
|
})
|
||||||
meta.pruned = true
|
|
||||||
const list = pruneNotifications(store.list)
|
createEffect(() => {
|
||||||
batch(() => {
|
const scopes = new Set(global.servers.list().map((conn) => server.scope(ServerConnection.key(conn))))
|
||||||
setStore("list", list)
|
states.forEach((value, scope) => {
|
||||||
setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
|
if (scopes.has(scope)) return
|
||||||
|
value.dispose()
|
||||||
|
states.delete(scope)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const append = (notification: Notification) => {
|
onCleanup(() => states.forEach((value) => value.dispose()))
|
||||||
const list = pruneNotifications([...store.list, notification])
|
|
||||||
const keep = new Set(list)
|
|
||||||
const removed = store.list.filter((n) => !keep.has(n))
|
|
||||||
|
|
||||||
batch(() => {
|
const selected = () => ensure(activeServer())
|
||||||
if (keep.has(notification)) appendToIndex(notification)
|
|
||||||
removed.forEach((n) => removeFromIndex(n))
|
|
||||||
setStore("list", list)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const lookup = async (directory: string, sessionID?: string) => {
|
|
||||||
if (!sessionID) return undefined
|
|
||||||
const sync = serverSync().ensureDirSyncContext(directory)
|
|
||||||
const session = sync.session.get(sessionID)
|
|
||||||
if (session) return session
|
|
||||||
return sync.session
|
|
||||||
.sync(sessionID)
|
|
||||||
.then(() => sync.session.get(sessionID))
|
|
||||||
.catch(() => undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
|
|
||||||
const activeDirectory = currentDirectory()
|
|
||||||
const activeSession = currentSession()
|
|
||||||
if (!activeDirectory) return false
|
|
||||||
if (!activeSession) return false
|
|
||||||
if (!sessionID) return false
|
|
||||||
if (directory !== activeDirectory) return false
|
|
||||||
return sessionID === activeSession
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
|
|
||||||
const sessionID = event.properties.sessionID
|
|
||||||
void lookup(directory, sessionID).then((session) => {
|
|
||||||
if (meta.disposed) return
|
|
||||||
if (!session) return
|
|
||||||
if (session.parentID) return
|
|
||||||
|
|
||||||
if (settings.sounds.agentEnabled()) {
|
|
||||||
void playSoundById(settings.sounds.agent())
|
|
||||||
}
|
|
||||||
|
|
||||||
append({
|
|
||||||
directory,
|
|
||||||
time,
|
|
||||||
viewed: viewedInCurrentSession(directory, sessionID),
|
|
||||||
type: "turn-complete",
|
|
||||||
session: sessionID,
|
|
||||||
})
|
|
||||||
|
|
||||||
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
|
||||||
if (settings.notifications.agent()) {
|
|
||||||
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSessionError = (
|
|
||||||
directory: string,
|
|
||||||
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
|
|
||||||
time: number,
|
|
||||||
) => {
|
|
||||||
const sessionID = event.properties.sessionID
|
|
||||||
void lookup(directory, sessionID).then((session) => {
|
|
||||||
if (meta.disposed) return
|
|
||||||
if (session?.parentID) return
|
|
||||||
|
|
||||||
if (settings.sounds.errorsEnabled()) {
|
|
||||||
void playSoundById(settings.sounds.errors())
|
|
||||||
}
|
|
||||||
|
|
||||||
const error = "error" in event.properties ? event.properties.error : undefined
|
|
||||||
append({
|
|
||||||
directory,
|
|
||||||
time,
|
|
||||||
viewed: viewedInCurrentSession(directory, sessionID),
|
|
||||||
type: "error",
|
|
||||||
session: sessionID ?? "global",
|
|
||||||
error,
|
|
||||||
})
|
|
||||||
const description =
|
|
||||||
session?.title ??
|
|
||||||
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
|
||||||
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
|
||||||
if (settings.notifications.errors()) {
|
|
||||||
void platform.notify(language.t("notification.session.error.title"), description, href)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsub = serverSDK().event.listen((e) => {
|
|
||||||
const event = e.details
|
|
||||||
if (event.type !== "session.idle" && event.type !== "session.error") return
|
|
||||||
|
|
||||||
const directory = e.name
|
|
||||||
const time = Date.now()
|
|
||||||
if (event.type === "session.idle") {
|
|
||||||
handleSessionIdle(directory, event, time)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
handleSessionError(directory, event, time)
|
|
||||||
})
|
|
||||||
onCleanup(() => {
|
|
||||||
meta.disposed = true
|
|
||||||
unsub()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready: () => selected().ready(),
|
||||||
|
ensureServerState: ensure,
|
||||||
session: {
|
session: {
|
||||||
all(session: string) {
|
all: (session: string) => selected().session.all(session),
|
||||||
return index.session.all[session] ?? empty
|
unseen: (session: string) => selected().session.unseen(session),
|
||||||
},
|
unseenCount: (session: string) => selected().session.unseenCount(session),
|
||||||
unseen(session: string) {
|
unseenHasError: (session: string) => selected().session.unseenHasError(session),
|
||||||
return index.session.unseen[session] ?? empty
|
markViewed: (session: string) => selected().session.markViewed(session),
|
||||||
},
|
|
||||||
unseenCount(session: string) {
|
|
||||||
return index.session.unseenCount[session] ?? 0
|
|
||||||
},
|
|
||||||
unseenHasError(session: string) {
|
|
||||||
return index.session.unseenHasError[session] ?? false
|
|
||||||
},
|
|
||||||
markViewed(session: string) {
|
|
||||||
const unseen = index.session.unseen[session] ?? empty
|
|
||||||
if (!unseen.length) return
|
|
||||||
|
|
||||||
const projects = [
|
|
||||||
...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
|
|
||||||
]
|
|
||||||
batch(() => {
|
|
||||||
setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
|
|
||||||
updateUnseen("session", session, [])
|
|
||||||
projects.forEach((directory) => {
|
|
||||||
const next = (index.project.unseen[directory] ?? empty).filter(
|
|
||||||
(notification) => notification.session !== session,
|
|
||||||
)
|
|
||||||
updateUnseen("project", directory, next)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
project: {
|
project: {
|
||||||
all(directory: string) {
|
all: (directory: string) => selected().project.all(directory),
|
||||||
return index.project.all[directory] ?? empty
|
unseen: (directory: string) => selected().project.unseen(directory),
|
||||||
},
|
unseenCount: (directory: string) => selected().project.unseenCount(directory),
|
||||||
unseen(directory: string) {
|
unseenHasError: (directory: string) => selected().project.unseenHasError(directory),
|
||||||
return index.project.unseen[directory] ?? empty
|
markViewed: (directory: string) => selected().project.markViewed(directory),
|
||||||
},
|
|
||||||
unseenCount(directory: string) {
|
|
||||||
return index.project.unseenCount[directory] ?? 0
|
|
||||||
},
|
|
||||||
unseenHasError(directory: string) {
|
|
||||||
return index.project.unseenHasError[directory] ?? false
|
|
||||||
},
|
|
||||||
markViewed(directory: string) {
|
|
||||||
const unseen = index.project.unseen[directory] ?? empty
|
|
||||||
if (!unseen.length) return
|
|
||||||
|
|
||||||
const sessions = [
|
|
||||||
...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
|
|
||||||
]
|
|
||||||
batch(() => {
|
|
||||||
setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
|
|
||||||
updateUnseen("project", directory, [])
|
|
||||||
sessions.forEach((session) => {
|
|
||||||
const next = (index.session.unseen[session] ?? empty).filter(
|
|
||||||
(notification) => notification.directory !== directory,
|
|
||||||
)
|
|
||||||
updateUnseen("session", session, next)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
type NotificationState = ReturnType<typeof createServerNotificationState>
|
||||||
|
|
||||||
|
function createServerNotificationState(input: {
|
||||||
|
sdk: ServerSDK
|
||||||
|
sync: ServerSync
|
||||||
|
active: Accessor<boolean>
|
||||||
|
directory: Accessor<string | undefined>
|
||||||
|
sessionID: Accessor<string | undefined>
|
||||||
|
platform: ReturnType<typeof usePlatform>
|
||||||
|
settings: ReturnType<typeof useSettings>
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}) {
|
||||||
|
const serverSDK = () => input.sdk
|
||||||
|
const serverSync = () => input.sync
|
||||||
|
const platform = input.platform
|
||||||
|
const settings = input.settings
|
||||||
|
const language = input.language
|
||||||
|
|
||||||
|
const empty: Notification[] = []
|
||||||
|
|
||||||
|
const currentDirectory = input.directory
|
||||||
|
const currentSession = input.sessionID
|
||||||
|
|
||||||
|
const [store, setStore, _, ready] = persisted(
|
||||||
|
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
|
||||||
|
createStore({
|
||||||
|
list: [] as Notification[],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
|
||||||
|
|
||||||
|
const meta = { pruned: false, disposed: false }
|
||||||
|
|
||||||
|
const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
|
||||||
|
setIndex(scope, "unseen", key, unseen)
|
||||||
|
setIndex(scope, "unseenCount", key, unseen.length)
|
||||||
|
setIndex(
|
||||||
|
scope,
|
||||||
|
"unseenHasError",
|
||||||
|
key,
|
||||||
|
unseen.some((notification) => notification.type === "error"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendToIndex = (notification: Notification) => {
|
||||||
|
if (notification.session) {
|
||||||
|
setIndex("session", "all", notification.session, (all = []) => [...all, notification])
|
||||||
|
if (!notification.viewed) {
|
||||||
|
setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
|
||||||
|
setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
|
||||||
|
if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.directory) {
|
||||||
|
setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
|
||||||
|
if (!notification.viewed) {
|
||||||
|
setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
|
||||||
|
setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
|
||||||
|
if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFromIndex = (notification: Notification) => {
|
||||||
|
if (notification.session) {
|
||||||
|
setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
|
||||||
|
if (!notification.viewed) {
|
||||||
|
const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
|
||||||
|
updateUnseen("session", notification.session, unseen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.directory) {
|
||||||
|
setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
|
||||||
|
if (!notification.viewed) {
|
||||||
|
const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
|
||||||
|
updateUnseen("project", notification.directory, unseen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready()) return
|
||||||
|
if (meta.pruned) return
|
||||||
|
meta.pruned = true
|
||||||
|
const list = pruneNotifications(store.list)
|
||||||
|
batch(() => {
|
||||||
|
setStore("list", list)
|
||||||
|
setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const append = (notification: Notification) => {
|
||||||
|
const list = pruneNotifications([...store.list, notification])
|
||||||
|
const keep = new Set(list)
|
||||||
|
const removed = store.list.filter((n) => !keep.has(n))
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
if (keep.has(notification)) appendToIndex(notification)
|
||||||
|
removed.forEach((n) => removeFromIndex(n))
|
||||||
|
setStore("list", list)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const lookup = async (directory: string, sessionID?: string) => {
|
||||||
|
if (!sessionID) return undefined
|
||||||
|
const sync = serverSync().ensureDirSyncContext(directory)
|
||||||
|
const session = sync.session.get(sessionID)
|
||||||
|
if (session) return session
|
||||||
|
return sync.session
|
||||||
|
.sync(sessionID)
|
||||||
|
.then(() => sync.session.get(sessionID))
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
|
||||||
|
if (!input.active()) return false
|
||||||
|
const activeDirectory = currentDirectory()
|
||||||
|
const activeSession = currentSession()
|
||||||
|
if (!activeSession) return false
|
||||||
|
if (!sessionID) return false
|
||||||
|
if (activeDirectory && directory !== activeDirectory) return false
|
||||||
|
return sessionID === activeSession
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
|
||||||
|
const sessionID = event.properties.sessionID
|
||||||
|
void lookup(directory, sessionID).then((session) => {
|
||||||
|
if (meta.disposed) return
|
||||||
|
if (!session) return
|
||||||
|
if (session.parentID) return
|
||||||
|
|
||||||
|
if (settings.sounds.agentEnabled()) {
|
||||||
|
void playSoundById(settings.sounds.agent())
|
||||||
|
}
|
||||||
|
|
||||||
|
append({
|
||||||
|
directory,
|
||||||
|
time,
|
||||||
|
viewed: viewedInCurrentSession(directory, sessionID),
|
||||||
|
type: "turn-complete",
|
||||||
|
session: sessionID,
|
||||||
|
})
|
||||||
|
|
||||||
|
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
||||||
|
if (settings.notifications.agent()) {
|
||||||
|
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSessionError = (
|
||||||
|
directory: string,
|
||||||
|
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
|
||||||
|
time: number,
|
||||||
|
) => {
|
||||||
|
const sessionID = event.properties.sessionID
|
||||||
|
void lookup(directory, sessionID).then((session) => {
|
||||||
|
if (meta.disposed) return
|
||||||
|
if (session?.parentID) return
|
||||||
|
|
||||||
|
if (settings.sounds.errorsEnabled()) {
|
||||||
|
void playSoundById(settings.sounds.errors())
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = "error" in event.properties ? event.properties.error : undefined
|
||||||
|
append({
|
||||||
|
directory,
|
||||||
|
time,
|
||||||
|
viewed: viewedInCurrentSession(directory, sessionID),
|
||||||
|
type: "error",
|
||||||
|
session: sessionID ?? "global",
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
const description =
|
||||||
|
session?.title ??
|
||||||
|
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
||||||
|
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
||||||
|
if (settings.notifications.errors()) {
|
||||||
|
void platform.notify(language.t("notification.session.error.title"), description, href)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsub = serverSDK().event.listen((e) => {
|
||||||
|
const event = e.details
|
||||||
|
if (event.type !== "session.idle" && event.type !== "session.error") return
|
||||||
|
|
||||||
|
const directory = e.name
|
||||||
|
const time = Date.now()
|
||||||
|
if (event.type === "session.idle") {
|
||||||
|
handleSessionIdle(directory, event, time)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleSessionError(directory, event, time)
|
||||||
|
})
|
||||||
|
onCleanup(() => {
|
||||||
|
meta.disposed = true
|
||||||
|
unsub()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
session: {
|
||||||
|
all(session: string) {
|
||||||
|
return index.session.all[session] ?? empty
|
||||||
|
},
|
||||||
|
unseen(session: string) {
|
||||||
|
return index.session.unseen[session] ?? empty
|
||||||
|
},
|
||||||
|
unseenCount(session: string) {
|
||||||
|
return index.session.unseenCount[session] ?? 0
|
||||||
|
},
|
||||||
|
unseenHasError(session: string) {
|
||||||
|
return index.session.unseenHasError[session] ?? false
|
||||||
|
},
|
||||||
|
markViewed(session: string) {
|
||||||
|
const unseen = index.session.unseen[session] ?? empty
|
||||||
|
if (!unseen.length) return
|
||||||
|
|
||||||
|
const projects = [
|
||||||
|
...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
|
||||||
|
]
|
||||||
|
batch(() => {
|
||||||
|
setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
|
||||||
|
updateUnseen("session", session, [])
|
||||||
|
projects.forEach((directory) => {
|
||||||
|
const next = (index.project.unseen[directory] ?? empty).filter(
|
||||||
|
(notification) => notification.session !== session,
|
||||||
|
)
|
||||||
|
updateUnseen("project", directory, next)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
all(directory: string) {
|
||||||
|
return index.project.all[directory] ?? empty
|
||||||
|
},
|
||||||
|
unseen(directory: string) {
|
||||||
|
return index.project.unseen[directory] ?? empty
|
||||||
|
},
|
||||||
|
unseenCount(directory: string) {
|
||||||
|
return index.project.unseenCount[directory] ?? 0
|
||||||
|
},
|
||||||
|
unseenHasError(directory: string) {
|
||||||
|
return index.project.unseenHasError[directory] ?? false
|
||||||
|
},
|
||||||
|
markViewed(directory: string) {
|
||||||
|
const unseen = index.project.unseen[directory] ?? empty
|
||||||
|
if (!unseen.length) return
|
||||||
|
|
||||||
|
const sessions = [
|
||||||
|
...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
|
||||||
|
]
|
||||||
|
batch(() => {
|
||||||
|
setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
|
||||||
|
updateUnseen("project", directory, [])
|
||||||
|
sessions.forEach((session) => {
|
||||||
|
const next = (index.session.unseen[session] ?? empty).filter(
|
||||||
|
(notification) => notification.directory !== directory,
|
||||||
|
)
|
||||||
|
updateUnseen("session", session, next)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@import "@opencode-ai/ui/styles/tailwind";
|
@import "@opencode-ai/ui/styles/tailwind";
|
||||||
@import "@opencode-ai/session-ui/styles";
|
@import "@opencode-ai/session-ui/styles";
|
||||||
@import "@opencode-ai/ui/v2/styles/tailwind.css";
|
@import "@opencode-ai/ui/v2/styles/tailwind.css";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "JetBrainsMono Nerd Font Mono";
|
font-family: "JetBrainsMono Nerd Font Mono";
|
||||||
@@ -131,13 +132,4 @@
|
|||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fade-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import { archiveHomeSession } from "./home-session-archive"
|
|||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
|
|
||||||
const HOME_SESSION_LIMIT = 64
|
const HOME_SESSION_LIMIT = 64
|
||||||
|
const SHOW_HOME_SESSION_ARCHIVE = false
|
||||||
const HOME_ROW_LAYOUT =
|
const HOME_ROW_LAYOUT =
|
||||||
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
|
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
|
||||||
const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0`
|
const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0`
|
||||||
@@ -341,15 +342,15 @@ export function NewHome() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
|
function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
|
||||||
if (ServerConnection.key(conn) !== server.key) return 0
|
const state = notification.ensureServerState(ServerConnection.key(conn))
|
||||||
return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0)
|
return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
|
function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
|
||||||
if (ServerConnection.key(conn) !== server.key) return
|
const state = notification.ensureServerState(ServerConnection.key(conn))
|
||||||
directories(project)
|
directories(project)
|
||||||
.filter((directory) => notification.project.unseenCount(directory) > 0)
|
.filter((directory) => state.project.unseenCount(directory) > 0)
|
||||||
.forEach((directory) => notification.project.markViewed(directory))
|
.forEach((directory) => state.project.markViewed(directory))
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSession(session: Session) {
|
function openSession(session: Session) {
|
||||||
@@ -1189,22 +1190,24 @@ function HomeSessionRow(props: {
|
|||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
<div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
|
<Show when={SHOW_HOME_SESSION_ARCHIVE}>
|
||||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
|
<div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
|
||||||
<IconButtonV2
|
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
|
||||||
data-action="home-session-archive"
|
<IconButtonV2
|
||||||
variant="ghost-muted"
|
data-action="home-session-archive"
|
||||||
size="large"
|
variant="ghost-muted"
|
||||||
icon={<IconV2 name="archive" />}
|
size="large"
|
||||||
aria-label={language.t("common.archive")}
|
icon={<IconV2 name="archive" />}
|
||||||
onClick={(event) => {
|
aria-label={language.t("common.archive")}
|
||||||
event.preventDefault()
|
onClick={(event) => {
|
||||||
event.stopPropagation()
|
event.preventDefault()
|
||||||
void props.archiveSession(props.record.session)
|
event.stopPropagation()
|
||||||
}}
|
void props.archiveSession(props.record.session)
|
||||||
/>
|
}}
|
||||||
</TooltipV2>
|
/>
|
||||||
</div>
|
</TooltipV2>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,18 @@
|
|||||||
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { DebugBar } from "@/components/debug-bar"
|
import { DebugBar } from "@/components/debug-bar"
|
||||||
import { HelpButton } from "@/components/help-button"
|
import { HelpButton } from "@/components/help-button"
|
||||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||||
import { useNotification } from "@/context/notification"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { setNavigate } from "@/utils/notification-click"
|
import { setNavigate } from "@/utils/notification-click"
|
||||||
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
||||||
|
|
||||||
export default function NewLayout(props: ParentProps) {
|
export default function NewLayout(props: ParentProps) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const notification = useNotification()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const params = useParams<{ id?: string }>()
|
|
||||||
setNavigate(navigate)
|
setNavigate(navigate)
|
||||||
|
|
||||||
createEffect(() => setV2Toast(true))
|
createEffect(() => setV2Toast(true))
|
||||||
createEffect(() => {
|
|
||||||
if (!notification.ready() || !params.id) return
|
|
||||||
if (notification.session.unseenCount(params.id) === 0) return
|
|
||||||
notification.session.markViewed(params.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
const update: TitlebarUpdate = {
|
const update: TitlebarUpdate = {
|
||||||
version: () => {
|
version: () => {
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
|
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
|
||||||
import { Api } from "@opencode-ai/server/api"
|
import { ClientApi } from "../src/contract"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { HttpApi } from "effect/unstable/httpapi"
|
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
const contract = compile(HttpApi.make("opencode-client").add(Api.groups["server.session"]), {
|
const contract = compile(ClientApi, {
|
||||||
groupNames: { "server.session": "sessions" },
|
groupNames: { "server.session": "sessions", "server.event": "events" },
|
||||||
})
|
})
|
||||||
|
|
||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
Effect.all(
|
Effect.all(
|
||||||
[
|
[
|
||||||
write(emitPromise(contract), fileURLToPath(new URL("../src/generated", import.meta.url))),
|
|
||||||
write(
|
write(
|
||||||
emitEffectImported(contract, { module: "../contract", group: "SessionGroup" }),
|
emitPromise(contract, {
|
||||||
|
outputTypes: {
|
||||||
|
"events.subscribe": {
|
||||||
|
name: "OpenCodeEventEncoded",
|
||||||
|
import: 'import type { OpenCodeEventEncoded } from "@opencode-ai/protocol/groups/event"',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
fileURLToPath(new URL("../src/generated", import.meta.url)),
|
||||||
|
),
|
||||||
|
write(
|
||||||
|
emitEffectImported(contract, { module: "../contract", api: "ClientApi" }),
|
||||||
fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
|
fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { makeDefaultApi } from "@opencode-ai/protocol/api"
|
import { makeDefaultApi } from "@opencode-ai/protocol/api"
|
||||||
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
|
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
|
||||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||||
|
|
||||||
class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware>()(
|
class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware>()(
|
||||||
"@opencode-ai/client/LocationMiddleware",
|
"@opencode-ai/client/LocationMiddleware",
|
||||||
@@ -17,3 +17,5 @@ const Api = makeDefaultApi({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const SessionGroup = Api.groups["server.session"]
|
export const SessionGroup = Api.groups["server.session"]
|
||||||
|
export const EventGroup = Api.groups["server.event"]
|
||||||
|
export const ClientApi = HttpApi.make("opencode-client").add(SessionGroup).add(EventGroup)
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ export { Session } from "@opencode-ai/schema/session"
|
|||||||
export { SessionInput } from "@opencode-ai/schema/session-input"
|
export { SessionInput } from "@opencode-ai/schema/session-input"
|
||||||
export { SessionMessage } from "@opencode-ai/schema/session-message"
|
export { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
export { Prompt } from "@opencode-ai/schema/prompt"
|
export { Prompt } from "@opencode-ai/schema/prompt"
|
||||||
|
export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||||
|
|||||||
@@ -2,13 +2,11 @@
|
|||||||
import { Effect, Stream, Schema } from "effect"
|
import { Effect, Stream, Schema } from "effect"
|
||||||
import { Sse } from "effect/unstable/encoding"
|
import { Sse } from "effect/unstable/encoding"
|
||||||
import { HttpClientError } from "effect/unstable/http"
|
import { HttpClientError } from "effect/unstable/http"
|
||||||
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
|
import { HttpApiClient } from "effect/unstable/httpapi"
|
||||||
import { SessionGroup } from "../contract"
|
import { ClientApi } from "../contract"
|
||||||
import { ClientError } from "./client-error"
|
import { ClientError } from "./client-error"
|
||||||
|
|
||||||
const Api = HttpApi.make("generated").add(SessionGroup)
|
type RawClient = HttpApiClient.ForApi<typeof ClientApi>
|
||||||
|
|
||||||
type RawClient = HttpApiClient.ForApi<typeof Api>
|
|
||||||
|
|
||||||
const mapClientError = <E>(error: E) =>
|
const mapClientError = <E>(error: E) =>
|
||||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||||
@@ -149,12 +147,24 @@ const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12I
|
|||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
||||||
type Endpoint0_13Input = {
|
type Endpoint0_13Input = {
|
||||||
readonly sessionID: Endpoint0_13Request["params"]["sessionID"]
|
readonly sessionID: Endpoint0_13Request["params"]["sessionID"]
|
||||||
|
readonly limit?: Endpoint0_13Request["query"]["limit"]
|
||||||
readonly after?: Endpoint0_13Request["query"]["after"]
|
readonly after?: Endpoint0_13Request["query"]["after"]
|
||||||
}
|
}
|
||||||
const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) =>
|
const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) =>
|
||||||
|
raw["session.history"]({
|
||||||
|
params: { sessionID: input.sessionID },
|
||||||
|
query: { limit: input.limit, after: input.after },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||||
|
type Endpoint0_14Input = {
|
||||||
|
readonly sessionID: Endpoint0_14Request["params"]["sessionID"]
|
||||||
|
readonly after?: Endpoint0_14Request["query"]["after"]
|
||||||
|
}
|
||||||
|
const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) =>
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe(
|
raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
@@ -162,17 +172,17 @@ const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13I
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||||
type Endpoint0_14Input = { readonly sessionID: Endpoint0_14Request["params"]["sessionID"] }
|
type Endpoint0_15Input = { readonly sessionID: Endpoint0_15Request["params"]["sessionID"] }
|
||||||
const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) =>
|
const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) =>
|
||||||
raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
type Endpoint0_16Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||||
type Endpoint0_15Input = {
|
type Endpoint0_16Input = {
|
||||||
readonly sessionID: Endpoint0_15Request["params"]["sessionID"]
|
readonly sessionID: Endpoint0_16Request["params"]["sessionID"]
|
||||||
readonly messageID: Endpoint0_15Request["params"]["messageID"]
|
readonly messageID: Endpoint0_16Request["params"]["messageID"]
|
||||||
}
|
}
|
||||||
const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) =>
|
const Endpoint0_16 = (raw: RawClient["server.session"]) => (input: Endpoint0_16Input) =>
|
||||||
raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe(
|
raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
@@ -192,12 +202,26 @@ const adaptGroup0 = (raw: RawClient["server.session"]) => ({
|
|||||||
clear: Endpoint0_10(raw),
|
clear: Endpoint0_10(raw),
|
||||||
commit: Endpoint0_11(raw),
|
commit: Endpoint0_11(raw),
|
||||||
context: Endpoint0_12(raw),
|
context: Endpoint0_12(raw),
|
||||||
events: Endpoint0_13(raw),
|
history: Endpoint0_13(raw),
|
||||||
interrupt: Endpoint0_14(raw),
|
events: Endpoint0_14(raw),
|
||||||
message: Endpoint0_15(raw),
|
interrupt: Endpoint0_15(raw),
|
||||||
|
message: Endpoint0_16(raw),
|
||||||
})
|
})
|
||||||
|
|
||||||
const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })
|
const Endpoint1_0 = (raw: RawClient["server.event"]) => () =>
|
||||||
|
Stream.unwrap(
|
||||||
|
raw["event.subscribe"]({}).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const adaptGroup1 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint1_0(raw) })
|
||||||
|
|
||||||
|
const adaptClient = (raw: RawClient) => ({
|
||||||
|
sessions: adaptGroup0(raw["server.session"]),
|
||||||
|
events: adaptGroup1(raw["server.event"]),
|
||||||
|
})
|
||||||
|
|
||||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||||
HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))
|
HttpApiClient.make(ClientApi, options).pipe(Effect.map(adaptClient))
|
||||||
|
|||||||
@@ -24,12 +24,15 @@ import type {
|
|||||||
SessionsCommitOutput,
|
SessionsCommitOutput,
|
||||||
SessionsContextInput,
|
SessionsContextInput,
|
||||||
SessionsContextOutput,
|
SessionsContextOutput,
|
||||||
|
SessionsHistoryInput,
|
||||||
|
SessionsHistoryOutput,
|
||||||
SessionsEventsInput,
|
SessionsEventsInput,
|
||||||
SessionsEventsOutput,
|
SessionsEventsOutput,
|
||||||
SessionsInterruptInput,
|
SessionsInterruptInput,
|
||||||
SessionsInterruptOutput,
|
SessionsInterruptOutput,
|
||||||
SessionsMessageInput,
|
SessionsMessageInput,
|
||||||
SessionsMessageOutput,
|
SessionsMessageOutput,
|
||||||
|
EventsSubscribeOutput,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import { ClientError } from "./client-error"
|
import { ClientError } from "./client-error"
|
||||||
|
|
||||||
@@ -324,6 +327,18 @@ export function make(options: ClientOptions) {
|
|||||||
},
|
},
|
||||||
requestOptions,
|
requestOptions,
|
||||||
).then((value) => value.data),
|
).then((value) => value.data),
|
||||||
|
history: (input: SessionsHistoryInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<SessionsHistoryOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/history`,
|
||||||
|
query: { limit: input.limit, after: input.after },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [404, 400, 401],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
events: (input: SessionsEventsInput, requestOptions?: RequestOptions): AsyncIterable<SessionsEventsOutput> =>
|
events: (input: SessionsEventsInput, requestOptions?: RequestOptions): AsyncIterable<SessionsEventsOutput> =>
|
||||||
sse<SessionsEventsOutput>(
|
sse<SessionsEventsOutput>(
|
||||||
{
|
{
|
||||||
@@ -359,6 +374,13 @@ export function make(options: ClientOptions) {
|
|||||||
requestOptions,
|
requestOptions,
|
||||||
).then((value) => value.data),
|
).then((value) => value.data),
|
||||||
},
|
},
|
||||||
|
events: {
|
||||||
|
subscribe: (requestOptions?: RequestOptions): AsyncIterable<EventsSubscribeOutput> =>
|
||||||
|
sse<EventsSubscribeOutput>(
|
||||||
|
{ method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { OpenCodeEventEncoded } from "@opencode-ai/protocol/groups/event"
|
||||||
|
|
||||||
export type JsonValue =
|
export type JsonValue =
|
||||||
| null
|
| null
|
||||||
| boolean
|
| boolean
|
||||||
@@ -67,7 +69,7 @@ export const isUnknownError = (value: unknown): value is UnknownError =>
|
|||||||
export type SessionsListInput = {
|
export type SessionsListInput = {
|
||||||
readonly workspace?: {
|
readonly workspace?: {
|
||||||
readonly workspace?: string | undefined
|
readonly workspace?: string | undefined
|
||||||
readonly limit?: string | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
readonly directory?: string | undefined
|
readonly directory?: string | undefined
|
||||||
@@ -77,7 +79,7 @@ export type SessionsListInput = {
|
|||||||
}["workspace"]
|
}["workspace"]
|
||||||
readonly limit?: {
|
readonly limit?: {
|
||||||
readonly workspace?: string | undefined
|
readonly workspace?: string | undefined
|
||||||
readonly limit?: string | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
readonly directory?: string | undefined
|
readonly directory?: string | undefined
|
||||||
@@ -87,7 +89,7 @@ export type SessionsListInput = {
|
|||||||
}["limit"]
|
}["limit"]
|
||||||
readonly order?: {
|
readonly order?: {
|
||||||
readonly workspace?: string | undefined
|
readonly workspace?: string | undefined
|
||||||
readonly limit?: string | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
readonly directory?: string | undefined
|
readonly directory?: string | undefined
|
||||||
@@ -97,7 +99,7 @@ export type SessionsListInput = {
|
|||||||
}["order"]
|
}["order"]
|
||||||
readonly search?: {
|
readonly search?: {
|
||||||
readonly workspace?: string | undefined
|
readonly workspace?: string | undefined
|
||||||
readonly limit?: string | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
readonly directory?: string | undefined
|
readonly directory?: string | undefined
|
||||||
@@ -107,7 +109,7 @@ export type SessionsListInput = {
|
|||||||
}["search"]
|
}["search"]
|
||||||
readonly directory?: {
|
readonly directory?: {
|
||||||
readonly workspace?: string | undefined
|
readonly workspace?: string | undefined
|
||||||
readonly limit?: string | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
readonly directory?: string | undefined
|
readonly directory?: string | undefined
|
||||||
@@ -117,7 +119,7 @@ export type SessionsListInput = {
|
|||||||
}["directory"]
|
}["directory"]
|
||||||
readonly project?: {
|
readonly project?: {
|
||||||
readonly workspace?: string | undefined
|
readonly workspace?: string | undefined
|
||||||
readonly limit?: string | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
readonly directory?: string | undefined
|
readonly directory?: string | undefined
|
||||||
@@ -127,7 +129,7 @@ export type SessionsListInput = {
|
|||||||
}["project"]
|
}["project"]
|
||||||
readonly subpath?: {
|
readonly subpath?: {
|
||||||
readonly workspace?: string | undefined
|
readonly workspace?: string | undefined
|
||||||
readonly limit?: string | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
readonly directory?: string | undefined
|
readonly directory?: string | undefined
|
||||||
@@ -137,7 +139,7 @@ export type SessionsListInput = {
|
|||||||
}["subpath"]
|
}["subpath"]
|
||||||
readonly cursor?: {
|
readonly cursor?: {
|
||||||
readonly workspace?: string | undefined
|
readonly workspace?: string | undefined
|
||||||
readonly limit?: string | undefined
|
readonly limit?: number | undefined
|
||||||
readonly order?: "asc" | "desc" | undefined
|
readonly order?: "asc" | "desc" | undefined
|
||||||
readonly search?: string | undefined
|
readonly search?: string | undefined
|
||||||
readonly directory?: string | undefined
|
readonly directory?: string | undefined
|
||||||
@@ -591,9 +593,469 @@ export type SessionsContextOutput = {
|
|||||||
>
|
>
|
||||||
}["data"]
|
}["data"]
|
||||||
|
|
||||||
|
export type SessionsHistoryInput = {
|
||||||
|
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||||
|
readonly limit?: { readonly limit?: number | undefined; readonly after?: number | undefined }["limit"]
|
||||||
|
readonly after?: { readonly limit?: number | undefined; readonly after?: number | undefined }["after"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionsHistoryOutput = {
|
||||||
|
readonly data: ReadonlyArray<
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.agent.switched"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly messageID: string
|
||||||
|
readonly agent: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.model.switched"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly messageID: string
|
||||||
|
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.moved"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly subdirectory?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.prompted"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly messageID: string
|
||||||
|
readonly prompt: {
|
||||||
|
readonly text: string
|
||||||
|
readonly files?: ReadonlyArray<{
|
||||||
|
readonly uri: string
|
||||||
|
readonly mime: string
|
||||||
|
readonly name?: string
|
||||||
|
readonly description?: string
|
||||||
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
|
}>
|
||||||
|
readonly agents?: ReadonlyArray<{
|
||||||
|
readonly name: string
|
||||||
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
readonly delivery: "steer" | "queue"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.prompt.admitted"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly messageID: string
|
||||||
|
readonly prompt: {
|
||||||
|
readonly text: string
|
||||||
|
readonly files?: ReadonlyArray<{
|
||||||
|
readonly uri: string
|
||||||
|
readonly mime: string
|
||||||
|
readonly name?: string
|
||||||
|
readonly description?: string
|
||||||
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
|
}>
|
||||||
|
readonly agents?: ReadonlyArray<{
|
||||||
|
readonly name: string
|
||||||
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
readonly delivery: "steer" | "queue"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.context.updated"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly messageID: string
|
||||||
|
readonly text: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.synthetic"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly messageID: string
|
||||||
|
readonly text: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.shell.started"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly messageID: string
|
||||||
|
readonly callID: string
|
||||||
|
readonly command: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.shell.ended"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly callID: string
|
||||||
|
readonly output: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.step.started"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly agent: string
|
||||||
|
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||||
|
readonly snapshot?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.step.ended"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly finish: string
|
||||||
|
readonly cost: number
|
||||||
|
readonly tokens: {
|
||||||
|
readonly input: number
|
||||||
|
readonly output: number
|
||||||
|
readonly reasoning: number
|
||||||
|
readonly cache: { readonly read: number; readonly write: number }
|
||||||
|
}
|
||||||
|
readonly snapshot?: string
|
||||||
|
readonly files?: ReadonlyArray<string>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.step.failed"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly error: { readonly type: "unknown"; readonly message: string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.text.started"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly textID: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.text.ended"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly textID: string
|
||||||
|
readonly text: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.tool.input.started"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly callID: string
|
||||||
|
readonly name: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.tool.input.ended"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly callID: string
|
||||||
|
readonly text: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.tool.called"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly callID: string
|
||||||
|
readonly tool: string
|
||||||
|
readonly input: { readonly [x: string]: JsonValue }
|
||||||
|
readonly provider: {
|
||||||
|
readonly executed: boolean
|
||||||
|
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.tool.progress"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly callID: string
|
||||||
|
readonly structured: { readonly [x: string]: JsonValue }
|
||||||
|
readonly content: ReadonlyArray<
|
||||||
|
| { readonly type: "text"; readonly text: string }
|
||||||
|
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
||||||
|
>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.tool.success"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly callID: string
|
||||||
|
readonly structured: { readonly [x: string]: JsonValue }
|
||||||
|
readonly content: ReadonlyArray<
|
||||||
|
| { readonly type: "text"; readonly text: string }
|
||||||
|
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
||||||
|
>
|
||||||
|
readonly outputPaths?: ReadonlyArray<string>
|
||||||
|
readonly result?: JsonValue
|
||||||
|
readonly provider: {
|
||||||
|
readonly executed: boolean
|
||||||
|
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.tool.failed"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly callID: string
|
||||||
|
readonly error: { readonly type: "unknown"; readonly message: string }
|
||||||
|
readonly result?: JsonValue
|
||||||
|
readonly provider: {
|
||||||
|
readonly executed: boolean
|
||||||
|
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.reasoning.started"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly reasoningID: string
|
||||||
|
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.reasoning.ended"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly assistantMessageID: string
|
||||||
|
readonly reasoningID: string
|
||||||
|
readonly text: string
|
||||||
|
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.retried"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly attempt: number
|
||||||
|
readonly error: {
|
||||||
|
readonly message: string
|
||||||
|
readonly statusCode?: number
|
||||||
|
readonly isRetryable: boolean
|
||||||
|
readonly responseHeaders?: { readonly [x: string]: string }
|
||||||
|
readonly responseBody?: string
|
||||||
|
readonly metadata?: { readonly [x: string]: string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.compaction.started"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly messageID: string
|
||||||
|
readonly reason: "auto" | "manual"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.compaction.ended"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly messageID: string
|
||||||
|
readonly reason: "auto" | "manual"
|
||||||
|
readonly text: string
|
||||||
|
readonly recent: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.revert.staged"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: {
|
||||||
|
readonly timestamp: number
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly revert: {
|
||||||
|
readonly messageID: string
|
||||||
|
readonly partID?: string
|
||||||
|
readonly snapshot?: string
|
||||||
|
readonly diff?: string
|
||||||
|
readonly files?: ReadonlyArray<{
|
||||||
|
readonly path: string
|
||||||
|
readonly status: "added" | "modified" | "deleted"
|
||||||
|
readonly additions: number
|
||||||
|
readonly deletions: number
|
||||||
|
readonly patch: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.revert.cleared"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: { readonly timestamp: number; readonly sessionID: string }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly id: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly type: "session.next.revert.committed"
|
||||||
|
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||||
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
|
readonly data: { readonly timestamp: number; readonly sessionID: string; readonly messageID: string }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
readonly hasMore: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionsEventsInput = {
|
export type SessionsEventsInput = {
|
||||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||||
readonly after?: { readonly after?: string | undefined }["after"]
|
readonly after?: { readonly after?: number | undefined }["after"]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionsEventsOutput =
|
export type SessionsEventsOutput =
|
||||||
@@ -1206,3 +1668,5 @@ export type SessionsMessageOutput = {
|
|||||||
readonly time: { readonly created: number }
|
readonly time: { readonly created: number }
|
||||||
}
|
}
|
||||||
}["data"]
|
}["data"]
|
||||||
|
|
||||||
|
export type EventsSubscribeOutput = OpenCodeEventEncoded
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from "./generated/index"
|
export * from "./generated/index"
|
||||||
|
export type { EventsSubscribeOutput as OpenCodeEvent } from "./generated/types"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { Workspace } from "@opencode-ai/schema/workspace"
|
|||||||
import { Api } from "@opencode-ai/server/api"
|
import { Api } from "@opencode-ai/server/api"
|
||||||
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
|
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
|
||||||
import { HttpApi } from "effect/unstable/httpapi"
|
import { HttpApi } from "effect/unstable/httpapi"
|
||||||
import { SessionGroup } from "../src/contract"
|
import { EventGroup, SessionGroup } from "../src/contract"
|
||||||
|
|
||||||
test("Core and Server reuse the authoritative Schema and Protocol values", () => {
|
test("Core and Server reuse the authoritative Schema and Protocol values", () => {
|
||||||
expect(AgentV2.ID).toBe(Agent.ID)
|
expect(AgentV2.ID).toBe(Agent.ID)
|
||||||
@@ -32,6 +32,7 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
|
|||||||
expect(CorePrompt).toBe(Prompt)
|
expect(CorePrompt).toBe(Prompt)
|
||||||
expect(Api.groups["server.session"].identifier).toBe("server.session")
|
expect(Api.groups["server.session"].identifier).toBe("server.session")
|
||||||
expect(SessionGroup.identifier).toBe(Api.groups["server.session"].identifier)
|
expect(SessionGroup.identifier).toBe(Api.groups["server.session"].identifier)
|
||||||
|
expect(EventGroup.identifier).toBe(Api.groups["server.event"].identifier)
|
||||||
expect(Session.ID.create()).toStartWith("ses_")
|
expect(Session.ID.create()).toStartWith("ses_")
|
||||||
expect(Project.ID.global).toBe("global")
|
expect(Project.ID.global).toBe("global")
|
||||||
expect(Provider.ID.anthropic).toBe("anthropic")
|
expect(Provider.ID.anthropic).toBe("anthropic")
|
||||||
|
|||||||
@@ -15,7 +15,53 @@ test("sessions.get returns the decoded Effect projection", async () => {
|
|||||||
expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
|
expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("events.subscribe exposes and decodes the native Effect event stream", async () => {
|
||||||
|
const httpClient = HttpClient.make((request) =>
|
||||||
|
Effect.succeed(
|
||||||
|
HttpClientResponse.fromWeb(
|
||||||
|
request,
|
||||||
|
new Response(
|
||||||
|
`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` +
|
||||||
|
`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
|
||||||
|
{ headers: { "content-type": "text/event-stream" } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const events = await Effect.gen(function* () {
|
||||||
|
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||||
|
return yield* client.events.subscribe().pipe(Stream.runCollect)
|
||||||
|
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||||
|
|
||||||
|
expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.next.model.switched"])
|
||||||
|
const durable = events[1]
|
||||||
|
if (durable?.type !== "session.next.model.switched") throw new Error("Expected model event")
|
||||||
|
expect(DateTime.toEpochMillis(durable.data.timestamp)).toBe(1_717_171_717_000)
|
||||||
|
expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("events.subscribe terminates on Effect protocol decode failures", async () => {
|
||||||
|
const httpClient = HttpClient.make((request) =>
|
||||||
|
Effect.succeed(
|
||||||
|
HttpClientResponse.fromWeb(
|
||||||
|
request,
|
||||||
|
new Response(`data: {"type":"server.connected"}\n\n`, {
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const error = await Effect.gen(function* () {
|
||||||
|
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||||
|
return yield* client.events.subscribe().pipe(Stream.runCollect, Effect.flip)
|
||||||
|
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||||
|
|
||||||
|
expect(error._tag).toBe("ClientError")
|
||||||
|
})
|
||||||
|
|
||||||
test("session methods retain decoded Effect inputs and outputs", async () => {
|
test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||||
|
const historyQueries: Array<Record<string, string>> = []
|
||||||
|
let historyPage = 0
|
||||||
const httpClient = HttpClient.make((request) => {
|
const httpClient = HttpClient.make((request) => {
|
||||||
const url = request.url
|
const url = request.url
|
||||||
if (url.includes("/event")) {
|
if (url.includes("/event")) {
|
||||||
@@ -28,6 +74,18 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (url.includes("/history")) {
|
||||||
|
historyPage++
|
||||||
|
historyQueries.push(Object.fromEntries(request.urlParams.params))
|
||||||
|
return Effect.succeed(
|
||||||
|
HttpClientResponse.fromWeb(
|
||||||
|
request,
|
||||||
|
Response.json(
|
||||||
|
historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
if (url.includes("/prompt")) {
|
if (url.includes("/prompt")) {
|
||||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
|
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
|
||||||
}
|
}
|
||||||
@@ -72,6 +130,18 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||||||
yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") })
|
yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") })
|
||||||
yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") })
|
yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") })
|
||||||
const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") })
|
const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") })
|
||||||
|
const history = yield* client.sessions.history({
|
||||||
|
sessionID: Session.ID.make("ses_test"),
|
||||||
|
after: 0,
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
const historyNext = history.hasMore
|
||||||
|
? yield* client.sessions.history({
|
||||||
|
sessionID: Session.ID.make("ses_test"),
|
||||||
|
after: history.data.at(-1)?.durable?.seq,
|
||||||
|
limit: 2,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
const events = yield* client.sessions
|
const events = yield* client.sessions
|
||||||
.events({ sessionID: Session.ID.make("ses_test"), after: 0 })
|
.events({ sessionID: Session.ID.make("ses_test"), after: 0 })
|
||||||
.pipe(Stream.runCollect)
|
.pipe(Stream.runCollect)
|
||||||
@@ -80,7 +150,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||||||
sessionID: Session.ID.make("ses_test"),
|
sessionID: Session.ID.make("ses_test"),
|
||||||
messageID: SessionMessage.ID.make("msg_model"),
|
messageID: SessionMessage.ID.make("msg_model"),
|
||||||
})
|
})
|
||||||
return { page, active, created, admitted, context, events, message }
|
return { page, active, created, admitted, context, history, historyNext, events, message }
|
||||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||||
|
|
||||||
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
|
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
|
||||||
@@ -92,10 +162,39 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||||||
expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
|
expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
|
||||||
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
|
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
|
||||||
expect(result.context).toEqual([])
|
expect(result.context).toEqual([])
|
||||||
|
expect(DateTime.toEpochMillis(result.history.data[0].data.timestamp)).toBe(1_717_171_717_000)
|
||||||
|
expect(result.history).toEqual(expect.objectContaining({ hasMore: true }))
|
||||||
|
expect(result.historyNext).toEqual({ data: [], hasMore: false })
|
||||||
|
expect(historyQueries[0]).toEqual({ limit: "1", after: "0" })
|
||||||
|
expect(historyQueries[1]).toEqual({ limit: "2", after: "1" })
|
||||||
expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000)
|
expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000)
|
||||||
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
|
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("sessions.history retains the typed SessionNotFoundError", async () => {
|
||||||
|
const httpClient = HttpClient.make((request) =>
|
||||||
|
Effect.succeed(
|
||||||
|
HttpClientResponse.fromWeb(
|
||||||
|
request,
|
||||||
|
Response.json(
|
||||||
|
{ _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" },
|
||||||
|
{ status: 404 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const error = await Effect.gen(function* () {
|
||||||
|
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||||
|
return yield* client.sessions
|
||||||
|
.history({
|
||||||
|
sessionID: Session.ID.make("ses_missing"),
|
||||||
|
})
|
||||||
|
.pipe(Effect.flip)
|
||||||
|
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||||
|
|
||||||
|
expect(error._tag).toBe("SessionNotFoundError")
|
||||||
|
})
|
||||||
|
|
||||||
const session = {
|
const session = {
|
||||||
data: {
|
data: {
|
||||||
id: "ses_test",
|
id: "ses_test",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { isUnauthorizedError, OpenCode } from "../src"
|
import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src"
|
||||||
|
|
||||||
test("sessions.get returns the wire projection", async () => {
|
test("sessions.get returns the wire projection", async () => {
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
@@ -17,8 +17,38 @@ test("sessions.get returns the wire projection", async () => {
|
|||||||
expect(result.time.created).toBe(1_717_171_717_000)
|
expect(result.time.created).toBe(1_717_171_717_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("events.subscribe exposes the Promise event stream wire projection", async () => {
|
||||||
|
const client = OpenCode.make({
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
fetch: async () =>
|
||||||
|
new Response(
|
||||||
|
`: heartbeat\n\ndata: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` +
|
||||||
|
`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
|
||||||
|
{ headers: { "content-type": "text/event-stream" } },
|
||||||
|
),
|
||||||
|
})
|
||||||
|
const events = []
|
||||||
|
for await (const event of client.events.subscribe()) events.push(event)
|
||||||
|
|
||||||
|
expect(events).toEqual([{ id: "evt_connected", type: "server.connected", data: {} }, modelSwitchedEvent])
|
||||||
|
expect(events[1]?.type === "session.next.model.switched" && events[1].data.timestamp).toBe(1_717_171_717_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("events.subscribe terminates on malformed Promise SSE data", async () => {
|
||||||
|
const client = OpenCode.make({
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
fetch: async () => new Response("data: {not-json}\n\n", { headers: { "content-type": "text/event-stream" } }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(client.events.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({
|
||||||
|
name: "ClientError",
|
||||||
|
reason: "MalformedResponse",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("session methods use the public HTTP contract", async () => {
|
test("session methods use the public HTTP contract", async () => {
|
||||||
const requests: Array<{ url: string; init?: RequestInit }> = []
|
const requests: Array<{ url: string; init?: RequestInit }> = []
|
||||||
|
let historyPage = 0
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
baseUrl: "http://localhost:3000",
|
baseUrl: "http://localhost:3000",
|
||||||
fetch: async (input, init) => {
|
fetch: async (input, init) => {
|
||||||
@@ -29,6 +59,12 @@ test("session methods use the public HTTP contract", async () => {
|
|||||||
headers: { "content-type": "text/event-stream" },
|
headers: { "content-type": "text/event-stream" },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (url.includes("/history")) {
|
||||||
|
historyPage++
|
||||||
|
return Response.json(
|
||||||
|
historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
|
||||||
|
)
|
||||||
|
}
|
||||||
if (url.includes("/prompt")) return Response.json(admission)
|
if (url.includes("/prompt")) return Response.json(admission)
|
||||||
if (url.includes("/context")) return Response.json({ data: [] })
|
if (url.includes("/context")) return Response.json({ data: [] })
|
||||||
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
|
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
|
||||||
@@ -39,7 +75,7 @@ test("session methods use the public HTTP contract", async () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const page = await client.sessions.list({ limit: "10", order: "desc" })
|
const page = await client.sessions.list({ limit: 10, order: "desc" })
|
||||||
const active = await client.sessions.active()
|
const active = await client.sessions.active()
|
||||||
const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
|
const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
|
||||||
await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
|
await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
|
||||||
@@ -55,8 +91,13 @@ test("session methods use the public HTTP contract", async () => {
|
|||||||
await client.sessions.compact({ sessionID: "ses_test" })
|
await client.sessions.compact({ sessionID: "ses_test" })
|
||||||
await client.sessions.wait({ sessionID: "ses_test" })
|
await client.sessions.wait({ sessionID: "ses_test" })
|
||||||
const context = await client.sessions.context({ sessionID: "ses_test" })
|
const context = await client.sessions.context({ sessionID: "ses_test" })
|
||||||
|
const history = await client.sessions.history({ sessionID: "ses_test", after: 0, limit: 1 })
|
||||||
|
const historyAfter = history.data.at(-1)?.durable?.seq
|
||||||
|
const historyNext = history.hasMore
|
||||||
|
? await client.sessions.history({ sessionID: "ses_test", after: historyAfter, limit: 2 })
|
||||||
|
: undefined
|
||||||
const events = []
|
const events = []
|
||||||
for await (const event of client.sessions.events({ sessionID: "ses_test", after: "0" })) events.push(event)
|
for await (const event of client.sessions.events({ sessionID: "ses_test", after: 0 })) events.push(event)
|
||||||
await client.sessions.interrupt({ sessionID: "ses_test" })
|
await client.sessions.interrupt({ sessionID: "ses_test" })
|
||||||
const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" })
|
const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" })
|
||||||
|
|
||||||
@@ -65,6 +106,8 @@ test("session methods use the public HTTP contract", async () => {
|
|||||||
expect(created.id).toBe("ses_test")
|
expect(created.id).toBe("ses_test")
|
||||||
expect(admitted.id).toBe("msg_test")
|
expect(admitted.id).toBe("msg_test")
|
||||||
expect(context).toEqual([])
|
expect(context).toEqual([])
|
||||||
|
expect(history).toEqual({ data: [modelSwitchedEvent], hasMore: true })
|
||||||
|
expect(historyNext).toEqual({ data: [], hasMore: false })
|
||||||
expect(events).toEqual([modelSwitchedEvent])
|
expect(events).toEqual([modelSwitchedEvent])
|
||||||
expect(message).toEqual(modelSwitchedMessage)
|
expect(message).toEqual(modelSwitchedMessage)
|
||||||
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
|
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
|
||||||
@@ -77,6 +120,8 @@ test("session methods use the public HTTP contract", async () => {
|
|||||||
["POST", "http://localhost:3000/api/session/ses_test/compact"],
|
["POST", "http://localhost:3000/api/session/ses_test/compact"],
|
||||||
["POST", "http://localhost:3000/api/session/ses_test/wait"],
|
["POST", "http://localhost:3000/api/session/ses_test/wait"],
|
||||||
["GET", "http://localhost:3000/api/session/ses_test/context"],
|
["GET", "http://localhost:3000/api/session/ses_test/context"],
|
||||||
|
["GET", "http://localhost:3000/api/session/ses_test/history?limit=1&after=0"],
|
||||||
|
["GET", "http://localhost:3000/api/session/ses_test/history?limit=2&after=1"],
|
||||||
["GET", "http://localhost:3000/api/session/ses_test/event?after=0"],
|
["GET", "http://localhost:3000/api/session/ses_test/event?after=0"],
|
||||||
["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
|
["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
|
||||||
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
|
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
|
||||||
@@ -104,6 +149,24 @@ test("middleware errors remain declared client errors", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("sessions.history decodes SessionNotFoundError", async () => {
|
||||||
|
const client = OpenCode.make({
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
fetch: async () =>
|
||||||
|
Response.json(
|
||||||
|
{ _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" },
|
||||||
|
{ status: 404 },
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.sessions.history({ sessionID: "ses_missing" })
|
||||||
|
throw new Error("Expected request to fail")
|
||||||
|
} catch (error) {
|
||||||
|
expect(isSessionNotFoundError(error)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const session = {
|
const session = {
|
||||||
data: {
|
data: {
|
||||||
id: "ses_test",
|
id: "ses_test",
|
||||||
|
|||||||
@@ -215,6 +215,16 @@ export async function handler(
|
|||||||
body: reqBody,
|
body: reqBody,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (providerInfo.id.startsWith("console.")) {
|
||||||
|
const resEndpointId = res.headers.get("x-opencode-endpoint-id")
|
||||||
|
const resEndpointModelId = res.headers.get("x-opencode-upstream-model-id")
|
||||||
|
if (resEndpointId && resEndpointModelId)
|
||||||
|
logger.metric({
|
||||||
|
provider: resEndpointId,
|
||||||
|
"provider.model": resEndpointModelId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (res.status !== 200) {
|
if (res.status !== 200) {
|
||||||
logger.metric({
|
logger.metric({
|
||||||
"llm.error.code": res.status,
|
"llm.error.code": res.status,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
"opencode": "./bin/opencode"
|
"opencode": "./bin/opencode"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
|
"./effect/layer-node": "./src/effect/layer-node/index.ts",
|
||||||
|
"./effect/node": "./src/effect/node.ts",
|
||||||
"./session/runner": "./src/session/runner/index.ts",
|
"./session/runner": "./src/session/runner/index.ts",
|
||||||
"./system-context": "./src/system-context/index.ts",
|
"./system-context": "./src/system-context/index.ts",
|
||||||
"./*": "./src/*.ts"
|
"./*": "./src/*.ts"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as AgentV2 from "./agent"
|
export * as AgentV2 from "./agent"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import { Array, Context, Effect, Layer, Types } from "effect"
|
import { Array, Context, Effect, Layer, Types } from "effect"
|
||||||
import { Agent } from "@opencode-ai/schema/agent"
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
import { State } from "./state"
|
import { State } from "./state"
|
||||||
@@ -106,3 +107,5 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as AISDK from "./aisdk"
|
export * as AISDK from "./aisdk"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||||
import { Cause, Context, Effect, Layer, Schema, Scope } from "effect"
|
import { Cause, Context, Effect, Layer, Schema, Scope } from "effect"
|
||||||
import { ModelV2 } from "./model"
|
import { ModelV2 } from "./model"
|
||||||
@@ -231,4 +232,6 @@ export const locationLayer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
|
||||||
|
|
||||||
export const defaultLayer = locationLayer
|
export const defaultLayer = locationLayer
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export * as BackgroundJob from "./background-job"
|
|||||||
|
|
||||||
import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
|
import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
|
||||||
import { Identifier } from "./id/id"
|
import { Identifier } from "./id/id"
|
||||||
|
import { makeGlobalNode } from "./effect/node"
|
||||||
|
|
||||||
export type Status = "running" | "completed" | "error" | "cancelled"
|
export type Status = "running" | "completed" | "error" | "cancelled"
|
||||||
|
|
||||||
@@ -362,3 +363,5 @@ export const make = Effect.gen(function* () {
|
|||||||
export const layer = Layer.effect(Service, make)
|
export const layer = Layer.effect(Service, make)
|
||||||
|
|
||||||
export const defaultLayer = layer
|
export const defaultLayer = layer
|
||||||
|
|
||||||
|
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as Catalog from "./catalog"
|
export * as Catalog from "./catalog"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
|
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/schema/catalog"
|
import { Catalog } from "@opencode-ai/schema/catalog"
|
||||||
import { ModelV2 } from "./model"
|
import { ModelV2 } from "./model"
|
||||||
@@ -296,3 +297,5 @@ export const locationLayer = layer.pipe(
|
|||||||
Layer.provideMerge(Integration.locationLayer),
|
Layer.provideMerge(Integration.locationLayer),
|
||||||
Layer.provideMerge(Policy.locationLayer),
|
Layer.provideMerge(Policy.locationLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Policy.node, Integration.node] })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as CommandV2 from "./command"
|
export * as CommandV2 from "./command"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import { Context, Effect, Layer, Types } from "effect"
|
import { Context, Effect, Layer, Types } from "effect"
|
||||||
import { Command } from "@opencode-ai/schema/command"
|
import { Command } from "@opencode-ai/schema/command"
|
||||||
import { State } from "./state"
|
import { State } from "./state"
|
||||||
@@ -59,3 +60,5 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as Config from "./config"
|
export * as Config from "./config"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { type ParseError, parse } from "jsonc-parser"
|
import { type ParseError, parse } from "jsonc-parser"
|
||||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||||
@@ -218,3 +219,9 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer.pipe(Layer.provideMerge(Policy.locationLayer))
|
export const locationLayer = layer.pipe(Layer.provideMerge(Policy.locationLayer))
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer,
|
||||||
|
deps: [FSUtil.node, Global.node, Location.node, Policy.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect"
|
|||||||
import { Credential } from "@opencode-ai/schema/credential"
|
import { Credential } from "@opencode-ai/schema/credential"
|
||||||
import { Integration } from "@opencode-ai/schema/integration"
|
import { Integration } from "@opencode-ai/schema/integration"
|
||||||
import { Database } from "./database/database"
|
import { Database } from "./database/database"
|
||||||
|
import { makeGlobalNode } from "./effect/node"
|
||||||
import { CredentialTable } from "./credential/sql"
|
import { CredentialTable } from "./credential/sql"
|
||||||
|
|
||||||
export const ID = Credential.ID
|
export const ID = Credential.ID
|
||||||
@@ -135,3 +136,5 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||||
|
|
||||||
|
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
import * as NodeChildProcess from "node:child_process"
|
import * as NodeChildProcess from "node:child_process"
|
||||||
import { PassThrough } from "node:stream"
|
import { PassThrough } from "node:stream"
|
||||||
import launch from "cross-spawn"
|
import launch from "cross-spawn"
|
||||||
import { LayerNode } from "./effect/layer-node"
|
import { makeGlobalNode } from "./effect/node"
|
||||||
import { filesystem, path } from "./effect/layer-node-platform"
|
import { filesystem, path } from "./effect/layer-node-platform"
|
||||||
|
|
||||||
const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err)))
|
const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err)))
|
||||||
@@ -503,6 +503,6 @@ export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSyste
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
|
||||||
export const node = LayerNode.make({ service: ChildProcessSpawner, layer, deps: [filesystem, path] })
|
export const node = makeGlobalNode({ service: ChildProcessSpawner, layer, deps: [filesystem, path] })
|
||||||
|
|
||||||
export * as CrossSpawnSpawner from "./cross-spawn-spawner"
|
export * as CrossSpawnSpawner from "./cross-spawn-spawner"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Flag } from "../flag/flag"
|
|||||||
import { isAbsolute, join } from "path"
|
import { isAbsolute, join } from "path"
|
||||||
import { DatabaseMigration } from "./migration"
|
import { DatabaseMigration } from "./migration"
|
||||||
import { InstallationChannel } from "../installation/version"
|
import { InstallationChannel } from "../installation/version"
|
||||||
import { LayerNode } from "../effect/layer-node"
|
import { makeGlobalNode } from "../effect/node"
|
||||||
|
|
||||||
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
|
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
|
||||||
type DatabaseShape = Effect.Success<typeof makeDatabase>
|
type DatabaseShape = Effect.Success<typeof makeDatabase>
|
||||||
@@ -60,4 +60,4 @@ export const defaultLayer = Layer.unwrap(
|
|||||||
}),
|
}),
|
||||||
).pipe(Layer.provide(Global.defaultLayer))
|
).pipe(Layer.provide(Global.defaultLayer))
|
||||||
|
|
||||||
export const node = LayerNode.make({ service: Service, layer: layerFromPath(path()), deps: [] })
|
export const node = makeGlobalNode({ service: Service, layer: layerFromPath(path()), deps: [] })
|
||||||
|
|||||||
@@ -3,16 +3,16 @@ import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
|
|||||||
import { FileSystem, Path } from "effect"
|
import { FileSystem, Path } from "effect"
|
||||||
import { FetchHttpClient } from "effect/unstable/http"
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
import { HttpClient } from "effect/unstable/http"
|
import { HttpClient } from "effect/unstable/http"
|
||||||
import { LayerNode } from "./layer-node"
|
import { makeGlobalNode } from "./node"
|
||||||
|
|
||||||
export const filesystem = LayerNode.make({ service: FileSystem.FileSystem, layer: NodeFileSystem.layer, deps: [] })
|
export const filesystem = makeGlobalNode({ service: FileSystem.FileSystem, layer: NodeFileSystem.layer, deps: [] })
|
||||||
export const path = LayerNode.make({ service: Path.Path, layer: NodePath.layer, deps: [] })
|
export const path = makeGlobalNode({ service: Path.Path, layer: NodePath.layer, deps: [] })
|
||||||
export const httpClient = LayerNode.make({ service: HttpClient.HttpClient, layer: FetchHttpClient.layer, deps: [] })
|
export const httpClient = makeGlobalNode({ service: HttpClient.HttpClient, layer: FetchHttpClient.layer, deps: [] })
|
||||||
export const requestExecutor = LayerNode.make({
|
export const requestExecutor = makeGlobalNode({
|
||||||
service: RequestExecutor.Service,
|
service: RequestExecutor.Service,
|
||||||
layer: RequestExecutor.layer,
|
layer: RequestExecutor.layer,
|
||||||
deps: [httpClient],
|
deps: [httpClient],
|
||||||
})
|
})
|
||||||
export const llmClient = LayerNode.make({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] })
|
export const llmClient = makeGlobalNode({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] })
|
||||||
|
|
||||||
export * as LayerNodePlatform from "./layer-node-platform"
|
export * as LayerNodePlatform from "./layer-node-platform"
|
||||||
|
|||||||
@@ -1,248 +0,0 @@
|
|||||||
import { Brand, Context, Layer } from "effect"
|
|
||||||
|
|
||||||
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
|
|
||||||
type AnyNode = Node<unknown, unknown, any>
|
|
||||||
type NodeList<Item extends AnyNode = AnyNode> = readonly [] | readonly [Item, ...Item[]]
|
|
||||||
type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown, any> ? A : never
|
|
||||||
type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E, any> ? E : never
|
|
||||||
type Missing<Required, Dependencies extends NodeList> = Exclude<Required, Output<Dependencies[number]>>
|
|
||||||
type CheckDependencies<Implementation extends Layer.Any, Dependencies extends NodeList> = [
|
|
||||||
Missing<Layer.Services<Implementation>, Dependencies>,
|
|
||||||
] extends [never]
|
|
||||||
? unknown
|
|
||||||
: { readonly "Missing dependencies": Missing<Layer.Services<Implementation>, Dependencies> }
|
|
||||||
declare const $OutputType: unique symbol
|
|
||||||
declare const $ErrorType: unique symbol
|
|
||||||
|
|
||||||
export type Tier<Name extends string = string> = Name & Brand.Brand<"LayerNode.Tier">
|
|
||||||
|
|
||||||
const makeTier = Brand.nominal<Tier>()
|
|
||||||
|
|
||||||
export type Node<A, E = never, T extends Tier | undefined = undefined> = {
|
|
||||||
readonly kind: "layer" | "group"
|
|
||||||
readonly name: string
|
|
||||||
readonly service?: Context.Service.Any
|
|
||||||
readonly implementation?: Layer.Any
|
|
||||||
readonly dependencies: readonly AnyNode[]
|
|
||||||
readonly tier?: T
|
|
||||||
readonly [$OutputType]?: () => A
|
|
||||||
readonly [$ErrorType]?: () => E
|
|
||||||
}
|
|
||||||
|
|
||||||
type NodeIdentity =
|
|
||||||
| { readonly service: Context.Service.Any; readonly name?: never }
|
|
||||||
| { readonly name: string; readonly service?: never }
|
|
||||||
type DistributiveOmit<A, K extends PropertyKey> = A extends unknown ? Omit<A, K> : never
|
|
||||||
|
|
||||||
type NodeInput<
|
|
||||||
Implementation extends Layer.Any,
|
|
||||||
Items extends NodeList,
|
|
||||||
T extends Tier | undefined = undefined,
|
|
||||||
> = NodeIdentity & {
|
|
||||||
readonly layer: Implementation
|
|
||||||
readonly deps: Items & CheckDependencies<Implementation, NoInfer<Items>>
|
|
||||||
readonly tier?: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export function make<
|
|
||||||
const Implementation extends Layer.Any,
|
|
||||||
const Items extends NodeList,
|
|
||||||
const T extends Tier | undefined = undefined,
|
|
||||||
>(
|
|
||||||
input: NodeInput<Implementation, Items, T>,
|
|
||||||
): Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, T> {
|
|
||||||
return {
|
|
||||||
kind: "layer",
|
|
||||||
name: input.service !== undefined ? input.service.key : input.name,
|
|
||||||
service: input.service,
|
|
||||||
implementation: input.layer,
|
|
||||||
dependencies: input.deps,
|
|
||||||
tier: input.tier,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function group<const Items extends NodeList>(
|
|
||||||
dependencies: Items,
|
|
||||||
): Node<Output<Items[number]>, Error<Items[number]>> {
|
|
||||||
return { kind: "group", name: "group", dependencies }
|
|
||||||
}
|
|
||||||
|
|
||||||
type AllowedTierNames<Names extends readonly string[], Name extends Names[number]> = Names extends readonly [
|
|
||||||
infer Head extends string,
|
|
||||||
...infer Tail extends readonly string[],
|
|
||||||
]
|
|
||||||
? Head extends Name
|
|
||||||
? Head | Tail[number]
|
|
||||||
: AllowedTierNames<Tail, Name>
|
|
||||||
: never
|
|
||||||
|
|
||||||
type NodeInTiers<Names extends string> = Node<unknown, unknown, Tier<Names>>
|
|
||||||
|
|
||||||
export interface Tiers<Names extends readonly [string, ...string[]]> {
|
|
||||||
readonly names: Names
|
|
||||||
readonly values: { readonly [K in Names[number]]: Tier<K> }
|
|
||||||
readonly make: <Name extends Names[number]>(
|
|
||||||
name: Name,
|
|
||||||
) => <
|
|
||||||
const Implementation extends Layer.Any,
|
|
||||||
const Items extends NodeList<NodeInTiers<AllowedTierNames<Names, Name>>>,
|
|
||||||
>(
|
|
||||||
input: DistributiveOmit<NodeInput<Implementation, Items, Tier<Name>>, "tier">,
|
|
||||||
) => Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, Tier<Name>>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function tiers<const Names extends readonly [string, ...string[]]>(names: Names): Tiers<Names> {
|
|
||||||
const values = Object.fromEntries(names.map((name) => [name, makeTier(name)])) as Tiers<Names>["values"]
|
|
||||||
return {
|
|
||||||
names,
|
|
||||||
values,
|
|
||||||
make: ((name: Names[number]) => (input: DistributiveOmit<NodeInput<Layer.Any, NodeList, Tier>, "tier">) =>
|
|
||||||
make({ ...input, tier: values[name] })) as Tiers<Names>["make"],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultTiers = tiers(["untiered"])
|
|
||||||
const untiered = defaultTiers.values.untiered
|
|
||||||
|
|
||||||
export type Replacement = {
|
|
||||||
readonly source: Layer.Any
|
|
||||||
readonly replacement: Layer.Any
|
|
||||||
}
|
|
||||||
|
|
||||||
type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<ReplacementError, SourceError>] extends [never]
|
|
||||||
? unknown
|
|
||||||
: { readonly "New replacement errors": Exclude<ReplacementError, SourceError> }
|
|
||||||
|
|
||||||
export function replace<A, E, R, E2>(
|
|
||||||
source: Layer.Layer<A, E, R>,
|
|
||||||
replacement: Layer.Layer<NoInfer<A>, E2, never> & CheckReplacementErrors<E, NoInfer<E2>>,
|
|
||||||
): Replacement {
|
|
||||||
return { source, replacement }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildLayer<
|
|
||||||
A,
|
|
||||||
E,
|
|
||||||
const Names extends readonly [string, ...string[]] = readonly ["untiered"],
|
|
||||||
const Built extends Layer.Any = Layer.Layer<never, never, never>,
|
|
||||||
>(
|
|
||||||
node: Node<A, E, any>,
|
|
||||||
options?: {
|
|
||||||
readonly tiers?: Tiers<Names>
|
|
||||||
readonly buildTier?: (tier: Names[number], layers: readonly Layer.Any[]) => Built
|
|
||||||
readonly replacements?: readonly Replacement[]
|
|
||||||
},
|
|
||||||
): Layer.Layer<A | Layer.Success<Built>, E | Layer.Error<Built>, never> {
|
|
||||||
const tiers = options?.tiers ?? (defaultTiers as unknown as Tiers<Names>)
|
|
||||||
const replacementMap = new Map(options?.replacements?.map((item) => [item.source, item.replacement]))
|
|
||||||
const plans = plan(node, tiers, replacementMap)
|
|
||||||
const layers: RuntimeLayer[] = tiers.names.map((name) => {
|
|
||||||
const tier = tiers.values[name as Names[number]]
|
|
||||||
const layers = plans.get(tier) ?? []
|
|
||||||
return (options?.buildTier?.(name, layers) ?? combine(layers)) as RuntimeLayer
|
|
||||||
})
|
|
||||||
if (layers.length === 0) return Layer.empty as never
|
|
||||||
return layers.slice(1).reduce((result, layer) => result.pipe(Layer.provideMerge(layer)), layers[0]) as never
|
|
||||||
}
|
|
||||||
|
|
||||||
export function combine(layers: readonly Layer.Any[]): RuntimeLayer {
|
|
||||||
return layers.reduce<RuntimeLayer>(
|
|
||||||
(result, layer) => (layer as RuntimeLayer).pipe(Layer.provideMerge(result)),
|
|
||||||
Layer.empty as RuntimeLayer,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function plan(
|
|
||||||
root: AnyNode,
|
|
||||||
tiers: Tiers<readonly [string, ...string[]]>,
|
|
||||||
replacements: ReadonlyMap<Layer.Any, Layer.Any>,
|
|
||||||
) {
|
|
||||||
const indexes = new Map(tiers.names.map((name, index) => [tiers.values[name], index]))
|
|
||||||
const plans = new Map<Tier, Layer.Any[]>()
|
|
||||||
const activeImplementations = new Map<Tier, Map<string, AnyNode>>()
|
|
||||||
const serviceTiers = new Map<string, Tier>()
|
|
||||||
const visiting = new Set<AnyNode>()
|
|
||||||
const stack: AnyNode[] = []
|
|
||||||
const boundaryVisited = new Map<AnyNode, Set<Tier>>()
|
|
||||||
const boundaryServices = new Map<Tier, Map<string, AnyNode>>()
|
|
||||||
|
|
||||||
const validateBoundary = (node: AnyNode, origin: Tier) => {
|
|
||||||
const checked = boundaryVisited.get(node) ?? new Set<Tier>()
|
|
||||||
boundaryVisited.set(node, checked)
|
|
||||||
if (checked.has(origin)) return false
|
|
||||||
checked.add(origin)
|
|
||||||
const services = boundaryServices.get(origin) ?? new Map<string, AnyNode>()
|
|
||||||
boundaryServices.set(origin, services)
|
|
||||||
const key = node.name
|
|
||||||
const existing = services.get(key)
|
|
||||||
if (existing && existing !== node) {
|
|
||||||
throw new Error(`Tier ${origin} has conflicting implementations for ${key}`)
|
|
||||||
}
|
|
||||||
services.set(key, node)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const visit = (node: AnyNode, currentTier?: Tier, origins: readonly Tier[] = []) => {
|
|
||||||
if (node.kind === "group") {
|
|
||||||
node.dependencies.forEach((dependency) => visit(dependency, currentTier, origins))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const tier = node.tier ?? untiered
|
|
||||||
if (!indexes.has(tier)) throw new Error(`Node ${node.name} is not in the tier configuration`)
|
|
||||||
const key = node.name
|
|
||||||
const serviceTier = serviceTiers.get(key)
|
|
||||||
if (serviceTier && serviceTier !== tier) {
|
|
||||||
throw new Error(`Service ${key} belongs to both tier ${serviceTier} and tier ${tier}`)
|
|
||||||
}
|
|
||||||
serviceTiers.set(key, tier)
|
|
||||||
const nextOrigins = [...origins]
|
|
||||||
if (currentTier) {
|
|
||||||
const current = indexes.get(currentTier)!
|
|
||||||
const required = indexes.get(tier)!
|
|
||||||
if (required < current) {
|
|
||||||
throw new Error(`Tier ${currentTier} cannot depend on lower tier ${tier}`)
|
|
||||||
}
|
|
||||||
if (required > current) nextOrigins.push(currentTier)
|
|
||||||
}
|
|
||||||
const unseenOrigins = nextOrigins.filter((origin) => validateBoundary(node, origin))
|
|
||||||
|
|
||||||
// A node may need to be emitted more than once because the final output is a
|
|
||||||
// flat list of layers applied with Layer.provideMerge. If another node for
|
|
||||||
// the same service was emitted afterward, this node is no longer the active
|
|
||||||
// implementation for subsequent consumers. Re-emitting restores the intended
|
|
||||||
// implementation ordering while Effect memoization avoids reacquiring the layer.
|
|
||||||
const implementations = activeImplementations.get(tier) ?? new Map<string, AnyNode>()
|
|
||||||
activeImplementations.set(tier, implementations)
|
|
||||||
if (implementations.get(key) === node && unseenOrigins.length === 0) return
|
|
||||||
|
|
||||||
if (visiting.has(node)) {
|
|
||||||
const start = stack.indexOf(node)
|
|
||||||
throw new Error(
|
|
||||||
`Cycle detected in layer graph: ${[...stack.slice(start), node].map((item) => item.name).join(" -> ")}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
visiting.add(node)
|
|
||||||
stack.push(node)
|
|
||||||
try {
|
|
||||||
node.dependencies.forEach((dependency) => visit(dependency, tier, unseenOrigins))
|
|
||||||
const layers = plans.get(tier) ?? []
|
|
||||||
plans.set(tier, layers)
|
|
||||||
layers.push(replacements.get(node.implementation!) ?? node.implementation!)
|
|
||||||
implementations.set(key, node)
|
|
||||||
} finally {
|
|
||||||
stack.pop()
|
|
||||||
visiting.delete(node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
visit(root)
|
|
||||||
return plans
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireTier(node: AnyNode, indexes: ReadonlyMap<Tier, number>) {
|
|
||||||
if (!node.tier || !indexes.has(node.tier)) throw new Error(`Node ${node.name} is not in the tier configuration`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export * as LayerNode from "./layer-node"
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./layer-node"
|
||||||
|
export * as LayerNodeTree from "./layer-node-tree"
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { Layer } from "effect"
|
||||||
|
import { LayerNode } from "./layer-node"
|
||||||
|
|
||||||
|
type AnyNode = LayerNode.Node<unknown, unknown, any>
|
||||||
|
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
|
||||||
|
|
||||||
|
export function hoist<A, E, T extends LayerNode.Tag>(
|
||||||
|
root: LayerNode.Node<A, E, any>,
|
||||||
|
tag: T,
|
||||||
|
): {
|
||||||
|
readonly node: LayerNode.Node<A, E>
|
||||||
|
readonly hoisted: LayerNode.Node<unknown, E>
|
||||||
|
} {
|
||||||
|
const visited = new Map<AnyNode, AnyNode>()
|
||||||
|
const hoisted = new Map<string, AnyNode>()
|
||||||
|
const visiting = new Set<AnyNode>()
|
||||||
|
const stack: AnyNode[] = []
|
||||||
|
|
||||||
|
const visit = (node: AnyNode): AnyNode => {
|
||||||
|
if (node.kind === "group") {
|
||||||
|
return { ...node, dependencies: node.dependencies.map(visit) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingNode = visited.get(node)
|
||||||
|
if (existingNode) return existingNode
|
||||||
|
|
||||||
|
if (node.tag === tag) {
|
||||||
|
const existing = hoisted.get(node.name)
|
||||||
|
if (existing && existing !== node) {
|
||||||
|
throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
|
||||||
|
}
|
||||||
|
hoisted.set(node.name, node)
|
||||||
|
const empty = LayerNode.group([])
|
||||||
|
visited.set(node, empty)
|
||||||
|
return empty
|
||||||
|
}
|
||||||
|
if (node.kind === "unbound") {
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visiting.has(node)) {
|
||||||
|
const start = stack.indexOf(node)
|
||||||
|
throw new Error(
|
||||||
|
`Cycle detected in layer tree: ${[...stack.slice(start), node].map((item) => item.name).join(" -> ")}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
visiting.add(node)
|
||||||
|
stack.push(node)
|
||||||
|
try {
|
||||||
|
const dependencies = node.dependencies.map(visit)
|
||||||
|
const clone = { ...node, dependencies }
|
||||||
|
visited.set(node, clone)
|
||||||
|
return clone
|
||||||
|
} finally {
|
||||||
|
stack.pop()
|
||||||
|
visiting.delete(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
node: visit(root) as LayerNode.Node<A, E>,
|
||||||
|
hoisted: LayerNode.group(Array.from(hoisted.values())) as LayerNode.Node<unknown, E>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compile<A, E>(
|
||||||
|
root: LayerNode.Node<A, E, any>,
|
||||||
|
replacements?: ReadonlyMap<Layer.Any, Layer.Any>,
|
||||||
|
): Layer.Layer<A, E> {
|
||||||
|
const cache = new Map<AnyNode, RuntimeLayer>()
|
||||||
|
const compileNode = (node: AnyNode): RuntimeLayer => {
|
||||||
|
if (node.kind === "unbound") throw new Error(`Unbound layer node: ${node.name}`)
|
||||||
|
const cached = cache.get(node)
|
||||||
|
if (cached) return cached
|
||||||
|
const dependencies = node.dependencies.flatMap(flatten).map(compileNode)
|
||||||
|
const implementation = (replacements?.get(node.implementation!) ?? node.implementation!) as RuntimeLayer
|
||||||
|
const layer =
|
||||||
|
dependencies.length === 0
|
||||||
|
? implementation
|
||||||
|
: implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]]))
|
||||||
|
cache.set(node, layer)
|
||||||
|
return layer
|
||||||
|
}
|
||||||
|
const layers = flatten(root).map((node) => compileNode(node))
|
||||||
|
const layer = layers.reduce<RuntimeLayer>((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty)
|
||||||
|
return layer as Layer.Layer<A, E>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bind<A, E, T extends LayerNode.Tag | undefined>(
|
||||||
|
root: LayerNode.Node<A, E, T>,
|
||||||
|
source: AnyNode,
|
||||||
|
replacement: AnyNode,
|
||||||
|
): LayerNode.Node<A, E, T> {
|
||||||
|
if (source.kind !== "unbound") throw new Error(`Cannot bind non-unbound layer node: ${source.name}`)
|
||||||
|
if (source.name !== replacement.name) {
|
||||||
|
throw new Error(`Cannot bind ${source.name} to ${replacement.name}`)
|
||||||
|
}
|
||||||
|
if (source.tag !== replacement.tag) {
|
||||||
|
throw new Error(`Cannot bind ${source.name} across tags`)
|
||||||
|
}
|
||||||
|
const visited = new Map<AnyNode, AnyNode>()
|
||||||
|
const visit = (node: AnyNode): AnyNode => {
|
||||||
|
if (node === source) return replacement
|
||||||
|
const existing = visited.get(node)
|
||||||
|
if (existing) return existing
|
||||||
|
if (node.kind === "unbound") return node
|
||||||
|
const clone = { ...node, dependencies: node.dependencies.map(visit) }
|
||||||
|
visited.set(node, clone)
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
return visit(root) as LayerNode.Node<A, E, T>
|
||||||
|
}
|
||||||
|
|
||||||
|
function flatten(node: AnyNode): readonly AnyNode[] {
|
||||||
|
return node.kind === "group" ? node.dependencies.flatMap(flatten) : [node]
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as LayerNodeTree from "./layer-node-tree"
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { Brand, Context, Layer } from "effect"
|
||||||
|
|
||||||
|
type AnyNode = Node<unknown, unknown, any>
|
||||||
|
type NodeList<Item extends AnyNode = AnyNode> = readonly [] | readonly [Item, ...Item[]]
|
||||||
|
export type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown, any> ? A : never
|
||||||
|
export type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E, any> ? E : never
|
||||||
|
type NodeTag<Item> = [Item] extends [never] ? undefined : Item extends Node<unknown, unknown, infer T> ? T : never
|
||||||
|
type Missing<Required, Dependencies extends NodeList> = Exclude<Required, Output<Dependencies[number]>>
|
||||||
|
type CheckDependencies<Implementation extends Layer.Any, Dependencies extends NodeList> = [
|
||||||
|
Missing<Layer.Services<Implementation>, Dependencies>,
|
||||||
|
] extends [never]
|
||||||
|
? unknown
|
||||||
|
: { readonly "Missing dependencies": Missing<Layer.Services<Implementation>, Dependencies> }
|
||||||
|
declare const $OutputType: unique symbol
|
||||||
|
declare const $ErrorType: unique symbol
|
||||||
|
|
||||||
|
export type Tag<Name extends string = string> = Name & Brand.Brand<"LayerNode.Tag">
|
||||||
|
|
||||||
|
const makeTag = Brand.nominal<Tag>()
|
||||||
|
|
||||||
|
export interface Node<A, E = never, T extends Tag | undefined = undefined> {
|
||||||
|
readonly kind: "layer" | "unbound" | "group"
|
||||||
|
readonly name: string
|
||||||
|
readonly service?: Context.Service.Any
|
||||||
|
readonly implementation?: Layer.Any
|
||||||
|
readonly dependencies: readonly AnyNode[]
|
||||||
|
readonly tag?: T
|
||||||
|
readonly [$OutputType]?: () => A
|
||||||
|
readonly [$ErrorType]?: () => E
|
||||||
|
}
|
||||||
|
|
||||||
|
type NodeIdentity =
|
||||||
|
| { readonly service: Context.Service.Any; readonly name?: never }
|
||||||
|
| { readonly name: string; readonly service?: never }
|
||||||
|
type DistributiveOmit<A, K extends PropertyKey> = A extends unknown ? Omit<A, K> : never
|
||||||
|
|
||||||
|
type MakeInput<
|
||||||
|
Implementation extends Layer.Any,
|
||||||
|
Items extends NodeList,
|
||||||
|
T extends Tag | undefined = undefined,
|
||||||
|
> = NodeIdentity & {
|
||||||
|
readonly layer: Implementation
|
||||||
|
readonly deps: Items & CheckDependencies<Implementation, NoInfer<Items>>
|
||||||
|
readonly tag?: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export function make<
|
||||||
|
const Implementation extends Layer.Any,
|
||||||
|
const Items extends NodeList,
|
||||||
|
const T extends Tag | undefined = undefined,
|
||||||
|
>(
|
||||||
|
input: MakeInput<Implementation, Items, T>,
|
||||||
|
): Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, T> {
|
||||||
|
return {
|
||||||
|
kind: "layer",
|
||||||
|
name: input.service !== undefined ? input.service.key : input.name,
|
||||||
|
service: input.service,
|
||||||
|
implementation: input.layer,
|
||||||
|
dependencies: input.deps,
|
||||||
|
tag: input.tag,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unbound<R, Shape, const T extends Tag>(service: Context.Key<R, Shape>, tag: T): Node<R, never, T> {
|
||||||
|
return {
|
||||||
|
kind: "unbound",
|
||||||
|
name: service.key,
|
||||||
|
service,
|
||||||
|
dependencies: [],
|
||||||
|
tag,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function group<const Items extends readonly AnyNode[]>(
|
||||||
|
dependencies: Items,
|
||||||
|
): Node<Output<Items[number]>, Error<Items[number]>, NodeTag<Items[number]>> {
|
||||||
|
return { kind: "group", name: "group", dependencies }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TagConfig = Readonly<Record<string, readonly string[]>>
|
||||||
|
type TagNames<Config extends TagConfig> = keyof Config & string
|
||||||
|
type NodeInTags<Names extends string> = Node<unknown, unknown, Tag<Names> | undefined>
|
||||||
|
type CheckTags<Items extends NodeList, Names extends string> = [Exclude<Items[number], NodeInTags<Names>>] extends [
|
||||||
|
never,
|
||||||
|
]
|
||||||
|
? unknown
|
||||||
|
: { readonly "Invalid tag dependencies": Exclude<Items[number], NodeInTags<Names>> }
|
||||||
|
|
||||||
|
export interface Tags<Config extends TagConfig> {
|
||||||
|
readonly values: { readonly [Name in TagNames<Config>]: Tag<Name> }
|
||||||
|
readonly make: <Name extends TagNames<Config>>(
|
||||||
|
name: Name,
|
||||||
|
) => <const Implementation extends Layer.Any, const Items extends NodeList>(
|
||||||
|
input: DistributiveOmit<MakeInput<Implementation, Items, Tag<Name>>, "tag"> &
|
||||||
|
CheckTags<Items, Name | Extract<Config[Name][number], string>>,
|
||||||
|
) => Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, Tag<Name>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tags<const Config extends { readonly [Name in keyof Config]: readonly (keyof Config & string)[] }>(
|
||||||
|
config: Config,
|
||||||
|
): Tags<Config> {
|
||||||
|
const names = Object.keys(config) as TagNames<Config>[]
|
||||||
|
const values = Object.fromEntries(names.map((name) => [name, makeTag(name)])) as Tags<Config>["values"]
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
make: ((name: TagNames<Config>) => (input: DistributiveOmit<MakeInput<Layer.Any, NodeList, Tag>, "tag">) =>
|
||||||
|
make({ ...input, tag: values[name] })) as Tags<Config>["make"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Replacement = {
|
||||||
|
readonly source: Layer.Any
|
||||||
|
readonly replacement: Layer.Any
|
||||||
|
}
|
||||||
|
|
||||||
|
type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<ReplacementError, SourceError>] extends [never]
|
||||||
|
? unknown
|
||||||
|
: { readonly "New replacement errors": Exclude<ReplacementError, SourceError> }
|
||||||
|
|
||||||
|
export function replace<A, E, R, E2>(
|
||||||
|
source: Layer.Layer<A, E, R>,
|
||||||
|
replacement: Layer.Layer<NoInfer<A>, E2, never> & CheckReplacementErrors<E, NoInfer<E2>>,
|
||||||
|
): Replacement {
|
||||||
|
return { source, replacement }
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as LayerNode from "./layer-node"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Layer } from "effect"
|
||||||
|
import { buildLocationServiceMap } from "../location-services"
|
||||||
|
import { LocationServiceMap } from "../location-service-map"
|
||||||
|
import { LayerNode, LayerNodeTree } from "./layer-node"
|
||||||
|
import { makeGlobalNode } from "./node"
|
||||||
|
|
||||||
|
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements?: readonly LayerNode.Replacement[]) {
|
||||||
|
const replacementMap = new Map(replacements?.map((item) => [item.source, item.replacement]))
|
||||||
|
|
||||||
|
const locationMap = buildLocationServiceMap(replacementMap)
|
||||||
|
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
|
||||||
|
|
||||||
|
const app = LayerNodeTree.bind(root, LocationServiceMap.node, locationMapNode)
|
||||||
|
|
||||||
|
return LayerNodeTree.compile(app, replacementMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as NodeBuild from "./node-build"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { LayerNode } from "./layer-node"
|
||||||
|
|
||||||
|
export const tags = LayerNode.tags({
|
||||||
|
location: ["global"],
|
||||||
|
global: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
export type GlobalNode<A, E = never> = LayerNode.Node<A, E, (typeof tags.values)["global"]>
|
||||||
|
export type LocationNode<A, E = never> = LayerNode.Node<A, E, (typeof tags.values)["location"]>
|
||||||
|
|
||||||
|
export const makeGlobalNode = tags.make("global")
|
||||||
|
export const makeLocationNode = tags.make("location")
|
||||||
|
|
||||||
|
export * as Node from "./node"
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { LayerNode } from "./layer-node"
|
|
||||||
|
|
||||||
export const tiers = LayerNode.tiers(["location", "global"])
|
|
||||||
|
|
||||||
export type GlobalNode<A, E = never> = LayerNode.Node<A, E, (typeof tiers.values)["global"]>
|
|
||||||
export type LocationNode<A, E = never> = LayerNode.Node<A, E, (typeof tiers.values)["location"]>
|
|
||||||
|
|
||||||
export const makeGlobalNode = tiers.make("global")
|
|
||||||
export const makeLocationNode = tiers.make("location")
|
|
||||||
|
|
||||||
export * as ScopedNode from "./scoped-node"
|
|
||||||
+83
-17
@@ -1,13 +1,13 @@
|
|||||||
export * as EventV2 from "./event"
|
export * as EventV2 from "./event"
|
||||||
|
|
||||||
import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
|
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
|
||||||
import { and, asc, eq, gt } from "drizzle-orm"
|
import { and, asc, eq, gt, inArray } from "drizzle-orm"
|
||||||
import { Database } from "./database/database"
|
import { Database } from "./database/database"
|
||||||
import { EventSequenceTable, EventTable } from "./event/sql"
|
import { EventSequenceTable, EventTable } from "./event/sql"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { LayerNode } from "./effect/layer-node"
|
import { makeGlobalNode } from "./effect/node"
|
||||||
import { isDeepStrictEqual } from "node:util"
|
import { isDeepStrictEqual } from "node:util"
|
||||||
import { Durable } from "@opencode-ai/schema/durable-event-manifest"
|
import { Durable } from "@opencode-ai/schema/durable-event-manifest"
|
||||||
|
|
||||||
@@ -47,6 +47,71 @@ export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDur
|
|||||||
},
|
},
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
const decodeSerializedEvent = (event: SerializedEvent): Payload => {
|
||||||
|
const definition = Durable.get(event.type)
|
||||||
|
if (!definition?.durable) {
|
||||||
|
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
type: definition.type,
|
||||||
|
durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
|
||||||
|
data: Schema.decodeUnknownSync(definition.data)(event.data),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const readAggregate = Effect.fn("EventV2.readAggregate")(function* <A>(
|
||||||
|
db: Database.Interface["db"],
|
||||||
|
input: {
|
||||||
|
readonly aggregateID: string
|
||||||
|
readonly after?: number
|
||||||
|
readonly limit: number
|
||||||
|
readonly manifest: {
|
||||||
|
readonly definitions: ReadonlyMap<string, Definition>
|
||||||
|
readonly schema: Schema.Decoder<A, never>
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const after = input.after ?? -1
|
||||||
|
const rows = yield* db
|
||||||
|
.select()
|
||||||
|
.from(EventTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(EventTable.aggregate_id, input.aggregateID),
|
||||||
|
gt(EventTable.seq, after),
|
||||||
|
inArray(EventTable.type, Array.from(input.manifest.definitions.keys())),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(EventTable.seq))
|
||||||
|
.limit(input.limit + 1)
|
||||||
|
.all()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
const page = rows.slice(0, input.limit)
|
||||||
|
const decode = Schema.decodeUnknownSync(input.manifest.schema)
|
||||||
|
const events = page.map((event) =>
|
||||||
|
decode({
|
||||||
|
id: event.id,
|
||||||
|
type: input.manifest.definitions.get(event.type)?.type ?? event.type,
|
||||||
|
durable: {
|
||||||
|
aggregateID: event.aggregate_id,
|
||||||
|
seq: event.seq,
|
||||||
|
version: input.manifest.definitions.get(event.type)?.durable?.version,
|
||||||
|
},
|
||||||
|
data: event.data,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
events,
|
||||||
|
hasMore: rows.length > input.limit,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export class SubscriberOverflowError extends Schema.TaggedErrorClass<SubscriberOverflowError>()(
|
||||||
|
"EventV2.SubscriberOverflow",
|
||||||
|
{ capacity: Schema.Int },
|
||||||
|
) {}
|
||||||
|
|
||||||
export const define = Event.define
|
export const define = Event.define
|
||||||
export const versionedType = Event.versionedType
|
export const versionedType = Event.versionedType
|
||||||
|
|
||||||
@@ -84,6 +149,20 @@ export interface Interface {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
|
||||||
|
|
||||||
|
export const allBounded = (events: Interface, capacity: number) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const queue = yield* Queue.dropping<Payload, SubscriberOverflowError>(capacity)
|
||||||
|
const unsubscribe = yield* events.listen((event) =>
|
||||||
|
Queue.offer(queue, event).pipe(
|
||||||
|
Effect.flatMap((accepted) =>
|
||||||
|
accepted ? Effect.void : Queue.fail(queue, new SubscriberOverflowError({ capacity })).pipe(Effect.asVoid),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
yield* Effect.addFinalizer(() => unsubscribe.pipe(Effect.andThen(Queue.shutdown(queue)), Effect.asVoid))
|
||||||
|
return Stream.fromQueue(queue)
|
||||||
|
})
|
||||||
|
|
||||||
export interface LayerOptions {
|
export interface LayerOptions {
|
||||||
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
|
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
@@ -459,19 +538,6 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
|
|
||||||
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all)
|
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all)
|
||||||
|
|
||||||
const decodeSerializedEvent = (event: SerializedEvent) => {
|
|
||||||
const definition = Durable.get(event.type)
|
|
||||||
if (!definition?.durable) {
|
|
||||||
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: event.id,
|
|
||||||
type: definition.type,
|
|
||||||
durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
|
|
||||||
data: Schema.decodeUnknownSync(definition.data)(event.data),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const readAfter = (aggregateID: string, after: number) =>
|
const readAfter = (aggregateID: string, after: number) =>
|
||||||
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
|
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
@@ -569,6 +635,6 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const layer = layerWith()
|
export const layer = layerWith()
|
||||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] })
|
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] })
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as FileMutation from "./file-mutation"
|
export * as FileMutation from "./file-mutation"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { dirname } from "path"
|
import { dirname } from "path"
|
||||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||||
@@ -192,6 +193,8 @@ function sameBytes(left: Uint8Array, right: Uint8Array) {
|
|||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deferred until the corresponding V2 integrations exist.
|
* Deferred until the corresponding V2 integrations exist.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as FileSystem from "./filesystem"
|
export * as FileSystem from "./filesystem"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
@@ -113,3 +114,9 @@ const baseLayer = Layer.effect(
|
|||||||
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer))
|
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer))
|
||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer: baseLayer,
|
||||||
|
deps: [FSUtil.node, Location.node, FileSystemSearch.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as FileSystemSearch from "./search"
|
export * as FileSystemSearch from "./search"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "../effect/node"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Context, Effect, Layer, Scope } from "effect"
|
import { Context, Effect, Layer, Scope } from "effect"
|
||||||
import { Fff } from "#fff"
|
import { Fff } from "#fff"
|
||||||
@@ -229,6 +230,8 @@ export const fffLayer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = Layer.unwrap(
|
const layer = Layer.unwrap(Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)))
|
||||||
Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)),
|
|
||||||
)
|
export const locationLayer = layer
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Ripgrep.node] })
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export * as Watcher from "./watcher"
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||||
import type ParcelWatcher from "@parcel/watcher"
|
import type ParcelWatcher from "@parcel/watcher"
|
||||||
|
import { makeLocationNode } from "../effect/node"
|
||||||
import { Cause, Context, Effect, Layer } from "effect"
|
import { Cause, Context, Effect, Layer } from "effect"
|
||||||
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
|
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
@@ -133,3 +134,9 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer), Layer.provide(Git.defaultLayer))
|
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer), Layer.provide(Git.defaultLayer))
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer,
|
||||||
|
deps: [FSUtil.node, Location.node, Config.node, Git.node, EventV2.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Context, Effect, FileSystem, Layer, Schema } from "effect"
|
|||||||
import type { PlatformError } from "effect/PlatformError"
|
import type { PlatformError } from "effect/PlatformError"
|
||||||
import { Glob } from "./util/glob"
|
import { Glob } from "./util/glob"
|
||||||
import { serviceUse } from "./effect/service-use"
|
import { serviceUse } from "./effect/service-use"
|
||||||
import { LayerNode } from "./effect/layer-node"
|
import { makeGlobalNode } from "./effect/node"
|
||||||
import { filesystem } from "./effect/layer-node-platform"
|
import { filesystem } from "./effect/layer-node-platform"
|
||||||
|
|
||||||
export namespace FSUtil {
|
export namespace FSUtil {
|
||||||
@@ -201,7 +201,7 @@ export namespace FSUtil {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
|
||||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [filesystem] })
|
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [filesystem] })
|
||||||
|
|
||||||
// Pure helpers that don't need Effect (path manipulation, sync operations)
|
// Pure helpers that don't need Effect (path manipulation, sync operations)
|
||||||
export function mimeType(p: string): string {
|
export function mimeType(p: string): string {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { ChildProcess } from "effect/unstable/process"
|
|||||||
import { AbsolutePath, RelativePath } from "./schema"
|
import { AbsolutePath, RelativePath } from "./schema"
|
||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
import { AppProcess } from "./process"
|
import { AppProcess } from "./process"
|
||||||
import { LayerNode } from "./effect/layer-node"
|
import { makeGlobalNode } from "./effect/node"
|
||||||
import { File } from "./file"
|
import { File } from "./file"
|
||||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||||
|
|
||||||
@@ -944,7 +944,7 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer))
|
||||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] })
|
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] })
|
||||||
|
|
||||||
interface Result {
|
interface Result {
|
||||||
readonly exitCode: number
|
readonly exitCode: number
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import os from "os"
|
|||||||
import { Context, Effect, Layer } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
import { Flock } from "./util/flock"
|
import { Flock } from "./util/flock"
|
||||||
import { Flag } from "./flag/flag"
|
import { Flag } from "./flag/flag"
|
||||||
import { LayerNode } from "./effect/layer-node"
|
import { makeGlobalNode } from "./effect/node"
|
||||||
|
|
||||||
const app = "opencode"
|
const app = "opencode"
|
||||||
const data = path.join(xdgData!, app)
|
const data = path.join(xdgData!, app)
|
||||||
@@ -77,7 +77,7 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer
|
export const defaultLayer = layer
|
||||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [] })
|
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [] })
|
||||||
|
|
||||||
export const layerWith = (input: Partial<Interface>) =>
|
export const layerWith = (input: Partial<Interface>) =>
|
||||||
Layer.effect(
|
Layer.effect(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as Image from "./image"
|
export * as Image from "./image"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { Config } from "./config"
|
import { Config } from "./config"
|
||||||
import { FileSystem } from "./filesystem"
|
import { FileSystem } from "./filesystem"
|
||||||
@@ -76,3 +77,5 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
|
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [Config.node] })
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Location } from "./location"
|
|||||||
import { AbsolutePath } from "./schema"
|
import { AbsolutePath } from "./schema"
|
||||||
import { SystemContext } from "./system-context/index"
|
import { SystemContext } from "./system-context/index"
|
||||||
import { SystemContextRegistry } from "./system-context/registry"
|
import { SystemContextRegistry } from "./system-context/registry"
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
|
|
||||||
class File extends Schema.Class<File>("InstructionContext.File")({
|
class File extends Schema.Class<File>("InstructionContext.File")({
|
||||||
path: AbsolutePath,
|
path: AbsolutePath,
|
||||||
@@ -87,6 +88,12 @@ export const layer = Layer.effectDiscard(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
name: "instruction-context",
|
||||||
|
layer,
|
||||||
|
deps: [FSUtil.node, Global.node, Location.node, SystemContextRegistry.node],
|
||||||
|
})
|
||||||
|
|
||||||
function render(files: ReadonlyArray<File>) {
|
function render(files: ReadonlyArray<File>) {
|
||||||
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
|
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as Integration from "./integration"
|
export * as Integration from "./integration"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import {
|
import {
|
||||||
Cause,
|
Cause,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -515,3 +516,5 @@ export const locationLayer = Layer.effect(
|
|||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [Credential.node, EventV2.node] })
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
import { Effect, Layer, LayerMap } from "effect"
|
|
||||||
import { Location } from "./location"
|
|
||||||
import { Policy } from "./policy"
|
|
||||||
import { Config } from "./config"
|
|
||||||
import { PluginV2 } from "./plugin"
|
|
||||||
import { Catalog } from "./catalog"
|
|
||||||
import { Integration } from "./integration"
|
|
||||||
import { CommandV2 } from "./command"
|
|
||||||
import { AgentV2 } from "./agent"
|
|
||||||
import { PluginInternal } from "./plugin/internal"
|
|
||||||
import { Project } from "./project"
|
|
||||||
import { ProjectCopy } from "./project/copy"
|
|
||||||
import { ProjectDirectories } from "./project/directories"
|
|
||||||
import { EventV2 } from "./event"
|
|
||||||
import { Credential } from "./credential"
|
|
||||||
import { Npm } from "./npm"
|
|
||||||
import { ModelsDev } from "./models-dev"
|
|
||||||
import { FSUtil } from "./fs-util"
|
|
||||||
import { Git } from "./git"
|
|
||||||
import { Global } from "./global"
|
|
||||||
import { Database } from "./database/database"
|
|
||||||
import { PermissionV2 } from "./permission"
|
|
||||||
import { PermissionSaved } from "./permission/saved"
|
|
||||||
import { FileSystem } from "./filesystem"
|
|
||||||
import { Ripgrep } from "./ripgrep"
|
|
||||||
import { Watcher } from "./filesystem/watcher"
|
|
||||||
import { LocationMutation } from "./location-mutation"
|
|
||||||
import { FileMutation } from "./file-mutation"
|
|
||||||
import { Reference } from "./reference"
|
|
||||||
import { ReferenceGuidance } from "./reference/guidance"
|
|
||||||
import { RepositoryCache } from "./repository-cache"
|
|
||||||
import { Pty } from "./pty"
|
|
||||||
import { SkillV2 } from "./skill"
|
|
||||||
import { SkillGuidance } from "./skill/guidance"
|
|
||||||
import { BuiltInTools } from "./tool/builtins"
|
|
||||||
import { Image } from "./image"
|
|
||||||
import { ToolRegistry } from "./tool/registry"
|
|
||||||
import { ApplicationTools } from "./tool/application-tools"
|
|
||||||
import { ToolOutputStore } from "./tool-output-store"
|
|
||||||
import { AppProcess } from "./process"
|
|
||||||
import { SessionStore } from "./session/store"
|
|
||||||
import { SessionTodo } from "./session/todo"
|
|
||||||
import { QuestionV2 } from "./question"
|
|
||||||
import { LLMClient } from "@opencode-ai/llm"
|
|
||||||
import { RequestExecutor } from "@opencode-ai/llm/route"
|
|
||||||
import * as SessionRunnerLLM from "./session/runner/llm"
|
|
||||||
import { SessionRunnerModel } from "./session/runner/model"
|
|
||||||
import { SystemContextBuiltIns } from "./system-context/builtins"
|
|
||||||
import { FetchHttpClient } from "effect/unstable/http"
|
|
||||||
import { Snapshot } from "./snapshot"
|
|
||||||
|
|
||||||
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
|
|
||||||
lookup: (ref: Location.Ref) => {
|
|
||||||
const boot = Layer.effectDiscard(
|
|
||||||
Effect.logInfo("booting location services", { directory: ref.directory, workspaceID: ref.workspaceID }),
|
|
||||||
)
|
|
||||||
const location = Location.layer(ref)
|
|
||||||
const systemContext = SystemContextBuiltIns.locationLayer
|
|
||||||
const base = Layer.mergeAll(
|
|
||||||
location,
|
|
||||||
Policy.locationLayer,
|
|
||||||
Config.locationLayer,
|
|
||||||
Reference.locationLayer,
|
|
||||||
PluginV2.locationLayer,
|
|
||||||
Catalog.locationLayer,
|
|
||||||
Integration.locationLayer,
|
|
||||||
CommandV2.locationLayer,
|
|
||||||
AgentV2.locationLayer,
|
|
||||||
PluginInternal.locationLayer,
|
|
||||||
ProjectCopy.locationLayer,
|
|
||||||
FileSystem.locationLayer,
|
|
||||||
Watcher.locationLayer,
|
|
||||||
Pty.locationLayer,
|
|
||||||
SkillV2.locationLayer,
|
|
||||||
systemContext,
|
|
||||||
LocationMutation.locationLayer.pipe(Layer.orDie),
|
|
||||||
).pipe(Layer.provideMerge(location))
|
|
||||||
const resources = ToolOutputStore.layer.pipe(Layer.provide(base))
|
|
||||||
const permissionsAndTools = ToolRegistry.layer.pipe(
|
|
||||||
Layer.provideMerge(PermissionV2.locationLayer),
|
|
||||||
Layer.provide(resources),
|
|
||||||
Layer.provide(base),
|
|
||||||
)
|
|
||||||
const services = Layer.mergeAll(base, resources, permissionsAndTools)
|
|
||||||
const image = Image.layer.pipe(Layer.provide(services))
|
|
||||||
const mutation = FileMutation.locationLayer.pipe(Layer.provide(services))
|
|
||||||
const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services))
|
|
||||||
const referenceGuidance = ReferenceGuidance.locationLayer.pipe(Layer.provide(services))
|
|
||||||
const todos = SessionTodo.layer.pipe(Layer.provide(services))
|
|
||||||
const questions = QuestionV2.locationLayer.pipe(Layer.provide(services))
|
|
||||||
const builtInTools = BuiltInTools.locationLayer.pipe(
|
|
||||||
Layer.provide(services),
|
|
||||||
Layer.provide(mutation),
|
|
||||||
Layer.provide(resources),
|
|
||||||
Layer.provide(todos),
|
|
||||||
Layer.provide(questions),
|
|
||||||
Layer.provide(image),
|
|
||||||
)
|
|
||||||
const model = SessionRunnerModel.locationLayer.pipe(Layer.provide(services))
|
|
||||||
const snapshot = Snapshot.locationLayer.pipe(Layer.provide(services))
|
|
||||||
const runner = SessionRunnerLLM.defaultLayer.pipe(
|
|
||||||
Layer.provide(services),
|
|
||||||
Layer.provide(model),
|
|
||||||
Layer.provide(skillGuidance),
|
|
||||||
Layer.provide(referenceGuidance),
|
|
||||||
Layer.provide(snapshot),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Kick off a background project copy refresh to update locations now that we
|
|
||||||
// have a location
|
|
||||||
const projectCopyRefresh = Layer.effectDiscard(ProjectCopy.refreshAfterBoot).pipe(Layer.provide(services))
|
|
||||||
|
|
||||||
return Layer.mergeAll(
|
|
||||||
boot,
|
|
||||||
services,
|
|
||||||
image,
|
|
||||||
mutation,
|
|
||||||
resources,
|
|
||||||
todos,
|
|
||||||
questions,
|
|
||||||
model,
|
|
||||||
snapshot,
|
|
||||||
runner,
|
|
||||||
builtInTools,
|
|
||||||
referenceGuidance,
|
|
||||||
projectCopyRefresh,
|
|
||||||
).pipe(Layer.fresh)
|
|
||||||
},
|
|
||||||
idleTimeToLive: "60 minutes",
|
|
||||||
dependencies: [
|
|
||||||
Project.defaultLayer,
|
|
||||||
EventV2.defaultLayer,
|
|
||||||
Credential.defaultLayer,
|
|
||||||
Npm.defaultLayer,
|
|
||||||
ModelsDev.defaultLayer,
|
|
||||||
FSUtil.defaultLayer,
|
|
||||||
Git.defaultLayer,
|
|
||||||
AppProcess.defaultLayer,
|
|
||||||
Global.defaultLayer,
|
|
||||||
Ripgrep.defaultLayer,
|
|
||||||
Database.defaultLayer,
|
|
||||||
ProjectDirectories.defaultLayer,
|
|
||||||
SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
|
|
||||||
PermissionSaved.defaultLayer,
|
|
||||||
RepositoryCache.defaultLayer,
|
|
||||||
LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)),
|
|
||||||
FetchHttpClient.layer,
|
|
||||||
ToolOutputStore.defaultCleanupLayer,
|
|
||||||
ApplicationTools.layer,
|
|
||||||
],
|
|
||||||
}) {}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as LocationMutation from "./location-mutation"
|
export * as LocationMutation from "./location-mutation"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
@@ -153,3 +154,9 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer: layer.pipe(Layer.orDie),
|
||||||
|
deps: [FSUtil.node, Location.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Context, Effect, Layer, LayerMap } from "effect"
|
||||||
|
import { LayerNode } from "./effect/layer-node"
|
||||||
|
import { Node } from "./effect/node"
|
||||||
|
import { Location } from "./location"
|
||||||
|
import type { LocationError, LocationServices } from "./location-services"
|
||||||
|
|
||||||
|
export class Service extends Context.Service<
|
||||||
|
Service,
|
||||||
|
LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>
|
||||||
|
>()("@opencode/example/LocationServiceMap") {
|
||||||
|
static get(ref: Location.Ref) {
|
||||||
|
return Layer.unwrap(Effect.map(Service, (locations) => locations.get(ref)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const node = LayerNode.unbound(Service, Node.tags.values.global)
|
||||||
|
|
||||||
|
export * as LocationServiceMap from "./location-service-map"
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Effect, Layer, LayerMap } from "effect"
|
||||||
|
import { AgentV2 } from "./agent"
|
||||||
|
import { AISDK } from "./aisdk"
|
||||||
|
import { Catalog } from "./catalog"
|
||||||
|
import { CommandV2 } from "./command"
|
||||||
|
import { Config } from "./config"
|
||||||
|
import { LayerNode, LayerNodeTree } from "./effect/layer-node"
|
||||||
|
import { Node } from "./effect/node"
|
||||||
|
import { FileMutation } from "./file-mutation"
|
||||||
|
import { FileSystem } from "./filesystem"
|
||||||
|
import { FileSystemSearch } from "./filesystem/search"
|
||||||
|
import { Watcher } from "./filesystem/watcher"
|
||||||
|
import { Image } from "./image"
|
||||||
|
import { Integration } from "./integration"
|
||||||
|
import { Location } from "./location"
|
||||||
|
import { LocationMutation } from "./location-mutation"
|
||||||
|
import { LocationServiceMap } from "./location-service-map"
|
||||||
|
import { PermissionV2 } from "./permission"
|
||||||
|
import { PluginV2 } from "./plugin"
|
||||||
|
import { PluginInternal } from "./plugin/internal"
|
||||||
|
import { Policy } from "./policy"
|
||||||
|
import { ProjectCopy } from "./project/copy"
|
||||||
|
import { Pty } from "./pty"
|
||||||
|
import { QuestionV2 } from "./question"
|
||||||
|
import { Reference } from "./reference"
|
||||||
|
import { ReferenceGuidance } from "./reference/guidance"
|
||||||
|
import * as SessionRunnerLLM from "./session/runner/llm"
|
||||||
|
import { SessionRunnerModel } from "./session/runner/model"
|
||||||
|
import { SessionTodo } from "./session/todo"
|
||||||
|
import { SkillV2 } from "./skill"
|
||||||
|
import { SkillGuidance } from "./skill/guidance"
|
||||||
|
import { Snapshot } from "./snapshot"
|
||||||
|
import { SystemContextBuiltIns } from "./system-context/builtins"
|
||||||
|
import { SystemContextRegistry } from "./system-context/registry"
|
||||||
|
import { BuiltInTools } from "./tool/builtins"
|
||||||
|
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||||
|
import { ToolRegistry } from "./tool/registry"
|
||||||
|
import { ToolOutputStore } from "./tool-output-store"
|
||||||
|
|
||||||
|
export { LocationServiceMap } from "./location-service-map"
|
||||||
|
|
||||||
|
export const locationServices = LayerNode.group([
|
||||||
|
Location.node,
|
||||||
|
Policy.node,
|
||||||
|
Config.node,
|
||||||
|
AgentV2.node,
|
||||||
|
CommandV2.node,
|
||||||
|
Reference.node,
|
||||||
|
Integration.node,
|
||||||
|
Catalog.node,
|
||||||
|
AISDK.node,
|
||||||
|
PluginV2.node,
|
||||||
|
PluginInternal.node,
|
||||||
|
ProjectCopy.node,
|
||||||
|
ProjectCopy.refreshNode,
|
||||||
|
FileSystemSearch.node,
|
||||||
|
FileSystem.node,
|
||||||
|
Watcher.node,
|
||||||
|
Pty.node,
|
||||||
|
SkillV2.node,
|
||||||
|
SystemContextRegistry.node,
|
||||||
|
SystemContextBuiltIns.node,
|
||||||
|
LocationMutation.node,
|
||||||
|
FileMutation.node,
|
||||||
|
PermissionV2.node,
|
||||||
|
ToolOutputStore.node,
|
||||||
|
ToolRegistry.node,
|
||||||
|
ToolRegistry.toolsNode,
|
||||||
|
Image.node,
|
||||||
|
SkillGuidance.node,
|
||||||
|
ReferenceGuidance.node,
|
||||||
|
SessionTodo.node,
|
||||||
|
QuestionV2.node,
|
||||||
|
ReadToolFileSystem.node,
|
||||||
|
BuiltInTools.node,
|
||||||
|
SessionRunnerModel.node,
|
||||||
|
Snapshot.node,
|
||||||
|
SessionRunnerLLM.node,
|
||||||
|
])
|
||||||
|
|
||||||
|
export type LocationServices = LayerNode.Output<typeof locationServices>
|
||||||
|
export type LocationError = LayerNode.Error<typeof locationServices>
|
||||||
|
|
||||||
|
export function buildLocationServiceMap(
|
||||||
|
replacements?: ReadonlyMap<Layer.Any, Layer.Any>,
|
||||||
|
): Layer.Layer<LocationServiceMap.Service> {
|
||||||
|
return Layer.effect(
|
||||||
|
LocationServiceMap.Service,
|
||||||
|
LayerMap.make(
|
||||||
|
(ref: Location.Ref) => {
|
||||||
|
const location = LayerNodeTree.hoist(
|
||||||
|
LayerNodeTree.bind(locationServices, Location.node, Location.boundNode(ref)),
|
||||||
|
Node.tags.values.global,
|
||||||
|
)
|
||||||
|
return LayerNodeTree.compile(location.node, replacements).pipe(
|
||||||
|
Layer.fresh,
|
||||||
|
Layer.tap(() =>
|
||||||
|
Effect.logInfo("booting location services", {
|
||||||
|
directory: ref.directory,
|
||||||
|
workspaceID: ref.workspaceID,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Layer.provide(LayerNodeTree.compile(location.hoisted, replacements)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ idleTimeToLive: "60 minutes" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is temporary for backwards compatibility
|
||||||
|
export const locationServiceMapLayer = buildLocationServiceMap()
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Context, Effect, Layer } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
import { Info, Ref, response } from "@opencode-ai/schema/location"
|
import { Info, Ref, response } from "@opencode-ai/schema/location"
|
||||||
import { Project } from "./project"
|
import { Project } from "./project"
|
||||||
|
import { LayerNode } from "./effect/layer-node"
|
||||||
|
import { makeLocationNode, tags } from "./effect/node"
|
||||||
|
|
||||||
export * as Location from "./location"
|
export * as Location from "./location"
|
||||||
|
|
||||||
@@ -12,6 +14,8 @@ export interface Interface extends Info {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
|
||||||
|
|
||||||
|
export const node = LayerNode.unbound(Service, tags.values.location)
|
||||||
|
|
||||||
export const layer = (ref: Ref) =>
|
export const layer = (ref: Ref) =>
|
||||||
Layer.effect(
|
Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
@@ -26,3 +30,10 @@ export const layer = (ref: Ref) =>
|
|||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const boundNode = (ref: Ref) =>
|
||||||
|
makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer: layer(ref),
|
||||||
|
deps: [Project.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Hash } from "./util/hash"
|
|||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
import { InstallationChannel, InstallationVersion } from "./installation/version"
|
import { InstallationChannel, InstallationVersion } from "./installation/version"
|
||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
import { LayerNode } from "./effect/layer-node"
|
import { makeGlobalNode } from "./effect/node"
|
||||||
import { httpClient } from "./effect/layer-node-platform"
|
import { httpClient } from "./effect/layer-node-platform"
|
||||||
|
|
||||||
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
|
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
|
||||||
@@ -244,6 +244,6 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(FSUtil.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(EventV2.defaultLayer),
|
Layer.provide(EventV2.defaultLayer),
|
||||||
)
|
)
|
||||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] })
|
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] })
|
||||||
|
|
||||||
export * as ModelsDev from "./models-dev"
|
export * as ModelsDev from "./models-dev"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { NodeFileSystem } from "@effect/platform-node"
|
|||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
import { Global } from "./global"
|
import { Global } from "./global"
|
||||||
import { EffectFlock } from "./util/effect-flock"
|
import { EffectFlock } from "./util/effect-flock"
|
||||||
import { LayerNode } from "./effect/layer-node"
|
import { makeGlobalNode } from "./effect/node"
|
||||||
import { filesystem } from "./effect/layer-node-platform"
|
import { filesystem } from "./effect/layer-node-platform"
|
||||||
import { makeRuntime } from "./effect/runtime"
|
import { makeRuntime } from "./effect/runtime"
|
||||||
import { NpmConfig } from "./npm-config"
|
import { NpmConfig } from "./npm-config"
|
||||||
@@ -253,7 +253,7 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(Global.layer),
|
Layer.provide(Global.layer),
|
||||||
Layer.provide(NodeFileSystem.layer),
|
Layer.provide(NodeFileSystem.layer),
|
||||||
)
|
)
|
||||||
export const node = LayerNode.make({
|
export const node = makeGlobalNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer: layer,
|
layer: layer,
|
||||||
deps: [FSUtil.node, Global.node, filesystem, EffectFlock.node],
|
deps: [FSUtil.node, Global.node, filesystem, EffectFlock.node],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as PermissionV2 from "./permission"
|
export * as PermissionV2 from "./permission"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect"
|
import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect"
|
||||||
import { Permission } from "@opencode-ai/schema/permission"
|
import { Permission } from "@opencode-ai/schema/permission"
|
||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
@@ -300,3 +301,9 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer.pipe(Layer.provideMerge(AgentV2.locationLayer))
|
export const locationLayer = layer.pipe(Layer.provideMerge(AgentV2.locationLayer))
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer,
|
||||||
|
deps: [EventV2.node, Location.node, AgentV2.node, SessionStore.node, PermissionSaved.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export * as PermissionSaved from "./saved"
|
|||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { Database } from "../database/database"
|
import { Database } from "../database/database"
|
||||||
|
import { makeGlobalNode } from "../effect/node"
|
||||||
import { ProjectV2 } from "../project"
|
import { ProjectV2 } from "../project"
|
||||||
import { PermissionTable } from "./sql"
|
import { PermissionTable } from "./sql"
|
||||||
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||||
@@ -76,3 +77,5 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||||
|
|
||||||
|
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as PluginV2 from "./plugin"
|
export * as PluginV2 from "./plugin"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect"
|
import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect"
|
||||||
import type { Plugin as PluginRuntime } from "@opencode-ai/plugin/v2/effect"
|
import type { Plugin as PluginRuntime } from "@opencode-ai/plugin/v2/effect"
|
||||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||||
@@ -150,3 +151,18 @@ export const locationLayer = layer.pipe(
|
|||||||
Layer.provideMerge(Reference.locationLayer),
|
Layer.provideMerge(Reference.locationLayer),
|
||||||
Layer.provideMerge(SkillV2.locationLayer),
|
Layer.provideMerge(SkillV2.locationLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer,
|
||||||
|
deps: [
|
||||||
|
EventV2.node,
|
||||||
|
AgentV2.node,
|
||||||
|
AISDK.node,
|
||||||
|
Catalog.node,
|
||||||
|
CommandV2.node,
|
||||||
|
Integration.node,
|
||||||
|
Reference.node,
|
||||||
|
SkillV2.node,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export * as PluginInternal from "./internal"
|
export * as PluginInternal from "./internal"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "../effect/node"
|
||||||
|
import { httpClient } from "../effect/layer-node-platform"
|
||||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||||
import { Effect, Layer, Scope } from "effect"
|
import { Effect, Layer, Scope } from "effect"
|
||||||
import { AgentV2 } from "../agent"
|
import { AgentV2 } from "../agent"
|
||||||
@@ -57,7 +59,7 @@ export function define<R>(plugin: Plugin<R>) {
|
|||||||
return plugin
|
return plugin
|
||||||
}
|
}
|
||||||
|
|
||||||
export const locationLayer = Layer.effectDiscard(
|
const layer = Layer.effectDiscard(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
const commands = yield* CommandV2.Service
|
const commands = yield* CommandV2.Service
|
||||||
@@ -117,9 +119,34 @@ export const locationLayer = Layer.effectDiscard(
|
|||||||
yield* add(VariantPlugin.Plugin)
|
yield* add(VariantPlugin.Plugin)
|
||||||
}).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
|
}).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
|
||||||
}),
|
}),
|
||||||
).pipe(
|
)
|
||||||
|
|
||||||
|
export const locationLayer = layer.pipe(
|
||||||
Layer.provideMerge(PluginV2.locationLayer),
|
Layer.provideMerge(PluginV2.locationLayer),
|
||||||
Layer.provideMerge(Config.locationLayer),
|
Layer.provideMerge(Config.locationLayer),
|
||||||
Layer.provideMerge(FileSystem.locationLayer),
|
Layer.provideMerge(FileSystem.locationLayer),
|
||||||
Layer.provideMerge(FetchHttpClient.layer),
|
Layer.provideMerge(FetchHttpClient.layer),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
name: "plugin-internal",
|
||||||
|
layer,
|
||||||
|
deps: [
|
||||||
|
Catalog.node,
|
||||||
|
CommandV2.node,
|
||||||
|
PluginV2.node,
|
||||||
|
Integration.node,
|
||||||
|
AgentV2.node,
|
||||||
|
Config.node,
|
||||||
|
Location.node,
|
||||||
|
ModelsDev.node,
|
||||||
|
Npm.node,
|
||||||
|
EventV2.node,
|
||||||
|
FSUtil.node,
|
||||||
|
FileSystem.node,
|
||||||
|
Global.node,
|
||||||
|
httpClient,
|
||||||
|
SkillV2.node,
|
||||||
|
Reference.node,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as Policy from "./policy"
|
export * as Policy from "./policy"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import { Context, Effect as EffectRuntime, Layer, Schema } from "effect"
|
import { Context, Effect as EffectRuntime, Layer, Schema } from "effect"
|
||||||
import { Wildcard } from "./util/wildcard"
|
import { Wildcard } from "./util/wildcard"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
@@ -44,3 +45,5 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] })
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { PlatformError } from "effect/PlatformError"
|
|||||||
import { ChildProcess } from "effect/unstable/process"
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||||
import { CrossSpawnSpawner } from "./cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "./cross-spawn-spawner"
|
||||||
import { LayerNode } from "./effect/layer-node"
|
import { makeGlobalNode } from "./effect/node"
|
||||||
|
|
||||||
export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()("AppProcessError", {
|
export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()("AppProcessError", {
|
||||||
command: Schema.String,
|
command: Schema.String,
|
||||||
@@ -238,6 +238,6 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
|
||||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [CrossSpawnSpawner.node] })
|
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [CrossSpawnSpawner.node] })
|
||||||
|
|
||||||
export * as AppProcess from "./process"
|
export * as AppProcess from "./process"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import path from "path"
|
|||||||
import { AbsolutePath } from "./schema"
|
import { AbsolutePath } from "./schema"
|
||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
import { Git } from "./git"
|
import { Git } from "./git"
|
||||||
import { LayerNode } from "./effect/layer-node"
|
import { makeGlobalNode } from "./effect/node"
|
||||||
import { Hash } from "./util/hash"
|
import { Hash } from "./util/hash"
|
||||||
import { ProjectDirectories } from "./project/directories"
|
import { ProjectDirectories } from "./project/directories"
|
||||||
import { ProjectSchema } from "./project/schema"
|
import { ProjectSchema } from "./project/schema"
|
||||||
@@ -134,7 +134,7 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(Git.defaultLayer),
|
Layer.provide(Git.defaultLayer),
|
||||||
Layer.provideMerge(ProjectDirectories.defaultLayer),
|
Layer.provideMerge(ProjectDirectories.defaultLayer),
|
||||||
)
|
)
|
||||||
export const node = LayerNode.make({
|
export const node = makeGlobalNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer: layer,
|
layer: layer,
|
||||||
deps: [FSUtil.node, Git.node, ProjectDirectories.node],
|
deps: [FSUtil.node, Git.node, ProjectDirectories.node],
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import path from "path"
|
|||||||
import { AbsolutePath } from "../schema"
|
import { AbsolutePath } from "../schema"
|
||||||
import { FSUtil } from "../fs-util"
|
import { FSUtil } from "../fs-util"
|
||||||
import { Git } from "../git"
|
import { Git } from "../git"
|
||||||
import { LayerNode } from "../effect/layer-node"
|
import { makeLocationNode } from "../effect/node"
|
||||||
import { Project } from "../project"
|
import { Project } from "../project"
|
||||||
import { ProjectDirectories } from "./directories"
|
import { ProjectDirectories } from "./directories"
|
||||||
import { makeGitWorktreeStrategy } from "./copy-strategies"
|
import { makeGitWorktreeStrategy } from "./copy-strategies"
|
||||||
@@ -279,8 +279,14 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
export const node = LayerNode.make({
|
export const node = makeLocationNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer: layer,
|
layer: layer,
|
||||||
deps: [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node],
|
deps: [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const refreshNode = makeLocationNode({
|
||||||
|
name: "project-copy-refresh",
|
||||||
|
layer: Layer.effectDiscard(refreshAfterBoot),
|
||||||
|
deps: [node, Location.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export * as ProjectDirectories from "./directories"
|
|||||||
import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
|
import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { Database } from "../database/database"
|
import { Database } from "../database/database"
|
||||||
import { LayerNode } from "../effect/layer-node"
|
import { makeGlobalNode } from "../effect/node"
|
||||||
import { AbsolutePath, optional } from "../schema"
|
import { AbsolutePath, optional } from "../schema"
|
||||||
import { ProjectSchema } from "./schema"
|
import { ProjectSchema } from "./schema"
|
||||||
import { ProjectDirectoryTable } from "./sql"
|
import { ProjectDirectoryTable } from "./sql"
|
||||||
@@ -156,4 +156,4 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] })
|
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as Pty from "./pty"
|
export * as Pty from "./pty"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import type { Disp, Proc } from "#pty"
|
import type { Disp, Proc } from "#pty"
|
||||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||||
import { Pty } from "@opencode-ai/schema/pty"
|
import { Pty } from "@opencode-ai/schema/pty"
|
||||||
@@ -313,3 +314,5 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
|
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Location.node, Config.node] })
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { WorkspaceV2 } from "../workspace"
|
|||||||
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
|
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
|
||||||
import { PtyID } from "./schema"
|
import { PtyID } from "./schema"
|
||||||
import { Cache, Context, Duration, Effect, Layer } from "effect"
|
import { Cache, Context, Duration, Effect, Layer } from "effect"
|
||||||
import { LayerNode } from "../effect/layer-node"
|
import { makeGlobalNode } from "../effect/node"
|
||||||
|
|
||||||
const DEFAULT_TTL = Duration.seconds(60)
|
const DEFAULT_TTL = Duration.seconds(60)
|
||||||
const CAPACITY = 10_000
|
const CAPACITY = 10_000
|
||||||
@@ -54,4 +54,4 @@ export const make = (ttl: Duration.Input = DEFAULT_TTL) =>
|
|||||||
export const layer = Layer.effect(Service, make())
|
export const layer = Layer.effect(Service, make())
|
||||||
|
|
||||||
export const defaultLayer = layer
|
export const defaultLayer = layer
|
||||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [] })
|
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [] })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as QuestionV2 from "./question"
|
export * as QuestionV2 from "./question"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import { Context, Deferred, Effect, Layer, Schema } from "effect"
|
import { Context, Deferred, Effect, Layer, Schema } from "effect"
|
||||||
import { Question } from "@opencode-ai/schema/question"
|
import { Question } from "@opencode-ai/schema/question"
|
||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
@@ -148,3 +149,5 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as Reference from "./reference"
|
export * as Reference from "./reference"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import { Context, Effect, Layer, Scope, Types } from "effect"
|
import { Context, Effect, Layer, Scope, Types } from "effect"
|
||||||
import { Reference } from "@opencode-ai/schema/reference"
|
import { Reference } from "@opencode-ai/schema/reference"
|
||||||
import { Global } from "./global"
|
import { Global } from "./global"
|
||||||
@@ -120,3 +121,9 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer,
|
||||||
|
deps: [Global.node, EventV2.node, RepositoryCache.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as ReferenceGuidance from "./guidance"
|
export * as ReferenceGuidance from "./guidance"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "../effect/node"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { Reference } from "../reference"
|
import { Reference } from "../reference"
|
||||||
import { SystemContext } from "../system-context/index"
|
import { SystemContext } from "../system-context/index"
|
||||||
@@ -64,3 +65,5 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [Reference.node] })
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Git } from "./git"
|
|||||||
import { Global } from "./global"
|
import { Global } from "./global"
|
||||||
import { Repository } from "./repository"
|
import { Repository } from "./repository"
|
||||||
import { AbsolutePath } from "./schema"
|
import { AbsolutePath } from "./schema"
|
||||||
|
import { makeGlobalNode } from "./effect/node"
|
||||||
import { EffectFlock } from "./util/effect-flock"
|
import { EffectFlock } from "./util/effect-flock"
|
||||||
|
|
||||||
export type Result = {
|
export type Result = {
|
||||||
@@ -229,6 +230,12 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
|||||||
Layer.provide(Global.defaultLayer),
|
Layer.provide(Global.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const node = makeGlobalNode({
|
||||||
|
service: Service,
|
||||||
|
layer,
|
||||||
|
deps: [EffectFlock.node, FSUtil.node, Git.node, Global.node],
|
||||||
|
})
|
||||||
|
|
||||||
function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
|
function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
|
||||||
if (!input.reuse) return "cloned" as const
|
if (!input.reuse) return "cloned" as const
|
||||||
if (input.branchMatches === false || input.refresh) return "refreshed" as const
|
if (input.branchMatches === false || input.refresh) return "refreshed" as const
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export * as Ripgrep from "./ripgrep"
|
|||||||
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||||
import { ChildProcess } from "effect/unstable/process"
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
import { Entry, Match } from "@opencode-ai/schema/filesystem"
|
import { Entry, Match } from "@opencode-ai/schema/filesystem"
|
||||||
import { LayerNode } from "./effect/layer-node"
|
import { makeGlobalNode } from "./effect/node"
|
||||||
import { AppProcess, collectStream, waitForAbort } from "./process"
|
import { AppProcess, collectStream, waitForAbort } from "./process"
|
||||||
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
||||||
import { RipgrepBinary } from "./ripgrep/binary"
|
import { RipgrepBinary } from "./ripgrep/binary"
|
||||||
@@ -279,4 +279,4 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Layer.merge(RipgrepBinary.defaultLayer, AppProcess.defaultLayer)))
|
export const defaultLayer = layer.pipe(Layer.provide(Layer.merge(RipgrepBinary.defaultLayer, AppProcess.defaultLayer)))
|
||||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] })
|
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] })
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/
|
|||||||
import { ChildProcess } from "effect/unstable/process"
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||||
import { CrossSpawnSpawner } from "../cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "../cross-spawn-spawner"
|
||||||
import { LayerNode } from "../effect/layer-node"
|
import { makeGlobalNode } from "../effect/node"
|
||||||
import { httpClient } from "../effect/layer-node-platform"
|
import { httpClient } from "../effect/layer-node-platform"
|
||||||
import { FSUtil } from "../fs-util"
|
import { FSUtil } from "../fs-util"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
@@ -130,7 +130,7 @@ export namespace RipgrepBinary {
|
|||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const node = LayerNode.make({
|
export const node = makeGlobalNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer: layer,
|
layer: layer,
|
||||||
deps: [FSUtil.node, httpClient, CrossSpawnSpawner.node],
|
deps: [FSUtil.node, httpClient, CrossSpawnSpawner.node],
|
||||||
|
|||||||
+287
-269
@@ -27,6 +27,8 @@ import { fromRow } from "./session/info"
|
|||||||
import { SessionRunner } from "./session/runner/index"
|
import { SessionRunner } from "./session/runner/index"
|
||||||
import { SessionStore } from "./session/store"
|
import { SessionStore } from "./session/store"
|
||||||
import { SessionExecution } from "./session/execution"
|
import { SessionExecution } from "./session/execution"
|
||||||
|
import { makeGlobalNode } from "./effect/node"
|
||||||
|
import { LocationServiceMap } from "./location-service-map"
|
||||||
import { MessageDecodeError } from "./session/error"
|
import { MessageDecodeError } from "./session/error"
|
||||||
import { SessionEvent } from "./session/event"
|
import { SessionEvent } from "./session/event"
|
||||||
import { SessionInput } from "./session/input"
|
import { SessionInput } from "./session/input"
|
||||||
@@ -34,6 +36,7 @@ import { Snapshot } from "./snapshot"
|
|||||||
import { SessionRevert } from "./session/revert"
|
import { SessionRevert } from "./session/revert"
|
||||||
import { Revert } from "@opencode-ai/schema/revert"
|
import { Revert } from "@opencode-ai/schema/revert"
|
||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
|
import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
|
||||||
|
|
||||||
export const RevertState = Revert.State
|
export const RevertState = Revert.State
|
||||||
export type RevertState = Revert.State
|
export type RevertState = Revert.State
|
||||||
@@ -131,6 +134,11 @@ export interface Interface {
|
|||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
after?: number
|
after?: number
|
||||||
}) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
|
}) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
|
||||||
|
readonly history: (input: {
|
||||||
|
sessionID: SessionSchema.ID
|
||||||
|
after?: number
|
||||||
|
limit: number
|
||||||
|
}) => Effect.Effect<{ events: ReadonlyArray<SessionEvent.DurableEvent>; hasMore: boolean }, NotFoundError>
|
||||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
|
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
|
||||||
readonly switchModel: (input: {
|
readonly switchModel: (input: {
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
@@ -173,281 +181,277 @@ export interface Interface {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
|
||||||
|
|
||||||
export const layer = Layer.unwrap(
|
export const layer = Layer.effect(
|
||||||
Effect.promise(() => import("./location-layer")).pipe(
|
Service,
|
||||||
Effect.map(({ LocationServiceMap }) =>
|
Effect.gen(function* () {
|
||||||
Layer.effect(
|
const database = yield* Database.Service
|
||||||
Service,
|
const db = database.db
|
||||||
Effect.gen(function* () {
|
const events = yield* EventV2.Service
|
||||||
const database = yield* Database.Service
|
const projects = yield* ProjectV2.Service
|
||||||
const db = database.db
|
const execution = yield* SessionExecution.Service
|
||||||
const events = yield* EventV2.Service
|
const store = yield* SessionStore.Service
|
||||||
const projects = yield* ProjectV2.Service
|
const locations = yield* LocationServiceMap.Service
|
||||||
const execution = yield* SessionExecution.Service
|
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||||
const store = yield* SessionStore.Service
|
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||||
const locations = yield* LocationServiceMap
|
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
Effect.mapError(
|
||||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
() =>
|
||||||
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
new MessageDecodeError({
|
||||||
Effect.mapError(
|
sessionID: SessionSchema.ID.make(row.session_id),
|
||||||
() =>
|
messageID: SessionMessage.ID.make(row.id),
|
||||||
new MessageDecodeError({
|
}),
|
||||||
sessionID: SessionSchema.ID.make(row.session_id),
|
),
|
||||||
messageID: SessionMessage.ID.make(row.id),
|
)
|
||||||
}),
|
|
||||||
|
const result = Service.of({
|
||||||
|
create: Effect.fn("V2Session.create")(function* (input) {
|
||||||
|
const sessionID = input.id ?? SessionSchema.ID.create()
|
||||||
|
const recorded = yield* store.get(sessionID)
|
||||||
|
if (recorded) return recorded
|
||||||
|
const project = yield* projects.resolve(input.location.directory)
|
||||||
|
yield* db
|
||||||
|
.insert(ProjectTable)
|
||||||
|
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
const now = Date.now()
|
||||||
|
const info = SessionV1.SessionInfo.make({
|
||||||
|
id: sessionID,
|
||||||
|
slug: Slug.create(),
|
||||||
|
version: InstallationVersion,
|
||||||
|
projectID: project.id,
|
||||||
|
directory: input.location.directory,
|
||||||
|
path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"),
|
||||||
|
workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined,
|
||||||
|
title: `New session - ${new Date(now).toISOString()}`,
|
||||||
|
agent: input.agent,
|
||||||
|
model: input.model
|
||||||
|
? {
|
||||||
|
id: ModelV2.ID.make(input.model.id),
|
||||||
|
providerID: input.model.providerID,
|
||||||
|
variant: input.model.variant,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: { created: now, updated: now },
|
||||||
|
})
|
||||||
|
const projected = yield* events
|
||||||
|
.publish(SessionV1.Event.Created, { sessionID, info }, { location: input.location })
|
||||||
|
.pipe(
|
||||||
|
Effect.as({ type: "created" } as const),
|
||||||
|
Effect.catchDefect((defect) => {
|
||||||
|
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
|
||||||
|
return Effect.die(defect)
|
||||||
|
}
|
||||||
|
// Concurrent creation lost the projection race. The existing Session identity wins.
|
||||||
|
return store
|
||||||
|
.get(sessionID)
|
||||||
|
.pipe(
|
||||||
|
Effect.flatMap((session) =>
|
||||||
|
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (projected.type === "existing") return projected.session
|
||||||
|
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
|
||||||
|
return yield* result.get(sessionID).pipe(Effect.orDie)
|
||||||
|
}),
|
||||||
|
get: Effect.fn("V2Session.get")(function* (sessionID) {
|
||||||
|
const session = yield* store.get(sessionID)
|
||||||
|
if (!session) return yield* new NotFoundError({ sessionID })
|
||||||
|
return session
|
||||||
|
}),
|
||||||
|
list: Effect.fn("V2Session.list")(function* (input = {}) {
|
||||||
|
const direction = input.anchor?.direction ?? "next"
|
||||||
|
const requestedOrder = input.order ?? "desc"
|
||||||
|
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||||
|
const sortColumn = SessionTable.time_created
|
||||||
|
const conditions: SQL[] = []
|
||||||
|
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
|
||||||
|
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
||||||
|
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
|
||||||
|
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||||
|
if (input.anchor) {
|
||||||
|
conditions.push(
|
||||||
|
order === "asc"
|
||||||
|
? or(
|
||||||
|
gt(sortColumn, input.anchor.time),
|
||||||
|
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
|
||||||
|
)!
|
||||||
|
: or(
|
||||||
|
lt(sortColumn, input.anchor.time),
|
||||||
|
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
|
||||||
|
)!,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const query = db
|
||||||
|
.select()
|
||||||
|
.from(SessionTable)
|
||||||
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||||
|
.orderBy(
|
||||||
|
order === "asc" ? asc(sortColumn) : desc(sortColumn),
|
||||||
|
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
|
||||||
|
)
|
||||||
|
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||||
|
Effect.orDie,
|
||||||
|
)
|
||||||
|
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
|
||||||
|
}),
|
||||||
|
messages: Effect.fn("V2Session.messages")(function* (input) {
|
||||||
|
yield* result.get(input.sessionID)
|
||||||
|
const direction = input.cursor?.direction ?? "next"
|
||||||
|
const requestedOrder = input.order ?? "desc"
|
||||||
|
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||||
|
const anchor = input.cursor
|
||||||
|
? yield* db
|
||||||
|
.select({ seq: SessionMessageTable.seq })
|
||||||
|
.from(SessionMessageTable)
|
||||||
|
.where(
|
||||||
|
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)),
|
||||||
|
)
|
||||||
|
.get()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
: undefined
|
||||||
|
if (input.cursor && !anchor) return []
|
||||||
|
const boundary = anchor
|
||||||
|
? order === "asc"
|
||||||
|
? gt(SessionMessageTable.seq, anchor.seq)
|
||||||
|
: lt(SessionMessageTable.seq, anchor.seq)
|
||||||
|
: undefined
|
||||||
|
const where = boundary
|
||||||
|
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
|
||||||
|
: eq(SessionMessageTable.session_id, input.sessionID)
|
||||||
|
const query = db
|
||||||
|
.select()
|
||||||
|
.from(SessionMessageTable)
|
||||||
|
.where(where)
|
||||||
|
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
|
||||||
|
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||||
|
Effect.orDie,
|
||||||
|
)
|
||||||
|
return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode)
|
||||||
|
}),
|
||||||
|
message: Effect.fn("V2Session.message")(function* (input) {
|
||||||
|
const stored = yield* store.message(input.messageID)
|
||||||
|
return stored?.sessionID === input.sessionID ? stored.message : undefined
|
||||||
|
}),
|
||||||
|
context: Effect.fn("V2Session.context")(function* (sessionID) {
|
||||||
|
yield* result.get(sessionID)
|
||||||
|
return yield* store.context(sessionID)
|
||||||
|
}),
|
||||||
|
events: (input) =>
|
||||||
|
Stream.unwrap(
|
||||||
|
result
|
||||||
|
.get(input.sessionID)
|
||||||
|
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
|
||||||
|
).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
|
||||||
|
history: Effect.fn("V2Session.history")(function* (input) {
|
||||||
|
yield* result.get(input.sessionID)
|
||||||
|
return yield* EventV2.readAggregate(db, {
|
||||||
|
...input,
|
||||||
|
aggregateID: input.sessionID,
|
||||||
|
manifest: SessionDurable,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
prompt: Effect.fn("V2Session.prompt")((input) =>
|
||||||
|
Effect.uninterruptible(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* result.get(input.sessionID)
|
||||||
|
const prompt = resolvePrompt(input.prompt)
|
||||||
|
const messageID = input.id ?? SessionMessage.ID.create()
|
||||||
|
const delivery = input.delivery ?? "steer"
|
||||||
|
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
|
||||||
|
const admitted = yield* SessionInput.admit(db, events, {
|
||||||
|
id: messageID,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
prompt,
|
||||||
|
delivery,
|
||||||
|
}).pipe(
|
||||||
|
Effect.catchDefect((defect) =>
|
||||||
|
defect instanceof SessionInput.LifecycleConflict
|
||||||
|
? new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||||
|
: Effect.die(defect),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if (!SessionInput.equivalent(admitted, expected))
|
||||||
const result = Service.of({
|
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||||
create: Effect.fn("V2Session.create")(function* (input) {
|
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
|
||||||
const sessionID = input.id ?? SessionSchema.ID.create()
|
return admitted
|
||||||
const recorded = yield* store.get(sessionID)
|
}),
|
||||||
if (recorded) return recorded
|
),
|
||||||
const project = yield* projects.resolve(input.location.directory)
|
|
||||||
yield* db
|
|
||||||
.insert(ProjectTable)
|
|
||||||
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
|
|
||||||
.onConflictDoNothing()
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
const now = Date.now()
|
|
||||||
const info = SessionV1.SessionInfo.make({
|
|
||||||
id: sessionID,
|
|
||||||
slug: Slug.create(),
|
|
||||||
version: InstallationVersion,
|
|
||||||
projectID: project.id,
|
|
||||||
directory: input.location.directory,
|
|
||||||
path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"),
|
|
||||||
workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined,
|
|
||||||
title: `New session - ${new Date(now).toISOString()}`,
|
|
||||||
agent: input.agent,
|
|
||||||
model: input.model
|
|
||||||
? {
|
|
||||||
id: ModelV2.ID.make(input.model.id),
|
|
||||||
providerID: input.model.providerID,
|
|
||||||
variant: input.model.variant,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
cost: 0,
|
|
||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
||||||
time: { created: now, updated: now },
|
|
||||||
})
|
|
||||||
const projected = yield* events
|
|
||||||
.publish(SessionV1.Event.Created, { sessionID, info }, { location: input.location })
|
|
||||||
.pipe(
|
|
||||||
Effect.as({ type: "created" } as const),
|
|
||||||
Effect.catchDefect((defect) => {
|
|
||||||
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
|
|
||||||
return Effect.die(defect)
|
|
||||||
}
|
|
||||||
// Concurrent creation lost the projection race. The existing Session identity wins.
|
|
||||||
return store
|
|
||||||
.get(sessionID)
|
|
||||||
.pipe(
|
|
||||||
Effect.flatMap((session) =>
|
|
||||||
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
if (projected.type === "existing") return projected.session
|
|
||||||
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
|
|
||||||
return yield* result.get(sessionID).pipe(Effect.orDie)
|
|
||||||
}),
|
|
||||||
get: Effect.fn("V2Session.get")(function* (sessionID) {
|
|
||||||
const session = yield* store.get(sessionID)
|
|
||||||
if (!session) return yield* new NotFoundError({ sessionID })
|
|
||||||
return session
|
|
||||||
}),
|
|
||||||
list: Effect.fn("V2Session.list")(function* (input = {}) {
|
|
||||||
const direction = input.anchor?.direction ?? "next"
|
|
||||||
const requestedOrder = input.order ?? "desc"
|
|
||||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
|
||||||
const sortColumn = SessionTable.time_created
|
|
||||||
const conditions: SQL[] = []
|
|
||||||
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
|
|
||||||
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
|
||||||
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
|
|
||||||
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
|
||||||
if (input.anchor) {
|
|
||||||
conditions.push(
|
|
||||||
order === "asc"
|
|
||||||
? or(
|
|
||||||
gt(sortColumn, input.anchor.time),
|
|
||||||
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
|
|
||||||
)!
|
|
||||||
: or(
|
|
||||||
lt(sortColumn, input.anchor.time),
|
|
||||||
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
|
|
||||||
)!,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const query = db
|
|
||||||
.select()
|
|
||||||
.from(SessionTable)
|
|
||||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
||||||
.orderBy(
|
|
||||||
order === "asc" ? asc(sortColumn) : desc(sortColumn),
|
|
||||||
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
|
|
||||||
)
|
|
||||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
|
||||||
Effect.orDie,
|
|
||||||
)
|
|
||||||
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
|
|
||||||
}),
|
|
||||||
messages: Effect.fn("V2Session.messages")(function* (input) {
|
|
||||||
yield* result.get(input.sessionID)
|
|
||||||
const direction = input.cursor?.direction ?? "next"
|
|
||||||
const requestedOrder = input.order ?? "desc"
|
|
||||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
|
||||||
const anchor = input.cursor
|
|
||||||
? yield* db
|
|
||||||
.select({ seq: SessionMessageTable.seq })
|
|
||||||
.from(SessionMessageTable)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(SessionMessageTable.session_id, input.sessionID),
|
|
||||||
eq(SessionMessageTable.id, input.cursor.id),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.get()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
: undefined
|
|
||||||
if (input.cursor && !anchor) return []
|
|
||||||
const boundary = anchor
|
|
||||||
? order === "asc"
|
|
||||||
? gt(SessionMessageTable.seq, anchor.seq)
|
|
||||||
: lt(SessionMessageTable.seq, anchor.seq)
|
|
||||||
: undefined
|
|
||||||
const where = boundary
|
|
||||||
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
|
|
||||||
: eq(SessionMessageTable.session_id, input.sessionID)
|
|
||||||
const query = db
|
|
||||||
.select()
|
|
||||||
.from(SessionMessageTable)
|
|
||||||
.where(where)
|
|
||||||
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
|
|
||||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
|
||||||
Effect.orDie,
|
|
||||||
)
|
|
||||||
return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode)
|
|
||||||
}),
|
|
||||||
message: Effect.fn("V2Session.message")(function* (input) {
|
|
||||||
const stored = yield* store.message(input.messageID)
|
|
||||||
return stored?.sessionID === input.sessionID ? stored.message : undefined
|
|
||||||
}),
|
|
||||||
context: Effect.fn("V2Session.context")(function* (sessionID) {
|
|
||||||
yield* result.get(sessionID)
|
|
||||||
return yield* store.context(sessionID)
|
|
||||||
}),
|
|
||||||
events: (input) =>
|
|
||||||
Stream.unwrap(
|
|
||||||
result
|
|
||||||
.get(input.sessionID)
|
|
||||||
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
|
|
||||||
).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
|
|
||||||
prompt: Effect.fn("V2Session.prompt")((input) =>
|
|
||||||
Effect.uninterruptible(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* result.get(input.sessionID)
|
|
||||||
const prompt = resolvePrompt(input.prompt)
|
|
||||||
const messageID = input.id ?? SessionMessage.ID.create()
|
|
||||||
const delivery = input.delivery ?? "steer"
|
|
||||||
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
|
|
||||||
const admitted = yield* SessionInput.admit(db, events, {
|
|
||||||
id: messageID,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
prompt,
|
|
||||||
delivery,
|
|
||||||
}).pipe(
|
|
||||||
Effect.catchDefect((defect) =>
|
|
||||||
defect instanceof SessionInput.LifecycleConflict
|
|
||||||
? new PromptConflictError({ sessionID: input.sessionID, messageID })
|
|
||||||
: Effect.die(defect),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (!SessionInput.equivalent(admitted, expected))
|
|
||||||
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
|
||||||
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
|
|
||||||
return admitted
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
shell: Effect.fn("V2Session.shell")(function* () {
|
|
||||||
return yield* new OperationUnavailableError({ operation: "shell" })
|
|
||||||
}),
|
|
||||||
skill: Effect.fn("V2Session.skill")(function* () {
|
|
||||||
return yield* new OperationUnavailableError({ operation: "skill" })
|
|
||||||
}),
|
|
||||||
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
|
|
||||||
yield* result.get(input.sessionID)
|
|
||||||
yield* events.publish(SessionEvent.AgentSwitched, {
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
messageID: SessionMessage.ID.create(),
|
|
||||||
timestamp: yield* DateTime.now,
|
|
||||||
agent: input.agent,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
|
|
||||||
yield* result.get(input.sessionID)
|
|
||||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
messageID: SessionMessage.ID.create(),
|
|
||||||
timestamp: yield* DateTime.now,
|
|
||||||
model: input.model,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
compact: Effect.fn("V2Session.compact")(function* (input) {
|
|
||||||
yield* result.get(input.sessionID)
|
|
||||||
return yield* new OperationUnavailableError({ operation: "compact" })
|
|
||||||
}),
|
|
||||||
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
|
|
||||||
yield* result.get(sessionID)
|
|
||||||
return yield* new OperationUnavailableError({ operation: "wait" })
|
|
||||||
}),
|
|
||||||
active: execution.active,
|
|
||||||
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
|
|
||||||
yield* result.get(sessionID)
|
|
||||||
yield* execution.resume(sessionID)
|
|
||||||
}),
|
|
||||||
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
|
||||||
Effect.uninterruptible(execution.interrupt(sessionID)),
|
|
||||||
),
|
|
||||||
revert: {
|
|
||||||
stage: Effect.fn("V2Session.revert.stage")(function* (input) {
|
|
||||||
const session = yield* result.get(input.sessionID)
|
|
||||||
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
|
|
||||||
Effect.provideService(Database.Service, database),
|
|
||||||
Effect.provideService(EventV2.Service, events),
|
|
||||||
Effect.provide(locations.get(session.location)),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) {
|
|
||||||
const session = yield* result.get(sessionID)
|
|
||||||
yield* SessionRevert.clear(session).pipe(
|
|
||||||
Effect.provideService(EventV2.Service, events),
|
|
||||||
Effect.provide(locations.get(session.location)),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) {
|
|
||||||
const session = yield* result.get(sessionID)
|
|
||||||
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
),
|
shell: Effect.fn("V2Session.shell")(function* () {
|
||||||
),
|
return yield* new OperationUnavailableError({ operation: "shell" })
|
||||||
|
}),
|
||||||
|
skill: Effect.fn("V2Session.skill")(function* () {
|
||||||
|
return yield* new OperationUnavailableError({ operation: "skill" })
|
||||||
|
}),
|
||||||
|
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
|
||||||
|
yield* result.get(input.sessionID)
|
||||||
|
yield* events.publish(SessionEvent.AgentSwitched, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
messageID: SessionMessage.ID.create(),
|
||||||
|
timestamp: yield* DateTime.now,
|
||||||
|
agent: input.agent,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
|
||||||
|
yield* result.get(input.sessionID)
|
||||||
|
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
messageID: SessionMessage.ID.create(),
|
||||||
|
timestamp: yield* DateTime.now,
|
||||||
|
model: input.model,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
compact: Effect.fn("V2Session.compact")(function* (input) {
|
||||||
|
yield* result.get(input.sessionID)
|
||||||
|
return yield* new OperationUnavailableError({ operation: "compact" })
|
||||||
|
}),
|
||||||
|
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
|
||||||
|
yield* result.get(sessionID)
|
||||||
|
return yield* new OperationUnavailableError({ operation: "wait" })
|
||||||
|
}),
|
||||||
|
active: execution.active,
|
||||||
|
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
|
||||||
|
yield* result.get(sessionID)
|
||||||
|
yield* execution.resume(sessionID)
|
||||||
|
}),
|
||||||
|
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
||||||
|
Effect.uninterruptible(execution.interrupt(sessionID)),
|
||||||
|
),
|
||||||
|
revert: {
|
||||||
|
stage: Effect.fn("V2Session.revert.stage")(function* (input) {
|
||||||
|
const session = yield* result.get(input.sessionID)
|
||||||
|
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
|
||||||
|
Effect.provideService(Database.Service, database),
|
||||||
|
Effect.provideService(EventV2.Service, events),
|
||||||
|
Effect.provide(locations.get(session.location)),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) {
|
||||||
|
const session = yield* result.get(sessionID)
|
||||||
|
yield* SessionRevert.clear(session).pipe(
|
||||||
|
Effect.provideService(EventV2.Service, events),
|
||||||
|
Effect.provide(locations.get(session.location)),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) {
|
||||||
|
const session = yield* result.get(sessionID)
|
||||||
|
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = layer.pipe(
|
||||||
Layer.provide(
|
|
||||||
Layer.unwrap(Effect.promise(() => import("./location-layer")).pipe(Effect.map((m) => m.LocationServiceMap.layer))),
|
|
||||||
),
|
|
||||||
Layer.provide(SessionStore.defaultLayer),
|
Layer.provide(SessionStore.defaultLayer),
|
||||||
Layer.provide(SessionProjector.defaultLayer),
|
Layer.provide(SessionProjector.defaultLayer),
|
||||||
Layer.provide(EventV2.defaultLayer),
|
Layer.provide(EventV2.defaultLayer),
|
||||||
@@ -469,3 +473,17 @@ const resolvePrompt = (input: PromptInput.Prompt) =>
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const node = makeGlobalNode({
|
||||||
|
service: Service,
|
||||||
|
layer: layer.pipe(Layer.orDie),
|
||||||
|
deps: [
|
||||||
|
Database.node,
|
||||||
|
EventV2.node,
|
||||||
|
ProjectV2.node,
|
||||||
|
SessionExecution.node,
|
||||||
|
SessionStore.node,
|
||||||
|
LocationServiceMap.node,
|
||||||
|
SessionProjector.node,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { SessionSchema } from "./schema"
|
|||||||
import { SessionContextEpochTable } from "./sql"
|
import { SessionContextEpochTable } from "./sql"
|
||||||
|
|
||||||
type DatabaseService = Database.Interface["db"]
|
type DatabaseService = Database.Interface["db"]
|
||||||
|
type Transaction = Parameters<Parameters<DatabaseService["transaction"]>[0]>[0]
|
||||||
|
|
||||||
interface Prepared {
|
interface Prepared {
|
||||||
readonly baseline: string
|
readonly baseline: string
|
||||||
@@ -109,7 +110,7 @@ const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseServic
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const reset = Effect.fn("SessionContextEpoch.reset")(function* (
|
export const reset = Effect.fn("SessionContextEpoch.reset")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService | Transaction,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
) {
|
) {
|
||||||
yield* db
|
yield* db
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
export * as SessionExecution from "./execution"
|
export * as SessionExecution from "./execution"
|
||||||
|
|
||||||
import { Context, Effect, Layer } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
|
import { LayerNode } from "../effect/layer-node"
|
||||||
|
import { Node } from "../effect/node"
|
||||||
import { SessionRunner } from "./runner/index"
|
import { SessionRunner } from "./runner/index"
|
||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
|
|
||||||
@@ -18,6 +20,8 @@ export interface Interface {
|
|||||||
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
|
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionExecution") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionExecution") {}
|
||||||
|
|
||||||
|
export const node = LayerNode.unbound(Service, Node.tags.values.global)
|
||||||
|
|
||||||
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
||||||
export const noopLayer = Layer.succeed(
|
export const noopLayer = Layer.succeed(
|
||||||
Service,
|
Service,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Cause, Effect, Layer } from "effect"
|
import { Cause, Effect, Layer } from "effect"
|
||||||
import { LocationServiceMap } from "../../location-layer"
|
import { LocationServiceMap } from "../../location-service-map"
|
||||||
|
import { makeGlobalNode } from "../../effect/node"
|
||||||
import { SessionRunCoordinator } from "../run-coordinator"
|
import { SessionRunCoordinator } from "../run-coordinator"
|
||||||
import { SessionRunner } from "../runner"
|
import { SessionRunner } from "../runner"
|
||||||
import { SessionSchema } from "../schema"
|
import { SessionSchema } from "../schema"
|
||||||
@@ -11,7 +12,7 @@ export const layer = Layer.effect(
|
|||||||
SessionExecution.Service,
|
SessionExecution.Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const store = yield* SessionStore.Service
|
const store = yield* SessionStore.Service
|
||||||
const locations = yield* LocationServiceMap
|
const locations = yield* LocationServiceMap.Service
|
||||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
|
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
|
||||||
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
|
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
|
||||||
const session = yield* store.get(sessionID)
|
const session = yield* store.get(sessionID)
|
||||||
@@ -37,3 +38,11 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(SessionStore.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(SessionStore.defaultLayer))
|
||||||
|
|
||||||
|
export const node = makeGlobalNode({
|
||||||
|
service: SessionExecution.Service,
|
||||||
|
layer,
|
||||||
|
deps: [SessionStore.node, LocationServiceMap.node],
|
||||||
|
})
|
||||||
|
|
||||||
|
export * as SessionExecutionLocal from "./local"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { and, desc, eq, gt, or, sql } from "drizzle-orm"
|
|||||||
import { DateTime, Effect, Layer, Schema } from "effect"
|
import { DateTime, Effect, Layer, Schema } from "effect"
|
||||||
import { Database } from "../database/database"
|
import { Database } from "../database/database"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
import { LayerNode } from "../effect/layer-node"
|
import { makeGlobalNode } from "../effect/node"
|
||||||
import { SessionEvent } from "./event"
|
import { SessionEvent } from "./event"
|
||||||
import { SessionV1 } from "../v1/session"
|
import { SessionV1 } from "../v1/session"
|
||||||
import { WorkspaceTable } from "../control-plane/workspace.sql"
|
import { WorkspaceTable } from "../control-plane/workspace.sql"
|
||||||
@@ -456,4 +456,4 @@ export const layer = Layer.effectDiscard(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer))
|
||||||
export const node = LayerNode.make({ name: "session-projector", layer, deps: [EventV2.node, Database.node] })
|
export const node = makeGlobalNode({ name: "session-projector", layer, deps: [EventV2.node, Database.node] })
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ import { createLLMEventPublisher } from "./publish-llm-event"
|
|||||||
import { toLLMMessages } from "./to-llm-message"
|
import { toLLMMessages } from "./to-llm-message"
|
||||||
import { MAX_STEPS_PROMPT } from "./max-steps"
|
import { MAX_STEPS_PROMPT } from "./max-steps"
|
||||||
import { Snapshot } from "../../snapshot"
|
import { Snapshot } from "../../snapshot"
|
||||||
|
import { makeLocationNode } from "../../effect/node"
|
||||||
|
import { llmClient } from "../../effect/layer-node-platform"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs one durable coding-agent Session until it settles.
|
* Runs one durable coding-agent Session until it settles.
|
||||||
@@ -404,3 +406,23 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer
|
export const defaultLayer = layer
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer,
|
||||||
|
deps: [
|
||||||
|
EventV2.node,
|
||||||
|
llmClient,
|
||||||
|
AgentV2.node,
|
||||||
|
ToolRegistry.node,
|
||||||
|
SessionRunnerModel.node,
|
||||||
|
SessionStore.node,
|
||||||
|
Location.node,
|
||||||
|
SystemContextRegistry.node,
|
||||||
|
SkillGuidance.node,
|
||||||
|
ReferenceGuidance.node,
|
||||||
|
Config.node,
|
||||||
|
Snapshot.node,
|
||||||
|
Database.node,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as SessionRunnerModel from "./model"
|
export * as SessionRunnerModel from "./model"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "../../effect/node"
|
||||||
import { type Model } from "@opencode-ai/llm"
|
import { type Model } from "@opencode-ai/llm"
|
||||||
import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages"
|
import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages"
|
||||||
import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat"
|
import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat"
|
||||||
@@ -213,3 +214,5 @@ export const locationLayer = Layer.effect(
|
|||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [Catalog.node, Integration.node] })
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export * as SessionStore from "./store"
|
|||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { Database } from "../database/database"
|
import { Database } from "../database/database"
|
||||||
|
import { makeGlobalNode } from "../effect/node"
|
||||||
import { SessionHistory } from "./history"
|
import { SessionHistory } from "./history"
|
||||||
import { MessageDecodeError } from "./error"
|
import { MessageDecodeError } from "./error"
|
||||||
import { SessionMessage } from "./message"
|
import { SessionMessage } from "./message"
|
||||||
@@ -60,3 +61,5 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||||
|
|
||||||
|
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { asc, eq } from "drizzle-orm"
|
|||||||
import { Context, Effect, Layer } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
import { SessionTodo } from "@opencode-ai/schema/session-todo"
|
import { SessionTodo } from "@opencode-ai/schema/session-todo"
|
||||||
import { Database } from "../database/database"
|
import { Database } from "../database/database"
|
||||||
|
import { makeLocationNode } from "../effect/node"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
import { TodoTable } from "./sql"
|
import { TodoTable } from "./sql"
|
||||||
@@ -75,3 +76,5 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer))
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Database.node] })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as SkillV2 from "./skill"
|
export * as SkillV2 from "./skill"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||||
import { Skill } from "@opencode-ai/schema/skill"
|
import { Skill } from "@opencode-ai/schema/skill"
|
||||||
@@ -129,3 +130,5 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer.pipe(Layer.provide(SkillDiscovery.defaultLayer))
|
export const locationLayer = layer.pipe(Layer.provide(SkillDiscovery.defaultLayer))
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [SkillDiscovery.node, FSUtil.node] })
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { Context, Effect, Layer, Schedule, Schema } from "effect"
|
|||||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { FSUtil } from "../fs-util"
|
import { FSUtil } from "../fs-util"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
|
import { makeGlobalNode } from "../effect/node"
|
||||||
|
import { httpClient } from "../effect/layer-node-platform"
|
||||||
import { AbsolutePath } from "../schema"
|
import { AbsolutePath } from "../schema"
|
||||||
|
|
||||||
const skillConcurrency = 4
|
const skillConcurrency = 4
|
||||||
@@ -213,3 +215,5 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(FSUtil.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(Global.defaultLayer),
|
Layer.provide(Global.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const node = makeGlobalNode({ service: Service, layer, deps: [httpClient, FSUtil.node, Global.node] })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as SkillGuidance from "./guidance"
|
export * as SkillGuidance from "./guidance"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "../effect/node"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { AgentV2 } from "../agent"
|
import { AgentV2 } from "../agent"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
@@ -71,3 +72,5 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [SkillV2.node] })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as Snapshot from "./snapshot"
|
export * as Snapshot from "./snapshot"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "./effect/node"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { Config } from "./config"
|
import { Config } from "./config"
|
||||||
@@ -228,6 +229,12 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
export const locationLayer = layer.pipe(Layer.provideMerge(Config.locationLayer))
|
export const locationLayer = layer.pipe(Layer.provideMerge(Config.locationLayer))
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer,
|
||||||
|
deps: [Config.node, FSUtil.node, Git.node, Global.node, Location.node],
|
||||||
|
})
|
||||||
|
|
||||||
export const noopLayer = Layer.succeed(
|
export const noopLayer = Layer.succeed(
|
||||||
Service,
|
Service,
|
||||||
Service.of({
|
Service.of({
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
export * as SystemContextBuiltIns from "./builtins"
|
export * as SystemContextBuiltIns from "./builtins"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "../effect/node"
|
||||||
import { DateTime, Effect, Layer, Schema } from "effect"
|
import { DateTime, Effect, Layer, Schema } from "effect"
|
||||||
import { Location } from "../location"
|
import { Location } from "../location"
|
||||||
import { SystemContext } from "./index"
|
import { SystemContext } from "./index"
|
||||||
import { InstructionContext } from "../instruction-context"
|
import { InstructionContext } from "../instruction-context"
|
||||||
import { SystemContextRegistry } from "./registry"
|
import { SystemContextRegistry } from "./registry"
|
||||||
|
import { FSUtil } from "../fs-util"
|
||||||
|
import { Global } from "../global"
|
||||||
|
|
||||||
const builtIns = Layer.effectDiscard(
|
const builtIns = Layer.effectDiscard(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -45,3 +48,9 @@ export const layer = Layer.mergeAll(builtIns, InstructionContext.layer).pipe(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer
|
export const locationLayer = layer
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
name: "system-context-builtins",
|
||||||
|
layer,
|
||||||
|
deps: [Location.node, SystemContextRegistry.node, InstructionContext.node, FSUtil.node, Global.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export * as SystemContextRegistry from "./registry"
|
|||||||
|
|
||||||
import { Context, Effect, Layer, Ref, Scope } from "effect"
|
import { Context, Effect, Layer, Ref, Scope } from "effect"
|
||||||
import { SystemContext } from "./index"
|
import { SystemContext } from "./index"
|
||||||
|
import { makeLocationNode } from "../effect/node"
|
||||||
|
|
||||||
export interface Entry {
|
export interface Entry {
|
||||||
readonly key: SystemContext.Key
|
readonly key: SystemContext.Key
|
||||||
@@ -44,3 +45,5 @@ export const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effe
|
|||||||
import { Config } from "./config"
|
import { Config } from "./config"
|
||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
import { Global } from "./global"
|
import { Global } from "./global"
|
||||||
|
import { makeGlobalNode, makeLocationNode } from "./effect/node"
|
||||||
import { SessionSchema } from "./session/schema"
|
import { SessionSchema } from "./session/schema"
|
||||||
import { Identifier } from "./util/identifier"
|
import { Identifier } from "./util/identifier"
|
||||||
import type { ToolOutput } from "@opencode-ai/llm"
|
import type { ToolOutput } from "@opencode-ai/llm"
|
||||||
@@ -193,6 +194,8 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.defaultLayer))
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node, Config.node] })
|
||||||
|
|
||||||
/** Runs retention scanning once globally rather than once per active Location. */
|
/** Runs retention scanning once globally rather than once per active Location. */
|
||||||
export const cleanupLayer = Layer.effectDiscard(
|
export const cleanupLayer = Layer.effectDiscard(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -202,3 +205,5 @@ export const cleanupLayer = Layer.effectDiscard(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultCleanupLayer = Layer.merge(defaultLayer, cleanupLayer.pipe(Layer.provide(defaultLayer)))
|
export const defaultCleanupLayer = Layer.merge(defaultLayer, cleanupLayer.pipe(Layer.provide(defaultLayer)))
|
||||||
|
|
||||||
|
export const cleanupNode = makeGlobalNode({ name: "tool-output-cleanup", layer: defaultCleanupLayer, deps: [] })
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export * as ApplicationTools from "./application-tools"
|
|||||||
import { Context, Effect, Layer, Scope } from "effect"
|
import { Context, Effect, Layer, Scope } from "effect"
|
||||||
import { State } from "../state"
|
import { State } from "../state"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
|
import { makeGlobalNode } from "../effect/node"
|
||||||
|
|
||||||
type Data = {
|
type Data = {
|
||||||
readonly entries: Map<string, Entry>
|
readonly entries: Map<string, Entry>
|
||||||
@@ -52,3 +53,5 @@ export const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as BuiltInTools from "./builtins"
|
export * as BuiltInTools from "./builtins"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "../effect/node"
|
||||||
import { Layer } from "effect"
|
import { Layer } from "effect"
|
||||||
import { BashTool } from "./bash"
|
import { BashTool } from "./bash"
|
||||||
import { ApplyPatchTool } from "./apply-patch"
|
import { ApplyPatchTool } from "./apply-patch"
|
||||||
@@ -14,6 +15,20 @@ import { TodoWriteTool } from "./todowrite"
|
|||||||
import { WebFetchTool } from "./webfetch"
|
import { WebFetchTool } from "./webfetch"
|
||||||
import { WebSearchTool } from "./websearch"
|
import { WebSearchTool } from "./websearch"
|
||||||
import { WriteTool } from "./write"
|
import { WriteTool } from "./write"
|
||||||
|
import { FSUtil } from "../fs-util"
|
||||||
|
import { AppProcess } from "../process"
|
||||||
|
import { Config } from "../config"
|
||||||
|
import { Location } from "../location"
|
||||||
|
import { LocationMutation } from "../location-mutation"
|
||||||
|
import { FileMutation } from "../file-mutation"
|
||||||
|
import { PermissionV2 } from "../permission"
|
||||||
|
import { Ripgrep } from "../ripgrep"
|
||||||
|
import { Image } from "../image"
|
||||||
|
import { QuestionV2 } from "../question"
|
||||||
|
import { SkillV2 } from "../skill"
|
||||||
|
import { SessionTodo } from "../session/todo"
|
||||||
|
import { ToolRegistry } from "./registry"
|
||||||
|
import { httpClient } from "../effect/layer-node-platform"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composes only the shipped Location-scoped built-in tool transforms.
|
* Composes only the shipped Location-scoped built-in tool transforms.
|
||||||
@@ -42,3 +57,25 @@ export const locationLayer = Layer.mergeAll(
|
|||||||
WebSearchTool.layer.pipe(Layer.provide(WebSearchTool.defaultConfigLayer)),
|
WebSearchTool.layer.pipe(Layer.provide(WebSearchTool.defaultConfigLayer)),
|
||||||
WriteTool.layer,
|
WriteTool.layer,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
name: "built-in-tools",
|
||||||
|
layer: locationLayer,
|
||||||
|
deps: [
|
||||||
|
ToolRegistry.toolsNode,
|
||||||
|
FSUtil.node,
|
||||||
|
AppProcess.node,
|
||||||
|
Config.node,
|
||||||
|
Location.node,
|
||||||
|
LocationMutation.node,
|
||||||
|
FileMutation.node,
|
||||||
|
PermissionV2.node,
|
||||||
|
Ripgrep.node,
|
||||||
|
Image.node,
|
||||||
|
QuestionV2.node,
|
||||||
|
SkillV2.node,
|
||||||
|
SessionTodo.node,
|
||||||
|
ReadToolFileSystem.node,
|
||||||
|
httpClient,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { pathToFileURL } from "url"
|
|||||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||||
import { FileSystem } from "../filesystem"
|
import { FileSystem } from "../filesystem"
|
||||||
import { FSUtil } from "../fs-util"
|
import { FSUtil } from "../fs-util"
|
||||||
|
import { makeLocationNode } from "../effect/node"
|
||||||
import { AbsolutePath, PositiveInt, RelativePath } from "../schema"
|
import { AbsolutePath, PositiveInt, RelativePath } from "../schema"
|
||||||
|
|
||||||
export const MAX_READ_LINES = 2_000
|
export const MAX_READ_LINES = 2_000
|
||||||
@@ -361,3 +362,5 @@ export const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Wildcard } from "../util/wildcard"
|
|||||||
import { ApplicationTools } from "./application-tools"
|
import { ApplicationTools } from "./application-tools"
|
||||||
import { definition, permission, settle, validateName, type AnyTool, type RegistrationError } from "./tool"
|
import { definition, permission, settle, validateName, type AnyTool, type RegistrationError } from "./tool"
|
||||||
import { Tools } from "./tools"
|
import { Tools } from "./tools"
|
||||||
|
import { makeLocationNode } from "../effect/node"
|
||||||
|
|
||||||
export type ExecuteInput = {
|
export type ExecuteInput = {
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
@@ -137,3 +138,15 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(ApplicationTools.layer),
|
Layer.provide(ApplicationTools.layer),
|
||||||
Layer.provide(ToolOutputStore.defaultLayer),
|
Layer.provide(ToolOutputStore.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer,
|
||||||
|
deps: [ApplicationTools.node, ToolOutputStore.node],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const toolsNode = makeLocationNode({
|
||||||
|
service: Tools.Service,
|
||||||
|
layer,
|
||||||
|
deps: [ApplicationTools.node, ToolOutputStore.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { FileSystem, Scope } from "effect"
|
|||||||
import type { PlatformError } from "effect/PlatformError"
|
import type { PlatformError } from "effect/PlatformError"
|
||||||
import { FSUtil } from "../fs-util"
|
import { FSUtil } from "../fs-util"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { LayerNode } from "../effect/layer-node"
|
import { makeGlobalNode } from "../effect/node"
|
||||||
import { Hash } from "./hash"
|
import { Hash } from "./hash"
|
||||||
|
|
||||||
export namespace EffectFlock {
|
export namespace EffectFlock {
|
||||||
@@ -281,5 +281,5 @@ export namespace EffectFlock {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.layer))
|
||||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Global.node, FSUtil.node] })
|
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Global.node, FSUtil.node] })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { test } from "bun:test"
|
||||||
|
import { Context, Effect, Layer } from "effect"
|
||||||
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
|
import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node"
|
||||||
|
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/core/effect/node"
|
||||||
|
|
||||||
|
class A extends Context.Service<A, {}>()("test/LayerNodeA") {}
|
||||||
|
class B extends Context.Service<B, {}>()("test/LayerNodeB") {}
|
||||||
|
class C extends Context.Service<C, {}>()("test/LayerNodeC") {}
|
||||||
|
class LayerError {
|
||||||
|
readonly _tag = "LayerError"
|
||||||
|
}
|
||||||
|
class OtherError {
|
||||||
|
readonly _tag = "OtherError"
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = LayerNode.tags({ app: [] })
|
||||||
|
const make = tags.make("app")
|
||||||
|
const build = <A, E>(root: LayerNode.Node<A, E, any>) => LayerNodeTree.compile(root) as Layer.Layer<A, E>
|
||||||
|
const aLayer = Layer.succeed(A, A.of({}))
|
||||||
|
const bLayer = Layer.effect(B, Effect.as(A, B.of({})))
|
||||||
|
const cLayer = Layer.effect(
|
||||||
|
C,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* A
|
||||||
|
yield* B
|
||||||
|
return C.of({})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const failingA = Layer.effect(A, Effect.fail(new LayerError()))
|
||||||
|
const a = make({ service: A, layer: aLayer, deps: [] })
|
||||||
|
const b = make({ service: B, layer: bLayer, deps: [a] })
|
||||||
|
const c = make({ service: C, layer: cLayer, deps: [a, b] })
|
||||||
|
const failing = make({ service: A, layer: failingA, deps: [] })
|
||||||
|
const dependent = make({ service: B, layer: bLayer, deps: [failing] })
|
||||||
|
const inputA = LayerNode.unbound(A, tags.values.app)
|
||||||
|
const inputDependent = make({ service: B, layer: bLayer, deps: [inputA] })
|
||||||
|
|
||||||
|
make({ name: "manual-a", layer: aLayer, deps: [] })
|
||||||
|
|
||||||
|
// @ts-expect-error A node must have a service or name
|
||||||
|
make({ layer: aLayer, deps: [] })
|
||||||
|
|
||||||
|
// @ts-expect-error Service and name are mutually exclusive
|
||||||
|
make({ service: A, name: "a", layer: aLayer, deps: [] })
|
||||||
|
|
||||||
|
// @ts-expect-error B requires A
|
||||||
|
make({ service: B, layer: bLayer, deps: [] })
|
||||||
|
|
||||||
|
// @ts-expect-error C requires A and B
|
||||||
|
make({ service: C, layer: cLayer, deps: [a] })
|
||||||
|
|
||||||
|
const closed = build(LayerNode.group([c]))
|
||||||
|
const closedWithError = build(LayerNode.group([dependent]))
|
||||||
|
const checkClosed: Layer.Layer<C, never, never> = closed
|
||||||
|
const checkError: Layer.Layer<B, LayerError, never> = closedWithError
|
||||||
|
void checkClosed
|
||||||
|
void checkError
|
||||||
|
|
||||||
|
LayerNode.replace(aLayer, Layer.succeed(A, A.of({})))
|
||||||
|
|
||||||
|
// @ts-expect-error Replacement must provide A
|
||||||
|
LayerNode.replace(aLayer, Layer.succeed(B, B.of({})))
|
||||||
|
|
||||||
|
// @ts-expect-error Replacement cannot introduce a new error
|
||||||
|
LayerNode.replace(aLayer, Layer.effect(A, Effect.fail(new OtherError())))
|
||||||
|
|
||||||
|
// @ts-expect-error Replacement must be closed
|
||||||
|
LayerNode.replace(bLayer, bLayer)
|
||||||
|
|
||||||
|
class TagA extends Context.Service<TagA, {}>()("test/TagA") {}
|
||||||
|
class TagB extends Context.Service<TagB, {}>()("test/TagB") {}
|
||||||
|
class TagC extends Context.Service<TagC, {}>()("test/TagC") {}
|
||||||
|
|
||||||
|
const scopedTags = LayerNode.tags({ request: ["global"], global: [] })
|
||||||
|
const request = scopedTags.make("request")
|
||||||
|
const global = scopedTags.make("global")
|
||||||
|
const globalA = global({ service: TagA, layer: Layer.succeed(TagA, TagA.of({})), deps: [] })
|
||||||
|
const requestA = request({ service: TagA, layer: Layer.succeed(TagA, TagA.of({})), deps: [] })
|
||||||
|
const requestB = request({ service: TagB, layer: Layer.succeed(TagB, TagB.of({})), deps: [] })
|
||||||
|
const tagBLayer = Layer.effect(TagB, Effect.as(TagA, TagB.of({})))
|
||||||
|
const tagCLayer = Layer.effect(
|
||||||
|
TagC,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* TagA
|
||||||
|
yield* TagB
|
||||||
|
return TagC.of({})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
request({ service: TagB, layer: tagBLayer, deps: [globalA] })
|
||||||
|
request({ service: TagC, layer: tagCLayer, deps: [globalA, requestB] })
|
||||||
|
request({ service: TagC, layer: tagCLayer, deps: [LayerNode.group([globalA, requestB])] })
|
||||||
|
|
||||||
|
// @ts-expect-error Tag configuration can only reference declared tags
|
||||||
|
LayerNode.tags({ request: ["missing"], global: [] })
|
||||||
|
|
||||||
|
// @ts-expect-error An unrelated dependency cannot satisfy TagA
|
||||||
|
request({ service: TagB, layer: tagBLayer, deps: [requestB] })
|
||||||
|
|
||||||
|
// @ts-expect-error Providing only TagA leaves TagB missing
|
||||||
|
request({ service: TagC, layer: tagCLayer, deps: [globalA] })
|
||||||
|
|
||||||
|
// @ts-expect-error Providing only TagB leaves TagA missing
|
||||||
|
request({ service: TagC, layer: tagCLayer, deps: [requestB] })
|
||||||
|
|
||||||
|
// @ts-expect-error Duplicate TagA providers still leave TagB missing
|
||||||
|
request({ service: TagC, layer: tagCLayer, deps: [globalA, requestA] })
|
||||||
|
|
||||||
|
// @ts-expect-error A group with only TagA still leaves TagB missing
|
||||||
|
request({ service: TagC, layer: tagCLayer, deps: [LayerNode.group([globalA])] })
|
||||||
|
|
||||||
|
// @ts-expect-error Global cannot depend on request
|
||||||
|
global({ service: TagB, layer: tagBLayer, deps: [requestA] })
|
||||||
|
|
||||||
|
// @ts-expect-error Groups preserve their child tags
|
||||||
|
global({ service: TagB, layer: tagBLayer, deps: [LayerNode.group([requestA])] })
|
||||||
|
|
||||||
|
class ScopedA extends Context.Service<ScopedA, {}>()("test/ScopedA") {}
|
||||||
|
class ScopedB extends Context.Service<ScopedB, {}>()("test/ScopedB") {}
|
||||||
|
|
||||||
|
const scopedA = Layer.succeed(ScopedA, ScopedA.of({}))
|
||||||
|
const scopedB = Layer.effect(ScopedB, Effect.as(ScopedA, ScopedB.of({})))
|
||||||
|
const globalScopedA = makeGlobalNode({ service: ScopedA, layer: scopedA, deps: [] })
|
||||||
|
const locationScopedA = makeLocationNode({ service: ScopedA, layer: scopedA, deps: [] })
|
||||||
|
|
||||||
|
makeGlobalNode({ service: ScopedB, layer: scopedB, deps: [globalScopedA] })
|
||||||
|
makeLocationNode({ service: ScopedB, layer: scopedB, deps: [globalScopedA] })
|
||||||
|
makeLocationNode({ service: ScopedB, layer: scopedB, deps: [locationScopedA] })
|
||||||
|
|
||||||
|
// @ts-expect-error Global nodes cannot depend on location nodes
|
||||||
|
makeGlobalNode({ service: ScopedB, layer: scopedB, deps: [locationScopedA] })
|
||||||
|
|
||||||
|
// @ts-expect-error ScopedB requires ScopedA
|
||||||
|
makeLocationNode({ service: ScopedB, layer: scopedB, deps: [] })
|
||||||
|
|
||||||
|
test("type exploration compiles", () => {})
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { Context, Effect, Layer } from "effect"
|
||||||
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
|
import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node"
|
||||||
|
|
||||||
|
class Value extends Context.Service<Value, { readonly value: string }>()("test/LayerNodeValue") {}
|
||||||
|
class Greeting extends Context.Service<Greeting, { readonly value: string }>()("test/LayerNodeGreeting") {}
|
||||||
|
class Left extends Context.Service<Left, { readonly value: string }>()("test/LayerNodeLeft") {}
|
||||||
|
class Right extends Context.Service<Right, { readonly value: string }>()("test/LayerNodeRight") {}
|
||||||
|
class Database extends Context.Service<Database, { readonly name: string }>()("test/GraphDatabase") {}
|
||||||
|
class Users extends Context.Service<Users, { readonly list: Effect.Effect<string[]> }>()("test/GraphUsers") {}
|
||||||
|
class App extends Context.Service<App, { readonly run: Effect.Effect<string[]> }>()("test/GraphApp") {}
|
||||||
|
|
||||||
|
const tags = LayerNode.tags({ app: [] })
|
||||||
|
const make = tags.make("app")
|
||||||
|
const build = <A, E>(root: LayerNode.Node<A, E, any>, replacements?: readonly LayerNode.Replacement[]) =>
|
||||||
|
LayerNodeTree.compile(root, new Map(replacements?.map((item) => [item.source, item.replacement]))) as Layer.Layer<
|
||||||
|
A,
|
||||||
|
E
|
||||||
|
>
|
||||||
|
const valueLayer = Layer.succeed(Value, Value.of({ value: "production" }))
|
||||||
|
const greetingLayer = Layer.effect(
|
||||||
|
Greeting,
|
||||||
|
Effect.map(Value, (value) => Greeting.of({ value: `hello ${value.value}` })),
|
||||||
|
)
|
||||||
|
const value = make({ service: Value, layer: valueLayer, deps: [] })
|
||||||
|
const greeting = make({ service: Greeting, layer: greetingLayer, deps: [value] })
|
||||||
|
|
||||||
|
describe("layer node", () => {
|
||||||
|
test("builds an untagged graph", async () => {
|
||||||
|
const value = LayerNode.make({ service: Value, layer: valueLayer, deps: [] })
|
||||||
|
const greeting = LayerNode.make({ service: Greeting, layer: greetingLayer, deps: [value] })
|
||||||
|
const program = Effect.map(Greeting, (item) => item.value).pipe(
|
||||||
|
Effect.provide(LayerNodeTree.compile(LayerNode.group([greeting]))),
|
||||||
|
)
|
||||||
|
expect(await Effect.runPromise(program)).toBe("hello production")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("builds a dependency graph", async () => {
|
||||||
|
const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(build(LayerNode.group([greeting]))))
|
||||||
|
expect(await Effect.runPromise(program)).toBe("hello production")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("exposes roots but hides transitive dependencies", () => {
|
||||||
|
const layer = build(LayerNode.group([greeting]))
|
||||||
|
const check: Layer.Layer<Greeting> = layer
|
||||||
|
void check
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves branch-specific implementations across roots", async () => {
|
||||||
|
const firstValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "first" })), deps: [] })
|
||||||
|
const secondValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] })
|
||||||
|
const leftLayer = Layer.effect(
|
||||||
|
Left,
|
||||||
|
Effect.map(Value, (item) => Left.of({ value: item.value })),
|
||||||
|
)
|
||||||
|
const rightLayer = Layer.effect(
|
||||||
|
Right,
|
||||||
|
Effect.map(Value, (item) => Right.of({ value: item.value })),
|
||||||
|
)
|
||||||
|
const left = make({ service: Left, layer: leftLayer, deps: [firstValue] })
|
||||||
|
const right = make({ service: Right, layer: rightLayer, deps: [secondValue] })
|
||||||
|
const layer = build(LayerNode.group([left, right]))
|
||||||
|
const program = Effect.gen(function* () {
|
||||||
|
return [(yield* Left).value, (yield* Right).value]
|
||||||
|
}).pipe(Effect.provide(layer))
|
||||||
|
expect(await Effect.runPromise(program)).toEqual(["first", "second"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("requires unbound nodes to be bound before compilation", async () => {
|
||||||
|
const unbound = LayerNode.unbound(Value, tags.values.app)
|
||||||
|
const greeting = make({ service: Greeting, layer: greetingLayer, deps: [unbound] })
|
||||||
|
const tree = LayerNode.group([greeting])
|
||||||
|
expect(() => LayerNodeTree.compile(tree)).toThrow("Unbound layer node: test/LayerNodeValue")
|
||||||
|
const bound = LayerNodeTree.bind(tree, unbound, value)
|
||||||
|
const layer = LayerNodeTree.compile(bound) as Layer.Layer<Greeting>
|
||||||
|
const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(layer))
|
||||||
|
expect(await Effect.runPromise(program)).toBe("hello production")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("replaces a layer by identity", async () => {
|
||||||
|
const replacement = Layer.succeed(Value, Value.of({ value: "simulation" }))
|
||||||
|
const program = Effect.map(Greeting, (item) => item.value).pipe(
|
||||||
|
Effect.provide(build(LayerNode.group([greeting]), [LayerNode.replace(valueLayer, replacement)])),
|
||||||
|
)
|
||||||
|
expect(await Effect.runPromise(program)).toBe("hello simulation")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("replaces every use of the same layer", async () => {
|
||||||
|
const leftLayer = Layer.effect(
|
||||||
|
Left,
|
||||||
|
Effect.map(Value, (item) => Left.of({ value: item.value })),
|
||||||
|
)
|
||||||
|
const rightLayer = Layer.effect(
|
||||||
|
Right,
|
||||||
|
Effect.map(Value, (item) => Right.of({ value: item.value })),
|
||||||
|
)
|
||||||
|
const left = make({ service: Left, layer: leftLayer, deps: [value] })
|
||||||
|
const right = make({ service: Right, layer: rightLayer, deps: [value] })
|
||||||
|
const replacement = Layer.succeed(Value, Value.of({ value: "replaced" }))
|
||||||
|
const layer = build(LayerNode.group([left, right]), [LayerNode.replace(valueLayer, replacement)])
|
||||||
|
const program = Effect.gen(function* () {
|
||||||
|
return [(yield* Left).value, (yield* Right).value]
|
||||||
|
}).pipe(Effect.provide(layer))
|
||||||
|
expect(await Effect.runPromise(program)).toEqual(["replaced", "replaced"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not acquire an unused replacement", async () => {
|
||||||
|
let acquisitions = 0
|
||||||
|
const other = Layer.succeed(Value, Value.of({ value: "other" }))
|
||||||
|
const replacement = Layer.effect(
|
||||||
|
Value,
|
||||||
|
Effect.sync(() => {
|
||||||
|
acquisitions++
|
||||||
|
return Value.of({ value: "replacement" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.map(Greeting, (item) => item.value).pipe(
|
||||||
|
Effect.provide(build(LayerNode.group([greeting]), [LayerNode.replace(other, replacement)])),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(acquisitions).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("hoists and compiles tagged graphs", async () => {
|
||||||
|
const tags = LayerNode.tags({ location: ["global"], global: [] })
|
||||||
|
const global = tags.make("global")
|
||||||
|
const location = tags.make("location")
|
||||||
|
const database = global({
|
||||||
|
service: Database,
|
||||||
|
layer: Layer.succeed(Database, Database.of({ name: "Alice" })),
|
||||||
|
deps: [],
|
||||||
|
})
|
||||||
|
const users = location({
|
||||||
|
service: Users,
|
||||||
|
layer: Layer.effect(
|
||||||
|
Users,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const db = yield* Database
|
||||||
|
return Users.of({ list: Effect.succeed([db.name]) })
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
deps: [database],
|
||||||
|
})
|
||||||
|
const app = location({
|
||||||
|
service: App,
|
||||||
|
layer: Layer.effect(
|
||||||
|
App,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const service = yield* Users
|
||||||
|
return App.of({ run: service.list })
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
deps: [users],
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = LayerNodeTree.hoist(LayerNode.group([app]), tags.values.global)
|
||||||
|
expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({
|
||||||
|
kind: "group",
|
||||||
|
dependencies: [],
|
||||||
|
})
|
||||||
|
expect(result.hoisted.dependencies).toEqual([database])
|
||||||
|
|
||||||
|
const layer = LayerNodeTree.compile(result.node).pipe(
|
||||||
|
Layer.provide(LayerNodeTree.compile(result.hoisted)),
|
||||||
|
) as unknown as Layer.Layer<App>
|
||||||
|
const program = Effect.gen(function* () {
|
||||||
|
return yield* (yield* App).run
|
||||||
|
}).pipe(Effect.provide(layer))
|
||||||
|
|
||||||
|
expect(await Effect.runPromise(program)).toEqual(["Alice"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects conflicting hoisted implementations", () => {
|
||||||
|
const tags = LayerNode.tags({ location: ["global"], global: [] })
|
||||||
|
const global = tags.make("global")
|
||||||
|
const location = tags.make("location")
|
||||||
|
const first = global({
|
||||||
|
service: Database,
|
||||||
|
layer: Layer.succeed(Database, Database.of({ name: "first" })),
|
||||||
|
deps: [],
|
||||||
|
})
|
||||||
|
const second = global({
|
||||||
|
service: Database,
|
||||||
|
layer: Layer.succeed(Database, Database.of({ name: "second" })),
|
||||||
|
deps: [],
|
||||||
|
})
|
||||||
|
const left = location({
|
||||||
|
service: Users,
|
||||||
|
layer: Layer.effect(Users, Effect.as(Database, Users.of({ list: Effect.succeed([]) }))),
|
||||||
|
deps: [first],
|
||||||
|
})
|
||||||
|
const right = location({
|
||||||
|
service: App,
|
||||||
|
layer: Layer.effect(App, Effect.as(Database, App.of({ run: Effect.succeed([]) }))),
|
||||||
|
deps: [second],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(() => LayerNodeTree.hoist(LayerNode.group([left, right]), tags.values.global)).toThrow(
|
||||||
|
"Tag global has conflicting implementations for test/GraphDatabase",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("treats dependency groups as transparent while hoisting", () => {
|
||||||
|
const tags = LayerNode.tags({ location: ["global"], global: [] })
|
||||||
|
const global = tags.make("global")
|
||||||
|
const location = tags.make("location")
|
||||||
|
const database = global({
|
||||||
|
service: Database,
|
||||||
|
layer: Layer.succeed(Database, Database.of({ name: "Alice" })),
|
||||||
|
deps: [],
|
||||||
|
})
|
||||||
|
const users = location({
|
||||||
|
service: Users,
|
||||||
|
layer: Layer.effect(Users, Effect.as(Database, Users.of({ list: Effect.succeed([]) }))),
|
||||||
|
deps: [LayerNode.group([database])],
|
||||||
|
})
|
||||||
|
const result = LayerNodeTree.hoist(LayerNode.group([users]), tags.values.global)
|
||||||
|
|
||||||
|
expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({
|
||||||
|
kind: "group",
|
||||||
|
dependencies: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { Context, Effect, Layer } from "effect"
|
||||||
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
|
import { Node } from "@opencode-ai/core/effect/node"
|
||||||
|
import { NodeBuild } from "@opencode-ai/core/effect/node-build"
|
||||||
|
import { Location } from "@opencode-ai/core/location"
|
||||||
|
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||||
|
import { Project } from "@opencode-ai/core/project"
|
||||||
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
import { tmpdir } from "../../fixture/tmpdir"
|
||||||
|
|
||||||
|
class Value extends Context.Service<Value, { readonly value: string }>()("test/TagValue") {}
|
||||||
|
class Result extends Context.Service<Result, { readonly value: string }>()("test/TagResult") {}
|
||||||
|
class Left extends Context.Service<Left, { readonly value: string }>()("test/TagLeft") {}
|
||||||
|
class Right extends Context.Service<Right, { readonly value: string }>()("test/TagRight") {}
|
||||||
|
class Last extends Context.Service<Last, { readonly value: string }>()("test/TagLast") {}
|
||||||
|
|
||||||
|
describe("node build", () => {
|
||||||
|
test("shares top-level project with location services", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
let acquisitions = 0
|
||||||
|
const projectLayer = Layer.effect(
|
||||||
|
Project.Service,
|
||||||
|
Effect.sync(() => {
|
||||||
|
acquisitions++
|
||||||
|
return Project.Service.of({
|
||||||
|
directories: () => Effect.succeed([]),
|
||||||
|
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
|
||||||
|
commit: () => Effect.void,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const layer = NodeBuild.build(LayerNode.group([Project.node, LocationServiceMap.node]), [
|
||||||
|
LayerNode.replace(Project.layer, projectLayer),
|
||||||
|
])
|
||||||
|
const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
|
||||||
|
const program = Effect.gen(function* () {
|
||||||
|
yield* Project.Service
|
||||||
|
const locations = yield* LocationServiceMap.Service
|
||||||
|
return yield* Location.Service.pipe(Effect.provide(locations.get(ref)))
|
||||||
|
}).pipe(Effect.provide(layer))
|
||||||
|
|
||||||
|
expect((await Effect.runPromise(program)).directory).toBe(ref.directory)
|
||||||
|
expect(acquisitions).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns a composed application layer", async () => {
|
||||||
|
const value = Node.makeGlobalNode({
|
||||||
|
service: Value,
|
||||||
|
layer: Layer.succeed(Value, Value.of({ value: "value" })),
|
||||||
|
deps: [],
|
||||||
|
})
|
||||||
|
const result = Node.makeGlobalNode({
|
||||||
|
service: Result,
|
||||||
|
layer: Layer.effect(
|
||||||
|
Result,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
return Result.of({ value: (yield* Value).value })
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
deps: [value],
|
||||||
|
})
|
||||||
|
const serviceLayer = NodeBuild.build(LayerNode.group([result]))
|
||||||
|
const program = Effect.gen(function* () {
|
||||||
|
return (yield* Result).value
|
||||||
|
}).pipe(Effect.provide(serviceLayer))
|
||||||
|
|
||||||
|
expect(await Effect.runPromise(program)).toBe("value")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rebinds same-tag providers without reacquiring them", async () => {
|
||||||
|
let firstAcquisitions = 0
|
||||||
|
const tags = LayerNode.tags({ global: [] })
|
||||||
|
const global = tags.make("global")
|
||||||
|
const first = global({
|
||||||
|
service: Value,
|
||||||
|
layer: Layer.effect(
|
||||||
|
Value,
|
||||||
|
Effect.sync(() => {
|
||||||
|
firstAcquisitions++
|
||||||
|
return Value.of({ value: "first" })
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
deps: [],
|
||||||
|
})
|
||||||
|
const second = global({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] })
|
||||||
|
const left = global({
|
||||||
|
service: Left,
|
||||||
|
layer: Layer.effect(
|
||||||
|
Left,
|
||||||
|
Effect.map(Value, (value) => Left.of({ value: value.value })),
|
||||||
|
),
|
||||||
|
deps: [first],
|
||||||
|
})
|
||||||
|
const right = global({
|
||||||
|
service: Right,
|
||||||
|
layer: Layer.effect(
|
||||||
|
Right,
|
||||||
|
Effect.map(Value, (value) => Right.of({ value: value.value })),
|
||||||
|
),
|
||||||
|
deps: [second],
|
||||||
|
})
|
||||||
|
const last = global({
|
||||||
|
service: Last,
|
||||||
|
layer: Layer.effect(
|
||||||
|
Last,
|
||||||
|
Effect.map(Value, (value) => Last.of({ value: value.value })),
|
||||||
|
),
|
||||||
|
deps: [first],
|
||||||
|
})
|
||||||
|
const layer = NodeBuild.build(LayerNode.group([left, right, last])) as Layer.Layer<Left | Right | Last>
|
||||||
|
const values = Effect.gen(function* () {
|
||||||
|
return [(yield* Left).value, (yield* Right).value, (yield* Last).value]
|
||||||
|
}).pipe(Effect.provide(layer))
|
||||||
|
|
||||||
|
expect(await Effect.runPromise(values)).toEqual(["first", "second", "first"])
|
||||||
|
expect(firstAcquisitions).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
|
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, Stream } from "effect"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import { Session } from "@opencode-ai/schema/session"
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
@@ -285,6 +285,69 @@ describe("EventV2", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("notifies global listeners only after a durable event is committed", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
const aggregateID = EventV2.ID.create()
|
||||||
|
const observed = new Array<{ id: string; seq: number }>()
|
||||||
|
yield* events.listen((event) =>
|
||||||
|
event.type !== SyncMessage.type
|
||||||
|
? Effect.void
|
||||||
|
: db
|
||||||
|
.select({ id: EventTable.id, seq: EventTable.seq })
|
||||||
|
.from(EventTable)
|
||||||
|
.where(eq(EventTable.id, event.id))
|
||||||
|
.get()
|
||||||
|
.pipe(
|
||||||
|
Effect.orDie,
|
||||||
|
Effect.tap((row) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (row) observed.push(row)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Effect.asVoid,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const event = yield* events.publish(SyncMessage, { id: aggregateID, text: "committed" })
|
||||||
|
if (!event.durable) throw new Error("Expected durable event metadata")
|
||||||
|
|
||||||
|
expect(observed).toEqual([{ id: event.id, seq: event.durable.seq }])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("ends only an overflowing bounded subscriber without blocking other listeners", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const consuming = yield* Deferred.make<void>()
|
||||||
|
const release = yield* Deferred.make<void>()
|
||||||
|
const slowStream = yield* EventV2.allBounded(events, 1)
|
||||||
|
const fastStream = yield* EventV2.allBounded(events, 8)
|
||||||
|
const slow = yield* slowStream.pipe(
|
||||||
|
Stream.runForEach(() => Deferred.succeed(consuming, undefined).pipe(Effect.andThen(Deferred.await(release)))),
|
||||||
|
Effect.forkScoped,
|
||||||
|
)
|
||||||
|
const fast = yield* fastStream.pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped)
|
||||||
|
|
||||||
|
yield* events.publish(Message, { text: "one" })
|
||||||
|
yield* Deferred.await(consuming)
|
||||||
|
yield* events.publish(Message, { text: "two" })
|
||||||
|
yield* events.publish(Message, { text: "overflow" })
|
||||||
|
const last = yield* events.publish(Message, { text: "still delivered" })
|
||||||
|
yield* Deferred.succeed(release, undefined)
|
||||||
|
|
||||||
|
const slowExit = yield* Fiber.await(slow)
|
||||||
|
expect(Exit.findErrorOption(slowExit).pipe(Option.getOrUndefined)).toBeInstanceOf(EventV2.SubscriberOverflowError)
|
||||||
|
expect(Array.from(yield* Fiber.join(fast))).toEqual([
|
||||||
|
expect.objectContaining({ data: { text: "one" } }),
|
||||||
|
expect.objectContaining({ data: { text: "two" } }),
|
||||||
|
expect.objectContaining({ data: { text: "overflow" } }),
|
||||||
|
last,
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("preserves observer interruption", () =>
|
it.effect("preserves observer interruption", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Tool } from "@opencode-ai/core/tool/tool"
|
|||||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
@@ -34,7 +34,7 @@ const applicationTools = ApplicationTools.layer
|
|||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
Layer.merge(
|
Layer.merge(
|
||||||
Layer.mergeAll(applicationTools, Database.defaultLayer, EventV2.defaultLayer),
|
Layer.mergeAll(applicationTools, Database.defaultLayer, EventV2.defaultLayer),
|
||||||
LocationServiceMap.layer.pipe(
|
locationServiceMapLayer.pipe(
|
||||||
Layer.provide(applicationTools),
|
Layer.provide(applicationTools),
|
||||||
Layer.provide(
|
Layer.provide(
|
||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
@@ -60,7 +60,7 @@ describe("LocationServiceMap", () => {
|
|||||||
Effect.flatMap((dir) =>
|
Effect.flatMap((dir) =>
|
||||||
Effect.scoped(
|
Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const locations = yield* LocationServiceMap
|
const locations = yield* LocationServiceMap.Service
|
||||||
const directory = AbsolutePath.make(dir.path)
|
const directory = AbsolutePath.make(dir.path)
|
||||||
const constructed = Location.Ref.make({ directory })
|
const constructed = Location.Ref.make({ directory })
|
||||||
const decoded = Schema.decodeUnknownSync(Location.Ref)({ directory })
|
const decoded = Schema.decodeUnknownSync(Location.Ref)({ directory })
|
||||||
@@ -111,7 +111,9 @@ describe("LocationServiceMap", () => {
|
|||||||
}
|
}
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.scoped,
|
Effect.scoped,
|
||||||
Effect.provide(LocationServiceMap.get(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
|
Effect.provide(
|
||||||
|
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) })),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const blockedState = yield* update(blocked.path)
|
const blockedState = yield* update(blocked.path)
|
||||||
@@ -191,7 +193,7 @@ describe("LocationServiceMap", () => {
|
|||||||
location,
|
location,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).pipe(Effect.provide(LocationServiceMap.get(location)), Effect.flip)
|
).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
|
||||||
|
|
||||||
expect(failure).toMatchObject({
|
expect(failure).toMatchObject({
|
||||||
_tag: "SessionRunnerModel.ModelUnavailableError",
|
_tag: "SessionRunnerModel.ModelUnavailableError",
|
||||||
@@ -231,7 +233,7 @@ describe("LocationServiceMap", () => {
|
|||||||
})
|
})
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.scoped,
|
Effect.scoped,
|
||||||
Effect.provide(LocationServiceMap.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))),
|
Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user