mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-22 18:56:10 +00:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
975b1132f1 | ||
|
|
cd97de7391 | ||
|
|
23fd5907be | ||
|
|
49d3f86802 | ||
|
|
909a1a6d78 | ||
|
|
dc468bdcfd | ||
|
|
f48f24ec4e | ||
|
|
34b3d59a23 | ||
|
|
a0a500316e | ||
|
|
1787fa4261 | ||
|
|
130957288e | ||
|
|
c5a4a8288c | ||
|
|
39d7394ede | ||
|
|
494123a875 | ||
|
|
fe840d42b8 | ||
|
|
f50e4accf3 | ||
|
|
9bceb8eb7d | ||
|
|
adebb87191 | ||
|
|
4d5efba5c9 | ||
|
|
c6ee511485 | ||
|
|
f9f2280452 | ||
|
|
d5980b47e9 | ||
|
|
41d1279b6f | ||
|
|
0d32d1f293 | ||
|
|
cf31029350 | ||
|
|
79b55d4db8 | ||
|
|
36264ccf90 | ||
|
|
7b750a8f20 | ||
|
|
e50261e524 | ||
|
|
639c94a375 | ||
|
|
b13a2d712a | ||
|
|
1b91b5df34 | ||
|
|
595cc91ddc | ||
|
|
57ce1b9ca8 | ||
|
|
4ecc3ac653 | ||
|
|
c7efbe6fc0 | ||
|
|
cd292a4ecb | ||
|
|
2bb4311042 | ||
|
|
cdc6d01c5a | ||
|
|
35b3fc85d0 | ||
|
|
9dadc2455f | ||
|
|
06dae383f7 | ||
|
|
7d204b5b57 | ||
|
|
49593c1ec4 | ||
|
|
ff837fe949 | ||
|
|
4c6750d464 | ||
|
|
69f1ec22e3 | ||
|
|
823d327401 |
@@ -65,7 +65,7 @@ jobs:
|
||||
|
||||
- name: Run unit tests
|
||||
timeout-minutes: 20
|
||||
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task
|
||||
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=none
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ const table = sqliteTable("session", {
|
||||
|
||||
## Testing
|
||||
|
||||
- Avoid mocks as much as possible
|
||||
- Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option.
|
||||
- Test actual implementation, do not duplicate logic into tests
|
||||
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
||||
|
||||
@@ -152,7 +152,7 @@ const table = sqliteTable("session", {
|
||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash activity recovery requires a separate explicit design before it may retry provider work.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default and coalesce into the active activity at the next safe provider-turn boundary. Explicit `queue` inputs open FIFO future activities one at a time after the active activity settles.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once.
|
||||
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
|
||||
- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.
|
||||
|
||||
+23
-9
@@ -24,7 +24,7 @@ A durable chronological instruction that tells the model the newly effective sta
|
||||
_Avoid_: System update, system notification, raw text diff
|
||||
|
||||
**Context Epoch**:
|
||||
The span during which one effective agent's initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition.
|
||||
The span during which one initially rendered **System Context** remains the immutable provider-cache baseline, ending at completed compaction, Session movement, or an incompatible context transition that requires a fresh baseline.
|
||||
|
||||
**Baseline System Context**:
|
||||
The full **System Context** rendered at the start of a **Context Epoch**.
|
||||
@@ -39,6 +39,18 @@ An expected temporary inability to observe a **Context Source** value; the runti
|
||||
**Safe Provider-Turn Boundary**:
|
||||
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
|
||||
|
||||
**Admitted Prompt**:
|
||||
A durable user input accepted into the Session inbox but not yet included in **Session History**.
|
||||
|
||||
**Prompt Promotion**:
|
||||
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
|
||||
|
||||
**Provider Turn**:
|
||||
One request to a model provider and the response projected from that request.
|
||||
|
||||
**Session Drain**:
|
||||
One process-local execution span that promotes eligible input and runs required **Provider Turns** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
|
||||
|
||||
**Model Tool Output**:
|
||||
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
|
||||
|
||||
@@ -67,6 +79,11 @@ The host-supplied environment overlay applied by the server when creating a PTY,
|
||||
- Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**.
|
||||
- Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes.
|
||||
- At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**.
|
||||
- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**.
|
||||
- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message.
|
||||
- Steering prompts promote at the next **Safe Provider-Turn Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's provider-turn allowance; multiple prompts promoted at one boundary reset it once.
|
||||
- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another.
|
||||
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, provider attempts, and tool state rather than inventing an enclosing execution identity.
|
||||
- The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline.
|
||||
- Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion.
|
||||
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
|
||||
@@ -75,31 +92,28 @@ The host-supplied environment overlay applied by the server when creating a PTY,
|
||||
- Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed.
|
||||
- `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**.
|
||||
- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked.
|
||||
- `SystemContext.replace(...)` represents an explicit baseline-replacing transition such as compaction or model/provider switch; it either produces a fresh generation or reports that replacement is blocked by unavailable admitted context.
|
||||
- Context Epoch preparation retries until stable after optimistic revision mismatches so concurrent replacement requests cannot terminate an otherwise valid safe-boundary run.
|
||||
- `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable.
|
||||
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
|
||||
- Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
|
||||
- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**.
|
||||
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
|
||||
- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote.
|
||||
- Context Epoch initialization is fenced against the authoritative Session Location, so an old-Location runner cannot recreate source context after a concurrent move.
|
||||
- Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values.
|
||||
- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**.
|
||||
- Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam.
|
||||
- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
|
||||
- Switching the selected agent requests **Context Epoch** replacement. A switch admitted after the current **Safe Provider-Turn Boundary** applies to the next provider turn while leaving the already-prepared baseline durable. Epoch creation is fenced against the authoritative effective agent, and retries re-observe the current agent.
|
||||
- A cross-agent replacement must complete before another provider turn; unavailable admitted context blocks that replacement instead of exposing the previous agent's privileged baseline.
|
||||
- The selected agent and model are sampled when a provider turn starts. Changes admitted after that boundary apply to the next provider turn and do not restart the current turn.
|
||||
- Selected-agent available-skill guidance remains a **Context Source**. An agent switch that changes that guidance produces a **Mid-Conversation System Message** while preserving the current baseline.
|
||||
- Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy.
|
||||
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
|
||||
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry.
|
||||
- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
|
||||
- The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
|
||||
- A **Context Epoch** begins with one immutable **Baseline System Context**.
|
||||
- A **Context Epoch** durably records the effective agent that owns its **Baseline System Context**.
|
||||
- A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
|
||||
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
|
||||
- Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache.
|
||||
- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
|
||||
- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
|
||||
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
|
||||
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
|
||||
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
|
||||
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
|
||||
|
||||
@@ -27,9 +27,11 @@ export default Runtime.handler(
|
||||
|
||||
function listen(hostname: string, port: Option.Option<number>, password: string) {
|
||||
if (Option.isSome(port)) return bind(hostname, port.value, password)
|
||||
// Preserve the familiar default when available, but let the OS choose a free
|
||||
// port when another local server already owns 4096.
|
||||
return bind(hostname, 4096, password).pipe(Effect.catch(() => bind(hostname, 0, password)))
|
||||
const next = (port: number): ReturnType<typeof bind> =>
|
||||
bind(hostname, port, password).pipe(
|
||||
Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))),
|
||||
)
|
||||
return next(4096)
|
||||
}
|
||||
|
||||
function bind(hostname: string, port: number, password: string) {
|
||||
|
||||
@@ -196,6 +196,13 @@ export async function handler(
|
||||
Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
|
||||
headers.set(k, headers.get(v)!)
|
||||
})
|
||||
Object.entries(providerInfo.headerModifier ?? {}).forEach(([k, v]) => {
|
||||
if (v === "$ip") return headers.set(k, ip)
|
||||
if (v === "$session") return headers.set(k, sessionId)
|
||||
if (v === "$model") return headers.set(k, model)
|
||||
if (v === "$request") return headers.set(k, requestId)
|
||||
headers.set(k, v)
|
||||
})
|
||||
headers.delete("host")
|
||||
headers.delete("content-length")
|
||||
headers.delete("x-opencode-request")
|
||||
|
||||
@@ -53,6 +53,7 @@ export namespace ZenData {
|
||||
apiKey: z.union([z.string(), z.record(z.string(), z.string())]),
|
||||
format: FormatSchema.optional(),
|
||||
headerMappings: z.record(z.string(), z.string()).optional(),
|
||||
headerModifier: z.record(z.string(), z.any()).optional(),
|
||||
payloadModifier: z.record(z.string(), z.any()).optional(),
|
||||
payloadMappings: z.record(z.string(), z.string()).optional(),
|
||||
adjustCacheUsage: z.boolean().optional(),
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
preload = ["./test/preload.ts"]
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "169a0f0f-d58f-479f-b024-fa1c7b9a09db",
|
||||
"prevIds": ["abd2f920-b822-49af-b8a7-2e48367d424f"],
|
||||
"id": "f14a9b18-8207-487e-a3d3-227e629ba9ad",
|
||||
"prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "workspace",
|
||||
@@ -900,16 +900,6 @@
|
||||
"entityType": "columns",
|
||||
"table": "session_context_epoch"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'build'",
|
||||
"generated": null,
|
||||
"name": "agent",
|
||||
"entityType": "columns",
|
||||
"table": "session_context_epoch"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
@@ -930,26 +920,6 @@
|
||||
"entityType": "columns",
|
||||
"table": "session_context_epoch"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "replacement_seq",
|
||||
"entityType": "columns",
|
||||
"table": "session_context_epoch"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "0",
|
||||
"generated": null,
|
||||
"name": "revision",
|
||||
"entityType": "columns",
|
||||
"table": "session_context_epoch"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
|
||||
@@ -45,15 +45,17 @@ async function generate() {
|
||||
if (await Bun.file(target).exists()) throw new Error(`Database migration already exists: ${name}`)
|
||||
await Bun.write(
|
||||
target,
|
||||
renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()),
|
||||
await formatTypescript(
|
||||
renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()),
|
||||
),
|
||||
)
|
||||
await fs.copyFile(path.join(incremental, name, "snapshot.json"), snapshot)
|
||||
}
|
||||
|
||||
await fs.mkdir(full)
|
||||
await drizzle(temporary, full, "schema")
|
||||
await Bun.write(schema, renderSchema(await generatedSql(full)))
|
||||
await Bun.write(registry, renderRegistry(await typescriptMigrations()))
|
||||
await Bun.write(schema, await formatTypescript(renderSchema(await generatedSql(full))))
|
||||
await Bun.write(registry, await formatTypescript(renderRegistry(await typescriptMigrations())))
|
||||
} finally {
|
||||
await fs.rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
@@ -76,12 +78,12 @@ async function check() {
|
||||
|
||||
await fs.mkdir(full)
|
||||
await drizzle(temporary, full, "schema")
|
||||
if ((await Bun.file(schema).text()) !== renderSchema(await generatedSql(full))) {
|
||||
if ((await Bun.file(schema).text()) !== (await formatTypescript(renderSchema(await generatedSql(full))))) {
|
||||
throw new Error("Current database schema is stale. Run `bun script/migration.ts` from packages/core.")
|
||||
}
|
||||
|
||||
const migrations = await typescriptMigrations()
|
||||
if ((await Bun.file(registry).text()) !== renderRegistry(migrations)) {
|
||||
if ((await Bun.file(registry).text()) !== (await formatTypescript(renderRegistry(migrations)))) {
|
||||
throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.")
|
||||
}
|
||||
} finally {
|
||||
@@ -170,6 +172,18 @@ function escapeTemplate(line: string) {
|
||||
return line.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${")
|
||||
}
|
||||
|
||||
async function formatTypescript(input: string) {
|
||||
const prettier = await import("prettier")
|
||||
const typescript = await import("prettier/plugins/typescript")
|
||||
const estree = await import("prettier/plugins/estree")
|
||||
return prettier.format(input, {
|
||||
parser: "typescript",
|
||||
plugins: [typescript.default, estree.default],
|
||||
semi: false,
|
||||
printWidth: 120,
|
||||
})
|
||||
}
|
||||
|
||||
function renderRegistry(names: string[]) {
|
||||
return `import type { DatabaseMigration } from "./migration"
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ export const layer = Layer.effect(
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
rebuild: state.rebuild,
|
||||
reload: state.reload,
|
||||
get: Effect.fn("AgentV2.get")(function* (id) {
|
||||
return state.get().agents.get(id)
|
||||
}),
|
||||
|
||||
+74
-21
@@ -1,14 +1,27 @@
|
||||
export * as AISDK from "./aisdk"
|
||||
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Cause, Context, Effect, Layer, Schema } from "effect"
|
||||
import { Cause, Context, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
import { EventV2 } from "./event"
|
||||
import { PluginV2 } from "./plugin"
|
||||
import { ProviderV2 } from "./provider"
|
||||
import { State } from "./state"
|
||||
|
||||
type SDK = any
|
||||
|
||||
export interface SDKEvent {
|
||||
readonly model: ModelV2.Info
|
||||
readonly package: string
|
||||
readonly options: Record<string, any>
|
||||
sdk?: SDK
|
||||
}
|
||||
|
||||
export interface LanguageEvent {
|
||||
readonly model: ModelV2.Info
|
||||
readonly sdk: SDK
|
||||
readonly options: Record<string, any>
|
||||
language?: LanguageModelV3
|
||||
}
|
||||
|
||||
function wrapSSE(res: Response, ms: number, ctl: AbortController) {
|
||||
if (typeof ms !== "number" || ms <= 0) return res
|
||||
if (!res.body) return res
|
||||
@@ -117,19 +130,70 @@ function initError(providerID: ProviderV2.ID) {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly hook: {
|
||||
readonly sdk: (
|
||||
callback: (event: SDKEvent) => Effect.Effect<void> | void,
|
||||
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||
readonly language: (
|
||||
callback: (event: LanguageEvent) => Effect.Effect<void> | void,
|
||||
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||
}
|
||||
readonly runSDK: (event: SDKEvent) => Effect.Effect<SDKEvent>
|
||||
readonly runLanguage: (event: LanguageEvent) => Effect.Effect<LanguageEvent>
|
||||
readonly language: (model: ModelV2.Info) => Effect.Effect<LanguageModelV3, InitError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/AISDK") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
export const locationLayer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
let sdkHooks: ((event: SDKEvent) => Effect.Effect<void> | void)[] = []
|
||||
let languageHooks: ((event: LanguageEvent) => Effect.Effect<void> | void)[] = []
|
||||
const languages = new Map<string, LanguageModelV3>()
|
||||
const sdks = new Map<string, SDK>()
|
||||
|
||||
return Service.of({
|
||||
const register = <Event>(
|
||||
hooks: () => ((event: Event) => Effect.Effect<void> | void)[],
|
||||
update: (hooks: ((event: Event) => Effect.Effect<void> | void)[]) => void,
|
||||
) =>
|
||||
Effect.fn("AISDK.hook")(function* (callback: (event: Event) => Effect.Effect<void> | void) {
|
||||
const scope = yield* Scope.Scope
|
||||
let active = true
|
||||
update([...hooks(), callback])
|
||||
const dispose = Effect.sync(() => {
|
||||
if (!active) return
|
||||
active = false
|
||||
update(hooks().filter((item) => item !== callback))
|
||||
})
|
||||
yield* Scope.addFinalizer(scope, dispose)
|
||||
return { dispose }
|
||||
})
|
||||
|
||||
const run = Effect.fnUntraced(function* <Event>(
|
||||
hooks: readonly ((event: Event) => Effect.Effect<void> | void)[],
|
||||
event: Event,
|
||||
) {
|
||||
for (const hook of hooks) {
|
||||
const result = hook(event)
|
||||
if (Effect.isEffect(result)) yield* result
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
const service = Service.of({
|
||||
hook: {
|
||||
sdk: register(
|
||||
() => sdkHooks,
|
||||
(next) => (sdkHooks = next),
|
||||
),
|
||||
language: register(
|
||||
() => languageHooks,
|
||||
(next) => (languageHooks = next),
|
||||
),
|
||||
},
|
||||
runSDK: (event) => run(sdkHooks, event),
|
||||
runLanguage: (event) => run(languageHooks, event),
|
||||
language: Effect.fn("AISDK.language")(function* (model) {
|
||||
const key = `${model.providerID}/${model.id}/${model.request.variant ?? "default"}`
|
||||
const existing = languages.get(key)
|
||||
@@ -148,26 +212,14 @@ export const layer = Layer.effect(
|
||||
})
|
||||
const sdk =
|
||||
sdks.get(sdkKey) ??
|
||||
(yield* plugin
|
||||
.trigger("aisdk.sdk", { model, package: model.api.package, options }, {})
|
||||
.pipe(initError(model.providerID))).sdk
|
||||
(yield* service.runSDK({ model, package: model.api.package, options }).pipe(initError(model.providerID))).sdk
|
||||
if (!sdk)
|
||||
return yield* new InitError({
|
||||
providerID: model.providerID,
|
||||
cause: new Error("No AISDK provider plugin returned an SDK"),
|
||||
})
|
||||
sdks.set(sdkKey, sdk)
|
||||
const result = yield* plugin
|
||||
.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model,
|
||||
sdk,
|
||||
options,
|
||||
},
|
||||
{},
|
||||
)
|
||||
.pipe(initError(model.providerID))
|
||||
const result = yield* service.runLanguage({ model, sdk, options }).pipe(initError(model.providerID))
|
||||
const language = yield* Effect.sync(() => result.language ?? sdk.languageModel(model.api.id)).pipe(
|
||||
initError(model.providerID),
|
||||
)
|
||||
@@ -175,7 +227,8 @@ export const layer = Layer.effect(
|
||||
return language
|
||||
}),
|
||||
})
|
||||
return service
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))))
|
||||
export const defaultLayer = locationLayer
|
||||
|
||||
@@ -69,10 +69,10 @@ export const layer = Layer.effect(
|
||||
const policy = yield* Policy.Service
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => {
|
||||
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
|
||||
if (provider.disabled) return false
|
||||
if (typeof provider.request.body.apiKey === "string") return true
|
||||
if (connected) return true
|
||||
if (integration?.connections.length) return true
|
||||
return !integration
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ export const layer = Layer.effect(
|
||||
})
|
||||
const result: Interface = {
|
||||
transform: state.transform,
|
||||
rebuild: state.rebuild,
|
||||
reload: state.reload,
|
||||
|
||||
provider: {
|
||||
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
|
||||
@@ -183,13 +183,8 @@ export const layer = Layer.effect(
|
||||
|
||||
available: Effect.fn("CatalogV2.provider.available")(function* () {
|
||||
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
|
||||
const connections = yield* integrations.connection.list()
|
||||
return (yield* result.provider.all()).filter((provider) =>
|
||||
available(
|
||||
provider,
|
||||
active.get(Integration.ID.make(provider.id)),
|
||||
connections.has(Integration.ID.make(provider.id)),
|
||||
),
|
||||
available(provider, active.get(Integration.ID.make(provider.id))),
|
||||
)
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -52,7 +52,7 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
rebuild: state.rebuild,
|
||||
reload: state.reload,
|
||||
transform: state.transform,
|
||||
get: Effect.fn("CommandV2.get")(function* (name) {
|
||||
return state.get().commands.get(name)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ConfigAgentPlugin from "./agent"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../../plugin/internal"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { AgentV2 } from "../../agent"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ConfigCommandPlugin from "./command"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../../plugin/internal"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { CommandV2 } from "../../command"
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
export * as ConfigExternalPlugin from "./external"
|
||||
|
||||
import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import { Config } from "../../config"
|
||||
import { FSUtil } from "../../fs-util"
|
||||
import { Location } from "../../location"
|
||||
import { Npm } from "../../npm"
|
||||
import { define } from "../../plugin/internal"
|
||||
import { PluginPromise } from "../../plugin/promise"
|
||||
|
||||
const PluginModule = Schema.Struct({
|
||||
default: Schema.Union([
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
effect: Schema.declare<EffectPlugin["effect"]>(
|
||||
(input): input is EffectPlugin["effect"] => typeof input === "function",
|
||||
),
|
||||
}),
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
setup: Schema.declare<PromisePlugin["setup"]>(
|
||||
(input): input is PromisePlugin["setup"] => typeof input === "function",
|
||||
),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
||||
export const Plugin = define({
|
||||
id: "config-plugin",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
yield* Effect.gen(function* () {
|
||||
const configured: { package: string; options?: Record<string, any> }[] = []
|
||||
|
||||
for (const entry of yield* config.entries()) {
|
||||
if (entry.type === "document") {
|
||||
const directory = entry.path ? path.dirname(entry.path) : location.directory
|
||||
for (const item of entry.info.plugins ?? []) {
|
||||
const ref = typeof item === "string" ? { package: item } : item
|
||||
const packageName = (() => {
|
||||
if (ref.package.startsWith("file://")) return fileURLToPath(ref.package)
|
||||
if (ref.package.startsWith("./") || ref.package.startsWith("../")) {
|
||||
return path.resolve(directory, ref.package)
|
||||
}
|
||||
return ref.package
|
||||
})()
|
||||
configured.push({ package: packageName, options: ref.options })
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === "directory") {
|
||||
const files = yield* fs
|
||||
.glob("{plugin,plugins}/*.{ts,js}", {
|
||||
cwd: entry.path,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})
|
||||
.pipe(Effect.orElseSucceed(() => []))
|
||||
files.sort()
|
||||
for (const file of files) configured.push({ package: file })
|
||||
}
|
||||
}
|
||||
|
||||
for (const ref of configured) {
|
||||
yield* Effect.gen(function* () {
|
||||
const entrypoint = path.isAbsolute(ref.package)
|
||||
? pathToFileURL(ref.package).href
|
||||
: (yield* npm.add(ref.package)).entrypoint
|
||||
if (!entrypoint) return
|
||||
|
||||
const mod = yield* Effect.promise(() => import(entrypoint))
|
||||
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
|
||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||
yield* ctx.plugin.add({
|
||||
id: plugin.id,
|
||||
effect: (host) => plugin.effect({ ...host, options: ref.options ?? {} }),
|
||||
})
|
||||
}).pipe(Effect.ignoreCause)
|
||||
}
|
||||
}).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
}),
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ConfigProviderPlugin from "./provider"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../../plugin/internal"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { ModelV2 } from "../../model"
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
export * as ConfigReferencePlugin from "./reference"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../../plugin/internal"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { ConfigReference } from "../reference"
|
||||
import { Reference } from "../../reference"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { Global } from "../../global"
|
||||
import { Location } from "../../location"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "core/config-reference",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
const global = yield* Global.Service
|
||||
yield* ctx.reference.transform(
|
||||
Effect.fn(function* (draft) {
|
||||
const entries = new Map<string, Reference.Source>()
|
||||
for (const doc of (yield* config.entries()).filter(
|
||||
(entry): entry is Config.Document => entry.type === "document",
|
||||
)) {
|
||||
const directory = doc.path ? path.dirname(doc.path) : ctx.location.directory
|
||||
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||
if (!validAlias(name)) continue
|
||||
entries.set(
|
||||
@@ -27,7 +31,7 @@ export const Plugin = define({
|
||||
? new Reference.LocalSource({
|
||||
type: "local",
|
||||
path: AbsolutePath.make(
|
||||
localPath(directory, ctx.path.home, typeof entry === "string" ? entry : entry.path),
|
||||
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
|
||||
),
|
||||
description: typeof entry === "string" ? undefined : entry.description,
|
||||
hidden: typeof entry === "string" ? undefined : entry.hidden,
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
export * as ConfigSkillPlugin from "./skill"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../../plugin/internal"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { SkillV2 } from "../../skill"
|
||||
import { Global } from "../../global"
|
||||
import { Location } from "../../location"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "config-skill",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
yield* ctx.skill.transform(
|
||||
Effect.fn(function* (draft) {
|
||||
const entries = yield* config.entries()
|
||||
@@ -29,13 +33,11 @@ export const Plugin = define({
|
||||
draft.source(new SkillV2.UrlSource({ type: "url", url: item }))
|
||||
continue
|
||||
}
|
||||
const expanded = item.startsWith("~/") ? path.join(ctx.path.home, item.slice(2)) : item
|
||||
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
|
||||
draft.source(
|
||||
new SkillV2.DirectorySource({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(
|
||||
path.isAbsolute(expanded) ? expanded : path.join(ctx.location.directory, expanded),
|
||||
),
|
||||
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,33 +29,33 @@ export class Key extends Schema.Class<Key>("Credential.Key")({
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
}) {}
|
||||
|
||||
export const Info = Schema.Union([OAuth, Key])
|
||||
export const Value = Schema.Union([OAuth, Key])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Credential.Info" })
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
.annotate({ identifier: "Credential.Value" })
|
||||
export type Value = Schema.Schema.Type<typeof Value>
|
||||
|
||||
export class Stored extends Schema.Class<Stored>("Credential.Stored")({
|
||||
export class Info extends Schema.Class<Info>("Credential.Info")({
|
||||
id: ID,
|
||||
integrationID: IntegrationSchema.ID,
|
||||
label: Schema.String,
|
||||
value: Info,
|
||||
value: Value,
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
/** Returns every stored credential. */
|
||||
readonly all: () => Effect.Effect<Stored[]>
|
||||
readonly all: () => Effect.Effect<Info[]>
|
||||
/** Returns stored credentials belonging to one integration. */
|
||||
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Stored[]>
|
||||
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Info[]>
|
||||
/** Returns one stored credential by ID. */
|
||||
readonly get: (id: ID) => Effect.Effect<Stored | undefined>
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
/** Replaces any credential for an integration and returns the new record. */
|
||||
readonly create: (input: {
|
||||
readonly integrationID: IntegrationSchema.ID
|
||||
readonly value: Info
|
||||
readonly value: Value
|
||||
readonly label?: string
|
||||
}) => Effect.Effect<Stored>
|
||||
}) => Effect.Effect<Info>
|
||||
/** Updates the label or secret value of a stored credential. */
|
||||
readonly update: (id: ID, updates: Partial<Pick<Stored, "label" | "value">>) => Effect.Effect<void>
|
||||
readonly update: (id: ID, updates: Partial<Pick<Info, "label" | "value">>) => Effect.Effect<void>
|
||||
/** Removes a stored credential. */
|
||||
readonly remove: (id: ID) => Effect.Effect<void>
|
||||
}
|
||||
@@ -66,10 +66,10 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
const decode = Schema.decodeUnknownSync(Value)
|
||||
const stored = (row: typeof CredentialTable.$inferSelect) => {
|
||||
if (!row.integration_id) return
|
||||
return new Stored({
|
||||
return new Info({
|
||||
id: row.id,
|
||||
integrationID: row.integration_id,
|
||||
label: row.label,
|
||||
@@ -106,7 +106,7 @@ export const layer = Layer.effect(
|
||||
return row ? stored(row) : undefined
|
||||
}),
|
||||
create: Effect.fn("Credential.create")(function* (input) {
|
||||
const credential = new Stored({
|
||||
const credential = new Info({
|
||||
id: ID.create(),
|
||||
integrationID: input.integrationID,
|
||||
label: input.label ?? "default",
|
||||
|
||||
@@ -7,7 +7,7 @@ export const CredentialTable = sqliteTable("credential", {
|
||||
id: text().$type<Credential.ID>().primaryKey(),
|
||||
integration_id: text().$type<IntegrationSchema.ID>(),
|
||||
label: text().notNull(),
|
||||
value: text({ mode: "json" }).$type<Credential.Info>().notNull(),
|
||||
value: text({ mode: "json" }).$type<Credential.Value>().notNull(),
|
||||
connector_id: text(),
|
||||
method_id: text(),
|
||||
active: integer({ mode: "boolean" }),
|
||||
|
||||
+3
@@ -37,5 +37,8 @@ export const migrations = (
|
||||
import("./migration/20260611035744_credential"),
|
||||
import("./migration/20260611192811_lush_chimera"),
|
||||
import("./migration/20260612174303_project_dir_strategy"),
|
||||
import("./migration/20260622142730_simplify_session_context_epoch"),
|
||||
import("./migration/20260622170816_reset_v2_session_state"),
|
||||
import("./migration/20260622202450_simplify_session_input"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260622142730_simplify_session_context_epoch",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`agent\`;`)
|
||||
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`replacement_seq\`;`)
|
||||
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`revision\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260622170816_reset_v2_session_state",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`DELETE FROM \`session_context_epoch\`;`)
|
||||
yield* tx.run(`DELETE FROM \`session_input\`;`)
|
||||
yield* tx.run(`DELETE FROM \`session_message\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
|
||||
yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`)
|
||||
yield* tx.run(`DELETE FROM \`workspace\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260622202450_simplify_session_input",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`DELETE FROM \`session_context_epoch\`;`)
|
||||
yield* tx.run(`DELETE FROM \`session_input\`;`)
|
||||
yield* tx.run(`DELETE FROM \`session_message\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
|
||||
yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`)
|
||||
yield* tx.run(`DELETE FROM \`workspace\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -149,11 +149,8 @@ export default {
|
||||
CREATE TABLE \`session_context_epoch\` (
|
||||
\`session_id\` text PRIMARY KEY,
|
||||
\`baseline\` text NOT NULL,
|
||||
\`agent\` text DEFAULT 'build' NOT NULL,
|
||||
\`snapshot\` text NOT NULL,
|
||||
\`baseline_seq\` integer NOT NULL,
|
||||
\`replacement_seq\` integer,
|
||||
\`revision\` integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
|
||||
@@ -46,6 +46,19 @@ export type Payload<D extends Definition = Definition> = {
|
||||
export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
|
||||
export type Unsubscribe = Effect.Effect<void>
|
||||
|
||||
export const latestSequence = Effect.fn("EventV2.latestSequence")(function* (
|
||||
db: Database.Interface["db"],
|
||||
aggregateID: string,
|
||||
) {
|
||||
const row = yield* db
|
||||
.select({ seq: EventSequenceTable.seq })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row?.seq ?? -1
|
||||
})
|
||||
|
||||
export type SerializedEvent = {
|
||||
readonly id: ID
|
||||
readonly type: string
|
||||
|
||||
@@ -7,8 +7,8 @@ import { FSUtil } from "./fs-util"
|
||||
import { Location } from "./location"
|
||||
import { PositiveInt, RelativePath } from "./schema"
|
||||
import { FileSystemSearch } from "./filesystem/search"
|
||||
import { Entry, Match, PathError } from "./filesystem/schema"
|
||||
export { Entry, Match, PathError, Submatch } from "./filesystem/schema"
|
||||
import { Entry, Match } from "./filesystem/schema"
|
||||
export { Entry, Match, Submatch } from "./filesystem/schema"
|
||||
|
||||
export const ReadInput = Schema.Struct({
|
||||
path: RelativePath,
|
||||
@@ -58,10 +58,8 @@ export const Event = {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly read: (
|
||||
input: ReadInput,
|
||||
) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }, PathError | FSUtil.Error>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Entry[], PathError | FSUtil.Error>
|
||||
readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
|
||||
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
|
||||
readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[]>
|
||||
readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[]>
|
||||
@@ -79,9 +77,9 @@ const baseLayer = Layer.effect(
|
||||
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
|
||||
const absolute = path.resolve(location.directory, input ?? ".")
|
||||
if (!FSUtil.contains(location.directory, absolute))
|
||||
return yield* new PathError({ path: input ?? ".", reason: "lexical_escape" })
|
||||
const real = yield* fs.realPath(absolute)
|
||||
if (!FSUtil.contains(root, real)) return yield* new PathError({ path: input ?? ".", reason: "symlink_escape" })
|
||||
return yield* Effect.die(new Error("Path escapes the location"))
|
||||
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
|
||||
if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
|
||||
return { absolute, real, directory: location.directory, root }
|
||||
})
|
||||
return Service.of({
|
||||
@@ -90,18 +88,19 @@ const baseLayer = Layer.effect(
|
||||
grep: search.grep,
|
||||
read: Effect.fn("FileSystem.read")(function* (input) {
|
||||
const target = yield* resolve(input.path)
|
||||
const info = yield* fs.stat(target.real)
|
||||
if (info.type !== "File") return yield* new PathError({ path: input.path, reason: "not_file" })
|
||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
||||
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
||||
return {
|
||||
content: yield* fs.readFile(target.real),
|
||||
content: yield* fs.readFile(target.real).pipe(Effect.orDie),
|
||||
mime: FSUtil.mimeType(target.real),
|
||||
}
|
||||
}),
|
||||
list: Effect.fn("FileSystem.list")(function* (input = {}) {
|
||||
const target = yield* resolve(input.path)
|
||||
const info = yield* fs.stat(target.real)
|
||||
if (info.type !== "Directory") return yield* new PathError({ path: input.path ?? ".", reason: "not_directory" })
|
||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
||||
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
|
||||
return yield* fs.readDirectoryEntries(target.real).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((items) =>
|
||||
items
|
||||
.flatMap((item) => {
|
||||
@@ -124,6 +123,6 @@ const baseLayer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.defaultLayer), Layer.provide(FSUtil.defaultLayer))
|
||||
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer))
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
@@ -21,8 +21,3 @@ export class Match extends Schema.Class<Match>("FileSystem.Match")({
|
||||
text: Schema.String,
|
||||
submatches: Schema.Array(Submatch),
|
||||
}) {}
|
||||
|
||||
export class PathError extends Schema.TaggedErrorClass<PathError>()("FileSystem.PathError", {
|
||||
path: Schema.String,
|
||||
reason: Schema.Literals(["lexical_escape", "symlink_escape", "not_file", "not_directory"]),
|
||||
}) {}
|
||||
|
||||
@@ -232,6 +232,6 @@ export const fffLayer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = Layer.unwrap(
|
||||
export const locationLayer = Layer.unwrap(
|
||||
Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)),
|
||||
)
|
||||
|
||||
@@ -108,11 +108,11 @@ export type OAuthAuthorization = {
|
||||
} & (
|
||||
| {
|
||||
readonly mode: "auto"
|
||||
readonly callback: Effect.Effect<Credential.Info, unknown>
|
||||
readonly callback: Effect.Effect<Credential.Value, unknown>
|
||||
}
|
||||
| {
|
||||
readonly mode: "code"
|
||||
readonly callback: (code: string) => Effect.Effect<Credential.Info, unknown>
|
||||
readonly callback: (code: string) => Effect.Effect<Credential.Value, unknown>
|
||||
}
|
||||
)
|
||||
|
||||
@@ -214,8 +214,6 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
/** Returns all integrations with their methods and current connections. */
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly connection: {
|
||||
/** Returns active connections for every registered or credential-backed integration. */
|
||||
readonly list: () => Effect.Effect<Map<ID, IntegrationConnection.Info>>
|
||||
/** Returns the active connection for one integration. */
|
||||
readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
|
||||
/** Runs a key method and stores the resulting credential. */
|
||||
@@ -241,7 +239,7 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
/** Updates a stored credential exposed as a connection. */
|
||||
readonly update: (
|
||||
credentialID: Credential.ID,
|
||||
updates: Partial<Pick<Credential.Stored, "label">>,
|
||||
updates: Partial<Pick<Credential.Info, "label">>,
|
||||
) => Effect.Effect<void>
|
||||
/** Removes a stored credential connection. */
|
||||
readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
|
||||
@@ -353,39 +351,27 @@ export const locationLayer = Layer.effect(
|
||||
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => {
|
||||
const connected = saved.map((credential) => ({
|
||||
type: "credential" as const,
|
||||
id: credential.id,
|
||||
label: credential.label,
|
||||
}))
|
||||
const detected = entry.methods
|
||||
const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => {
|
||||
const credentials = saved
|
||||
.map((credential) => ({
|
||||
type: "credential" as const,
|
||||
id: credential.id,
|
||||
label: credential.label,
|
||||
}))
|
||||
.toReversed()
|
||||
const env = (entry?.methods ?? [])
|
||||
.filter((method) => method.type === "env")
|
||||
.flatMap((method) => method.names.filter((name) => process.env[name]))
|
||||
.map((name) => ({ type: "env" as const, name }))
|
||||
return [...connected, ...detected]
|
||||
return [...credentials, ...env]
|
||||
}
|
||||
|
||||
const activeConnection = (
|
||||
entry: Entry | undefined,
|
||||
saved: readonly Credential.Stored[],
|
||||
): IntegrationConnection.Info | undefined => {
|
||||
const credential = saved.at(-1)
|
||||
if (credential) return { type: "credential", id: credential.id, label: credential.label }
|
||||
if (!entry) return
|
||||
const name = entry.methods
|
||||
.filter((method) => method.type === "env")
|
||||
.flatMap((method) => method.names)
|
||||
.find((name) => process.env[name])
|
||||
if (name) return { type: "env", name }
|
||||
}
|
||||
|
||||
const project = (entry: Entry, saved: readonly Credential.Stored[]) =>
|
||||
const project = (entry: Entry, connections: IntegrationConnection.Info[]) =>
|
||||
new Info({
|
||||
id: entry.ref.id,
|
||||
name: entry.ref.name,
|
||||
methods: entry.methods,
|
||||
connections: connections(entry, saved),
|
||||
connections,
|
||||
})
|
||||
|
||||
const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
@@ -399,7 +385,7 @@ export const locationLayer = Layer.effect(
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Info, unknown>) {
|
||||
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Value, unknown>) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const result = yield* SynchronizedRef.modify(attempts, (current) => {
|
||||
const attempt = current.get(attemptID)
|
||||
@@ -446,32 +432,22 @@ export const locationLayer = Layer.effect(
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
rebuild: state.rebuild,
|
||||
reload: state.reload,
|
||||
get: Effect.fn("Integration.get")(function* (id) {
|
||||
const entry = state.get().integrations.get(id)
|
||||
if (!entry) return undefined
|
||||
return project(entry, yield* credentials.list(id))
|
||||
return project(entry, resolveConnections(entry, yield* credentials.list(id)))
|
||||
}),
|
||||
list: Effect.fn("Integration.list")(function* () {
|
||||
return (yield* Effect.forEach(state.get().integrations.values(), (entry) =>
|
||||
Effect.gen(function* () {
|
||||
return project(entry, yield* credentials.list(entry.ref.id))
|
||||
}),
|
||||
)).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
|
||||
return Array.from(state.get().integrations.values(), (entry) =>
|
||||
project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? [])),
|
||||
).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
}),
|
||||
connection: {
|
||||
list: Effect.fn("Integration.connection.list")(function* () {
|
||||
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
|
||||
return new Map(
|
||||
new Set([...state.get().integrations.keys(), ...saved.keys()]).values().flatMap((id) => {
|
||||
const connection = activeConnection(state.get().integrations.get(id), saved.get(id) ?? [])
|
||||
return connection ? [[id, connection] as const] : []
|
||||
}),
|
||||
)
|
||||
}),
|
||||
forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
|
||||
const entry = state.get().integrations.get(id)
|
||||
return activeConnection(entry, yield* credentials.list(id))
|
||||
return resolveConnections(entry, yield* credentials.list(id))[0]
|
||||
}),
|
||||
key: Effect.fn("Integration.connection.key")(function* (input) {
|
||||
const method = state
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Catalog } from "./catalog"
|
||||
import { Integration } from "./integration"
|
||||
import { CommandV2 } from "./command"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { PluginBoot } from "./plugin/boot"
|
||||
import { PluginInternal } from "./plugin/internal"
|
||||
import { Project } from "./project"
|
||||
import { ProjectCopy } from "./project/copy"
|
||||
import { ProjectDirectories } from "./project/directories"
|
||||
@@ -65,7 +65,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
||||
Integration.locationLayer,
|
||||
CommandV2.locationLayer,
|
||||
AgentV2.locationLayer,
|
||||
PluginBoot.locationLayer,
|
||||
PluginInternal.locationLayer,
|
||||
ProjectCopy.locationLayer,
|
||||
FileSystem.locationLayer,
|
||||
Watcher.locationLayer,
|
||||
|
||||
+76
-173
@@ -1,12 +1,17 @@
|
||||
export * as PluginV2 from "./plugin"
|
||||
|
||||
import { createDraft, finishDraft, type Draft } from "immer"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Context, Effect, Exit, Layer, Schema, Scope } from "effect"
|
||||
import type { ModelV2 } from "./model"
|
||||
import type { Catalog } from "./catalog"
|
||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { AISDK } from "./aisdk"
|
||||
import { Catalog } from "./catalog"
|
||||
import { CommandV2 } from "./command"
|
||||
import { EventV2 } from "./event"
|
||||
import { Integration } from "./integration"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
import { PluginHost } from "./plugin/host"
|
||||
import { Reference } from "./reference"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { State } from "./state"
|
||||
|
||||
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
|
||||
@@ -21,69 +26,9 @@ export const Event = {
|
||||
}),
|
||||
}
|
||||
|
||||
type HookSpec = {
|
||||
"catalog.transform": {
|
||||
input: Catalog.Draft
|
||||
output: {}
|
||||
}
|
||||
"aisdk.language": {
|
||||
input: {
|
||||
model: ModelV2.Info
|
||||
sdk: any
|
||||
options: Record<string, any>
|
||||
}
|
||||
output: {
|
||||
language?: LanguageModelV3
|
||||
}
|
||||
}
|
||||
"aisdk.sdk": {
|
||||
input: {
|
||||
model: ModelV2.Info
|
||||
package: string
|
||||
options: Record<string, any>
|
||||
}
|
||||
output: {
|
||||
sdk?: any
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type Hooks = {
|
||||
[Name in keyof HookSpec]: Readonly<HookSpec[Name]["input"]> & {
|
||||
-readonly [Field in keyof HookSpec[Name]["output"]]: HookSpec[Name]["output"][Field] extends object
|
||||
? Draft<HookSpec[Name]["output"][Field]>
|
||||
: HookSpec[Name]["output"][Field]
|
||||
}
|
||||
}
|
||||
|
||||
export type HookFunctions = {
|
||||
[key in keyof Hooks]?: (input: Hooks[key]) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export type HookInput<Name extends keyof Hooks> = HookSpec[Name]["input"]
|
||||
export type HookOutput<Name extends keyof Hooks> = HookSpec[Name]["output"]
|
||||
|
||||
export interface Interface {
|
||||
readonly add: (input: {
|
||||
id: string
|
||||
effect: Effect.Effect<void | HookFunctions, never, Scope.Scope>
|
||||
}) => Effect.Effect<void, never, never>
|
||||
readonly add: (id: ID, effect: Plugin["effect"]) => Effect.Effect<void>
|
||||
readonly remove: (id: ID) => Effect.Effect<void>
|
||||
readonly hook: <Name extends keyof Hooks>(
|
||||
name: Name,
|
||||
callback: (input: Hooks[Name]) => Effect.Effect<void> | void,
|
||||
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||
readonly triggerFor: <Name extends keyof Hooks>(
|
||||
id: ID,
|
||||
name: Name,
|
||||
input: HookInput<Name>,
|
||||
output: HookOutput<Name>,
|
||||
) => Effect.Effect<HookInput<Name> & HookOutput<Name>>
|
||||
readonly trigger: <Name extends keyof Hooks>(
|
||||
name: Name,
|
||||
input: HookInput<Name>,
|
||||
output: HookOutput<Name>,
|
||||
) => Effect.Effect<HookInput<Name> & HookOutput<Name>>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Plugin") {}
|
||||
@@ -91,119 +36,77 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
let hooks: {
|
||||
id: ID
|
||||
hooks: HookFunctions
|
||||
scope: Scope.Closeable
|
||||
}[] = []
|
||||
let registrations: {
|
||||
[Name in keyof Hooks]: {
|
||||
name: Name
|
||||
callback: (input: Hooks[Name]) => Effect.Effect<void> | void
|
||||
}
|
||||
}[keyof Hooks][] = []
|
||||
const events = yield* EventV2.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const locks = KeyedMutex.makeUnsafe<ID>()
|
||||
const scope = yield* Scope.make()
|
||||
const active = new Map<ID, Scope.Closeable>()
|
||||
const loading = new Set<ID>()
|
||||
let host: Parameters<Plugin["effect"]>[0]
|
||||
|
||||
const svc = Service.of({
|
||||
add: Effect.fn("Plugin.add")(function* (input) {
|
||||
const id = ID.make(input.id)
|
||||
yield* locks.withLock(id)(
|
||||
Effect.gen(function* () {
|
||||
const existing = hooks.find((item) => item.id === id)
|
||||
if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
|
||||
const childScope = yield* Scope.fork(scope)
|
||||
const result = yield* input.effect.pipe(
|
||||
Scope.provide(childScope),
|
||||
Effect.withSpan("Plugin.load", {
|
||||
attributes: {
|
||||
"plugin.id": id,
|
||||
},
|
||||
const add = Effect.fn("Plugin.add")(function* (id: ID, effect: Plugin["effect"]) {
|
||||
if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`)
|
||||
|
||||
yield* locks.withLock(id)(
|
||||
Effect.sync(() => loading.add(id)).pipe(
|
||||
Effect.andThen(
|
||||
State.batch(
|
||||
Effect.gen(function* () {
|
||||
const existing = active.get(id)
|
||||
active.delete(id)
|
||||
if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore)
|
||||
|
||||
const child = yield* Scope.fork(scope)
|
||||
yield* effect(host).pipe(
|
||||
Scope.provide(child),
|
||||
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }),
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
|
||||
)
|
||||
active.set(id, child)
|
||||
yield* events.publish(Event.Added, { id })
|
||||
}),
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(childScope, exit) : Effect.void)),
|
||||
)
|
||||
const next = {
|
||||
id,
|
||||
hooks: result ?? {},
|
||||
scope: childScope,
|
||||
}
|
||||
hooks = existing ? hooks.with(hooks.indexOf(existing), next) : [...hooks, next]
|
||||
yield* events.publish(Event.Added, { id })
|
||||
}),
|
||||
)
|
||||
}),
|
||||
trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) {
|
||||
return yield* svc.triggerFor(ID.make("*"), name, input, output)
|
||||
}),
|
||||
triggerFor: Effect.fn("Plugin.triggerFor")(function* (id, name, input, output) {
|
||||
const draftEntries = new Map<string, ReturnType<typeof createDraft>>()
|
||||
const event = {
|
||||
...input,
|
||||
...output,
|
||||
} as Record<string, unknown>
|
||||
|
||||
for (const [field, value] of Object.entries(output)) {
|
||||
if (value && typeof value === "object") {
|
||||
draftEntries.set(field, createDraft(value))
|
||||
event[field] = draftEntries.get(field)
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of hooks) {
|
||||
if (id !== ID.make("*") && item.id !== id) continue
|
||||
const match = item.hooks[name]
|
||||
if (!match) continue
|
||||
yield* match(event as any).pipe(
|
||||
Effect.withSpan(`Plugin.hook.${name}`, {
|
||||
attributes: {
|
||||
plugin: item.id,
|
||||
hook: name,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const item of registrations) {
|
||||
if (item.name !== name) continue
|
||||
const result = item.callback(event as never)
|
||||
if (Effect.isEffect(result)) yield* result
|
||||
}
|
||||
|
||||
for (const [field, draft] of draftEntries) {
|
||||
event[field] = finishDraft(draft)
|
||||
}
|
||||
|
||||
return event as any
|
||||
}),
|
||||
remove: Effect.fn("Plugin.remove")(function* (id) {
|
||||
yield* locks.withLock(id)(
|
||||
Effect.gen(function* () {
|
||||
const existing = hooks.find((item) => item.id === id)
|
||||
hooks = hooks.filter((item) => item.id !== id)
|
||||
if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
hook: Effect.fn("Plugin.hook")(function* (name, callback) {
|
||||
const scope = yield* Scope.Scope
|
||||
const registration = { name, callback } as (typeof registrations)[number]
|
||||
let active = true
|
||||
registrations = [...registrations, registration]
|
||||
const dispose = Effect.sync(() => {
|
||||
if (!active) return
|
||||
active = false
|
||||
registrations = registrations.filter((item) => item !== registration)
|
||||
})
|
||||
yield* Scope.addFinalizer(scope, dispose)
|
||||
return { dispose }
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.ensuring(Effect.sync(() => loading.delete(id))),
|
||||
),
|
||||
)
|
||||
})
|
||||
return svc
|
||||
|
||||
const remove = Effect.fn("Plugin.remove")(function* (id: ID) {
|
||||
if (loading.has(id)) return yield* Effect.die(`Cannot remove plugin ${id} while it is loading`)
|
||||
|
||||
yield* locks.withLock(id)(
|
||||
State.batch(
|
||||
Effect.gen(function* () {
|
||||
const current = active.get(id)
|
||||
active.delete(id)
|
||||
if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
yield* Effect.addFinalizer((exit) =>
|
||||
Effect.gen(function* () {
|
||||
active.clear()
|
||||
yield* State.batch(Scope.close(scope, exit))
|
||||
}),
|
||||
)
|
||||
|
||||
const service = Service.of({
|
||||
add,
|
||||
remove,
|
||||
})
|
||||
host = yield* PluginHost.make(service)
|
||||
return service
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
// opencode
|
||||
// sdcok
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(AgentV2.locationLayer),
|
||||
Layer.provideMerge(AISDK.locationLayer),
|
||||
Layer.provideMerge(Catalog.locationLayer),
|
||||
Layer.provideMerge(CommandV2.locationLayer),
|
||||
Layer.provideMerge(Integration.locationLayer),
|
||||
Layer.provideMerge(Reference.locationLayer),
|
||||
Layer.provideMerge(SkillV2.locationLayer),
|
||||
)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
export * as AgentPlugin from "./agent"
|
||||
|
||||
import path from "path"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "./internal"
|
||||
import { Effect } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Global } from "../global"
|
||||
import { Location } from "../location"
|
||||
import { PermissionV2 } from "../permission"
|
||||
|
||||
const TRUNCATION_GLOB = path.join(Global.Path.data, "tool-output", "*")
|
||||
@@ -99,7 +100,8 @@ Rules:
|
||||
export const Plugin = define({
|
||||
id: "agent",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const worktree = ctx.location.directory
|
||||
const location = yield* Location.Service
|
||||
const worktree = location.directory
|
||||
const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")]
|
||||
const readonlyExternalDirectory: PermissionV2.Ruleset = [
|
||||
{ action: "external_directory", resource: "*", effect: "ask" },
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
export * as PluginBoot from "./boot"
|
||||
|
||||
import type { Plugin as PublicPlugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Context, Deferred, Effect, Layer } from "effect"
|
||||
import { Integration } from "../integration"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Catalog } from "../catalog"
|
||||
import { CommandV2 } from "../command"
|
||||
import { Config } from "../config"
|
||||
import { ConfigAgentPlugin } from "../config/plugin/agent"
|
||||
import { ConfigCommandPlugin } from "../config/plugin/command"
|
||||
import { ConfigSkillPlugin } from "../config/plugin/skill"
|
||||
import { ConfigReferencePlugin } from "../config/plugin/reference"
|
||||
import { EventV2 } from "../event"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { Global } from "../global"
|
||||
import { Location } from "../location"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
import { Npm } from "../npm"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { AgentPlugin } from "./agent"
|
||||
import { CommandPlugin } from "./command"
|
||||
import { SkillPlugin } from "./skill"
|
||||
import { ConfigProviderPlugin } from "../config/plugin/provider"
|
||||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { Reference } from "../reference"
|
||||
import { State } from "../state"
|
||||
import { PluginHost } from "./host"
|
||||
|
||||
type InternalPlugin = PublicPlugin<any>
|
||||
|
||||
export interface Interface {
|
||||
readonly add: (plugin: PublicPlugin<any>) => Effect.Effect<void>
|
||||
readonly wait: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/PluginBoot") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const commands = yield* CommandV2.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const integration = yield* Integration.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
const npm = yield* Npm.Service
|
||||
const events = yield* EventV2.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const global = yield* Global.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const reference = yield* Reference.Service
|
||||
const host = yield* PluginHost.make()
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
||||
const add = Effect.fn("PluginBoot.add")(function* (input: InternalPlugin) {
|
||||
yield* plugin.add({
|
||||
id: input.id,
|
||||
effect: input
|
||||
.effect(host)
|
||||
.pipe(
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(CommandV2.Service, commands),
|
||||
Effect.provideService(Integration.Service, integration),
|
||||
Effect.provideService(AgentV2.Service, agents),
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(Location.Service, location),
|
||||
Effect.provideService(ModelsDev.Service, modelsDev),
|
||||
Effect.provideService(Npm.Service, npm),
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provideService(FSUtil.Service, fs),
|
||||
Effect.provideService(FileSystem.Service, filesystem),
|
||||
Effect.provideService(Global.Service, global),
|
||||
Effect.provideService(SkillV2.Service, skill),
|
||||
Effect.provideService(Reference.Service, reference),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
const boot = Effect.gen(function* () {
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* add(AgentPlugin.Plugin)
|
||||
yield* add(CommandPlugin.Plugin)
|
||||
yield* add(SkillPlugin.Plugin)
|
||||
yield* add(ModelsDevPlugin)
|
||||
yield* add(ConfigProviderPlugin.Plugin)
|
||||
yield* add(ConfigAgentPlugin.Plugin)
|
||||
yield* add(ConfigCommandPlugin.Plugin)
|
||||
yield* add(ConfigSkillPlugin.Plugin)
|
||||
yield* add(ConfigReferencePlugin.Plugin)
|
||||
for (const item of ProviderPlugins) {
|
||||
yield* add(item)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.withSpan("PluginBoot.boot"))
|
||||
|
||||
yield* boot.pipe(
|
||||
Effect.exit,
|
||||
Effect.flatMap((exit) => Deferred.done(done, exit)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
add: (input) =>
|
||||
Deferred.await(done).pipe(
|
||||
Effect.andThen(
|
||||
plugin.add({
|
||||
id: input.id,
|
||||
effect: input.effect(host),
|
||||
}),
|
||||
),
|
||||
),
|
||||
wait: () => Deferred.await(done),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(PluginV2.locationLayer),
|
||||
Layer.provideMerge(Integration.locationLayer),
|
||||
Layer.provideMerge(Catalog.locationLayer),
|
||||
Layer.provideMerge(CommandV2.locationLayer),
|
||||
Layer.provideMerge(Config.locationLayer),
|
||||
Layer.provideMerge(AgentV2.locationLayer),
|
||||
Layer.provideMerge(SkillV2.locationLayer),
|
||||
Layer.provideMerge(Reference.locationLayer),
|
||||
Layer.provideMerge(FileSystem.locationLayer),
|
||||
)
|
||||
@@ -1,20 +1,22 @@
|
||||
export * as CommandPlugin from "./command"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "./internal"
|
||||
import { Effect } from "effect"
|
||||
import { Location } from "../location"
|
||||
import PROMPT_INITIALIZE from "./command/initialize.txt"
|
||||
import PROMPT_REVIEW from "./command/review.txt"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "command",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const location = yield* Location.Service
|
||||
yield* ctx.command.transform((draft) => {
|
||||
draft.update("init", (command) => {
|
||||
command.template = PROMPT_INITIALIZE.replace("${path}", ctx.location.project.directory)
|
||||
command.template = PROMPT_INITIALIZE.replace("${path}", location.project.directory)
|
||||
command.description = "guided AGENTS.md setup"
|
||||
})
|
||||
draft.update("review", (command) => {
|
||||
command.template = PROMPT_REVIEW.replace("${path}", ctx.location.project.directory)
|
||||
command.template = PROMPT_REVIEW.replace("${path}", location.project.directory)
|
||||
command.description = "review changes [commit|branch|pr], defaults to uncommitted"
|
||||
command.subtask = true
|
||||
})
|
||||
|
||||
@@ -1,58 +1,31 @@
|
||||
export * as PluginHost from "./host"
|
||||
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import type { PluginHost as Interface } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Event as SDKEvent, ModelV2Info } from "@opencode-ai/sdk/v2/types"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import type { PluginContext as Interface } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { AISDK } from "../aisdk"
|
||||
import { Catalog } from "../catalog"
|
||||
import { CommandV2 } from "../command"
|
||||
import { EventV2 } from "../event"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { Global } from "../global"
|
||||
import { Integration } from "../integration"
|
||||
import { Location } from "../location"
|
||||
import { ModelV2 } from "../model"
|
||||
import { Npm } from "../npm"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { ProviderV2 } from "../provider"
|
||||
import { Reference } from "../reference"
|
||||
import { SkillV2 } from "../skill"
|
||||
|
||||
type EventMap = { [Item in SDKEvent as Item["type"]]: Item }
|
||||
type SDKHook = (event: {
|
||||
readonly model: ModelV2Info
|
||||
readonly package: string
|
||||
readonly options: Record<string, any>
|
||||
sdk?: any
|
||||
}) => Effect.Effect<void> | void
|
||||
type LanguageHook = (event: {
|
||||
readonly model: ModelV2Info
|
||||
readonly sdk: any
|
||||
readonly options: Record<string, any>
|
||||
language?: LanguageModelV3
|
||||
}) => Effect.Effect<void> | void
|
||||
|
||||
export const make = Effect.fn("PluginHost.make")(function* () {
|
||||
export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
|
||||
const agents = yield* AgentV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const commands = yield* CommandV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const global = yield* Global.Service
|
||||
const integration = yield* Integration.Service
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const reference = yield* Reference.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
|
||||
return {
|
||||
options: {},
|
||||
agent: {
|
||||
get: (id) => agents.get(AgentV2.ID.make(id)),
|
||||
default: agents.default,
|
||||
list: agents.all,
|
||||
rebuild: agents.rebuild,
|
||||
reload: agents.reload,
|
||||
transform: (callback) =>
|
||||
agents.transform((draft) =>
|
||||
callback({
|
||||
@@ -65,51 +38,35 @@ export const make = Effect.fn("PluginHost.make")(function* () {
|
||||
),
|
||||
},
|
||||
aisdk: {
|
||||
hook: (name, callback) => {
|
||||
if (name === "sdk") {
|
||||
const run = callback as SDKHook
|
||||
return plugin.hook("aisdk.sdk", (event) => {
|
||||
const output = {
|
||||
model: event.model,
|
||||
package: event.package,
|
||||
options: event.options,
|
||||
sdk: event.sdk,
|
||||
}
|
||||
const result = run(output)
|
||||
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
|
||||
Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))),
|
||||
)
|
||||
})
|
||||
}
|
||||
const run = callback as LanguageHook
|
||||
return plugin.hook("aisdk.language", (event) => {
|
||||
sdk: (callback) =>
|
||||
aisdk.hook.sdk((event) => {
|
||||
const output = {
|
||||
model: event.model,
|
||||
package: event.package,
|
||||
options: event.options,
|
||||
sdk: event.sdk,
|
||||
}
|
||||
const result = callback(output)
|
||||
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
|
||||
Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))),
|
||||
)
|
||||
}),
|
||||
language: (callback) =>
|
||||
aisdk.hook.language((event) => {
|
||||
const output = {
|
||||
model: event.model,
|
||||
sdk: event.sdk,
|
||||
options: event.options,
|
||||
language: event.language,
|
||||
}
|
||||
const result = run(output)
|
||||
const result = callback(output)
|
||||
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
|
||||
Effect.tap(() => Effect.sync(() => (event.language = output.language))),
|
||||
)
|
||||
})
|
||||
},
|
||||
}),
|
||||
},
|
||||
catalog: {
|
||||
provider: {
|
||||
get: (id) => catalog.provider.get(ProviderV2.ID.make(id)),
|
||||
list: catalog.provider.all,
|
||||
available: catalog.provider.available,
|
||||
},
|
||||
model: {
|
||||
get: (providerID, modelID) => catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
|
||||
list: catalog.model.all,
|
||||
available: catalog.model.available,
|
||||
default: catalog.model.default,
|
||||
small: (providerID) => catalog.model.small(ProviderV2.ID.make(providerID)),
|
||||
},
|
||||
rebuild: catalog.rebuild,
|
||||
reload: catalog.reload,
|
||||
transform: (callback) =>
|
||||
catalog.transform((draft) =>
|
||||
callback({
|
||||
@@ -135,41 +92,11 @@ export const make = Effect.fn("PluginHost.make")(function* () {
|
||||
),
|
||||
},
|
||||
command: {
|
||||
get: commands.get,
|
||||
list: commands.list,
|
||||
rebuild: commands.rebuild,
|
||||
reload: commands.reload,
|
||||
transform: commands.transform,
|
||||
},
|
||||
event: {
|
||||
subscribe: <Type extends keyof EventMap>(type: Type): Stream.Stream<EventMap[Type]> =>
|
||||
Stream.unwrap(
|
||||
Effect.sync(() => {
|
||||
const definition = EventV2.registry.get(type)
|
||||
if (!definition) throw new Error(`Unknown event type: ${type}`)
|
||||
const encode = Schema.encodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)
|
||||
return events.subscribe(definition).pipe(
|
||||
Stream.map(
|
||||
(event) =>
|
||||
({
|
||||
id: event.id,
|
||||
type: event.type,
|
||||
properties: encode(event.data),
|
||||
}) as unknown as EventMap[Type],
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
},
|
||||
filesystem: {
|
||||
read: (input) => filesystem.read(Schema.decodeUnknownSync(FileSystem.ReadInput)(input)),
|
||||
list: (input) => filesystem.list(Schema.decodeUnknownSync(FileSystem.ListInput)(input ?? {})),
|
||||
find: (input) => filesystem.find(Schema.decodeUnknownSync(FileSystem.FindInput)(input)),
|
||||
glob: (input) => filesystem.glob(Schema.decodeUnknownSync(FileSystem.GlobInput)(input)),
|
||||
},
|
||||
integration: {
|
||||
get: (id) => integration.get(Integration.ID.make(id)),
|
||||
list: integration.list,
|
||||
rebuild: integration.rebuild,
|
||||
reload: integration.reload,
|
||||
transform: (callback) =>
|
||||
integration.transform((draft) =>
|
||||
callback({
|
||||
@@ -198,19 +125,12 @@ export const make = Effect.fn("PluginHost.make")(function* () {
|
||||
}),
|
||||
),
|
||||
},
|
||||
location,
|
||||
npm,
|
||||
path: {
|
||||
home: global.home,
|
||||
data: global.data,
|
||||
cache: global.cache,
|
||||
config: global.config,
|
||||
state: global.state,
|
||||
temp: global.tmp,
|
||||
plugin: {
|
||||
add: (input) => plugin.add(PluginV2.ID.make(input.id), input.effect),
|
||||
remove: (id) => plugin.remove(PluginV2.ID.make(id)),
|
||||
},
|
||||
reference: {
|
||||
list: reference.list,
|
||||
rebuild: reference.rebuild,
|
||||
reload: reference.reload,
|
||||
transform: (callback) =>
|
||||
reference.transform((draft) =>
|
||||
callback({
|
||||
@@ -221,9 +141,7 @@ export const make = Effect.fn("PluginHost.make")(function* () {
|
||||
),
|
||||
},
|
||||
skill: {
|
||||
sources: skill.sources,
|
||||
list: skill.list,
|
||||
rebuild: skill.rebuild,
|
||||
reload: skill.reload,
|
||||
transform: (callback) =>
|
||||
skill.transform((draft) =>
|
||||
callback({
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
export * as PluginInternal from "./internal"
|
||||
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect, Layer, Scope } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Catalog } from "../catalog"
|
||||
import { CommandV2 } from "../command"
|
||||
import { Config } from "../config"
|
||||
import { ConfigAgentPlugin } from "../config/plugin/agent"
|
||||
import { ConfigCommandPlugin } from "../config/plugin/command"
|
||||
import { ConfigExternalPlugin } from "../config/plugin/external"
|
||||
import { ConfigProviderPlugin } from "../config/plugin/provider"
|
||||
import { ConfigReferencePlugin } from "../config/plugin/reference"
|
||||
import { ConfigSkillPlugin } from "../config/plugin/skill"
|
||||
import { EventV2 } from "../event"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Global } from "../global"
|
||||
import { Integration } from "../integration"
|
||||
import { Location } from "../location"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
import { Npm } from "../npm"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { Reference } from "../reference"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { AgentPlugin } from "./agent"
|
||||
import { CommandPlugin } from "./command"
|
||||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
import { SkillPlugin } from "./skill"
|
||||
|
||||
export type Requirements =
|
||||
| AgentV2.Service
|
||||
| Catalog.Service
|
||||
| CommandV2.Service
|
||||
| Config.Service
|
||||
| EventV2.Service
|
||||
| FileSystem.Service
|
||||
| FSUtil.Service
|
||||
| Global.Service
|
||||
| Integration.Service
|
||||
| Location.Service
|
||||
| ModelsDev.Service
|
||||
| Npm.Service
|
||||
| Reference.Service
|
||||
| SkillV2.Service
|
||||
|
||||
export interface Plugin<R = never> {
|
||||
readonly id: string
|
||||
readonly effect: (context: PluginContext) => Effect.Effect<void, never, R | Scope.Scope>
|
||||
}
|
||||
|
||||
export function define<R>(plugin: Plugin<R>) {
|
||||
return plugin
|
||||
}
|
||||
|
||||
export const locationLayer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const commands = yield* CommandV2.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const integration = yield* Integration.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
const npm = yield* Npm.Service
|
||||
const events = yield* EventV2.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const global = yield* Global.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const reference = yield* Reference.Service
|
||||
const add = <R>(input: Plugin<R>) => {
|
||||
const loaded = {
|
||||
id: input.id,
|
||||
effect: (context: PluginContext) =>
|
||||
input
|
||||
.effect(context)
|
||||
.pipe(
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(CommandV2.Service, commands),
|
||||
Effect.provideService(Integration.Service, integration),
|
||||
Effect.provideService(AgentV2.Service, agents),
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(Location.Service, location),
|
||||
Effect.provideService(ModelsDev.Service, modelsDev),
|
||||
Effect.provideService(Npm.Service, npm),
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provideService(FSUtil.Service, fs),
|
||||
Effect.provideService(FileSystem.Service, filesystem),
|
||||
Effect.provideService(Global.Service, global),
|
||||
Effect.provideService(SkillV2.Service, skill),
|
||||
Effect.provideService(Reference.Service, reference),
|
||||
),
|
||||
}
|
||||
return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect)
|
||||
}
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
yield* add(AgentPlugin.Plugin)
|
||||
yield* add(CommandPlugin.Plugin)
|
||||
yield* add(SkillPlugin.Plugin)
|
||||
yield* add(ModelsDevPlugin)
|
||||
yield* add(ConfigProviderPlugin.Plugin)
|
||||
yield* add(ConfigAgentPlugin.Plugin)
|
||||
yield* add(ConfigCommandPlugin.Plugin)
|
||||
yield* add(ConfigSkillPlugin.Plugin)
|
||||
yield* add(ConfigReferencePlugin.Plugin)
|
||||
for (const item of ProviderPlugins) yield* add(item)
|
||||
yield* add(ConfigExternalPlugin.Plugin)
|
||||
}).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
|
||||
}),
|
||||
).pipe(
|
||||
Layer.provideMerge(PluginV2.locationLayer),
|
||||
Layer.provideMerge(Config.locationLayer),
|
||||
Layer.provideMerge(FileSystem.locationLayer),
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "./internal"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { EventV2 } from "../event"
|
||||
import { ModelV2 } from "../model"
|
||||
import { ModelRequest } from "../model-request"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
@@ -52,6 +53,7 @@ export const ModelsDevPlugin = define({
|
||||
id: "models-dev",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* ctx.integration.transform(
|
||||
Effect.fn(function* (integrations) {
|
||||
const data = yield* modelsDev.get()
|
||||
@@ -128,8 +130,8 @@ export const ModelsDevPlugin = define({
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* ctx.event.subscribe("models-dev.refreshed").pipe(
|
||||
Stream.runForEach(() => ctx.integration.rebuild().pipe(Effect.andThen(ctx.catalog.rebuild()))),
|
||||
yield* events.subscribe(ModelsDev.Event.Refreshed).pipe(
|
||||
Stream.runForEach(() => ctx.integration.reload().pipe(Effect.andThen(ctx.catalog.reload()))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
export * as PluginPromise from "./promise"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Plugin, PluginContext, Registration } from "@opencode-ai/plugin/v2/promise"
|
||||
import { Effect, Scope } from "effect"
|
||||
|
||||
// The Effect host hands back this registration shape; mirror it structurally so
|
||||
// we do not have to alias the Effect package's `Registration` against the Promise one.
|
||||
type HostRegistration = { readonly dispose: Effect.Effect<void> }
|
||||
|
||||
/**
|
||||
* Adapts a Promise plugin into an Effect plugin so the existing Effect-only
|
||||
* loader (`PluginV2` / `PluginInternal`) can run it unchanged.
|
||||
*
|
||||
* Hook registrations created during the async `setup` attach to the plugin's
|
||||
* scope, so unloading the plugin disposes them. The captured fiber context
|
||||
* preserves boot-time batching, so Promise-plugin transforms still coalesce
|
||||
* into one reload per domain.
|
||||
*/
|
||||
export function fromPromise(plugin: Plugin) {
|
||||
return define({
|
||||
id: plugin.id,
|
||||
effect: (host) =>
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.Scope
|
||||
const context = yield* Effect.context<Scope.Scope>()
|
||||
|
||||
// Run a hook registration on the plugin scope and resolve once it is registered.
|
||||
const register = (effect: Effect.Effect<HostRegistration, never, Scope.Scope>): Promise<Registration> =>
|
||||
Effect.runPromiseWith(context)(Scope.provide(scope)(effect)).then((registration) => ({
|
||||
dispose: () => Effect.runPromiseWith(context)(registration.dispose),
|
||||
}))
|
||||
|
||||
const run = (effect: Effect.Effect<void>) => Effect.runPromiseWith(context)(effect)
|
||||
|
||||
const transform =
|
||||
<Draft>(domain: {
|
||||
transform: (
|
||||
callback: (draft: Draft) => Effect.Effect<void> | void,
|
||||
) => Effect.Effect<HostRegistration, never, Scope.Scope>
|
||||
}) =>
|
||||
(callback: (draft: Draft) => Promise<void> | void) =>
|
||||
register(domain.transform((draft) => Effect.promise(() => Promise.resolve(callback(draft)))))
|
||||
|
||||
const context2: PluginContext = {
|
||||
options: host.options,
|
||||
agent: {
|
||||
transform: transform(host.agent),
|
||||
reload: () => run(host.agent.reload()),
|
||||
},
|
||||
aisdk: {
|
||||
sdk: (callback) =>
|
||||
register(host.aisdk.sdk((event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
language: (callback) =>
|
||||
register(host.aisdk.language((event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
},
|
||||
catalog: {
|
||||
transform: transform(host.catalog),
|
||||
reload: () => run(host.catalog.reload()),
|
||||
},
|
||||
command: {
|
||||
transform: transform(host.command),
|
||||
reload: () => run(host.command.reload()),
|
||||
},
|
||||
integration: {
|
||||
transform: transform(host.integration),
|
||||
reload: () => run(host.integration.reload()),
|
||||
},
|
||||
plugin: {
|
||||
add: (input) => {
|
||||
const child = fromPromise(input)
|
||||
return run(host.plugin.add(child))
|
||||
},
|
||||
remove: (id) => run(host.plugin.remove(id)),
|
||||
},
|
||||
reference: {
|
||||
transform: transform(host.reference),
|
||||
reload: () => run(host.reference.reload()),
|
||||
},
|
||||
skill: {
|
||||
transform: transform(host.skill),
|
||||
reload: () => run(host.skill.reload()),
|
||||
},
|
||||
}
|
||||
|
||||
yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -30,8 +30,10 @@ import { VercelPlugin } from "./provider/vercel"
|
||||
import { VenicePlugin } from "./provider/venice"
|
||||
import { XAIPlugin } from "./provider/xai"
|
||||
import { ZenmuxPlugin } from "./provider/zenmux"
|
||||
import type { PluginInternal } from "./internal"
|
||||
import type { Scope } from "effect"
|
||||
|
||||
export const ProviderPlugins = [
|
||||
export const ProviderPlugins: PluginInternal.Plugin<PluginInternal.Requirements | Scope.Scope>[] = [
|
||||
AlibabaPlugin,
|
||||
AmazonBedrockPlugin,
|
||||
AnthropicPlugin,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const AlibabaPlugin = define({
|
||||
id: "alibaba",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/alibaba") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba"))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
type MantleSDK = {
|
||||
@@ -78,8 +78,7 @@ export const AmazonBedrockPlugin = define({
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return
|
||||
const options = { ...evt.options }
|
||||
@@ -112,8 +111,7 @@ export const AmazonBedrockPlugin = define({
|
||||
evt.sdk = mod.createAmazonBedrock(options)
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return
|
||||
if (evt.model.api.type === "aisdk" && evt.model.api.package === "@ai-sdk/amazon-bedrock/mantle") {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const AnthropicPlugin = define({
|
||||
id: "anthropic",
|
||||
@@ -16,8 +16,7 @@ export const AnthropicPlugin = define({
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/anthropic") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic"))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
|
||||
@@ -28,8 +28,7 @@ export const AzurePlugin = define({
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/azure") return
|
||||
if (evt.model.providerID === ProviderV2.ID.azure) {
|
||||
@@ -47,8 +46,7 @@ export const AzurePlugin = define({
|
||||
evt.sdk = mod.createAzure(evt.options)
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.azure) return
|
||||
evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls))
|
||||
@@ -74,8 +72,7 @@ export const AzureCognitiveServicesPlugin = define({
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return
|
||||
evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const CerebrasPlugin = define({
|
||||
id: "cerebras",
|
||||
@@ -15,8 +15,7 @@ export const CerebrasPlugin = define({
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/cerebras") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras"))
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import os from "os"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const CloudflareAIGatewayPlugin = define({
|
||||
id: "cloudflare-ai-gateway",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "ai-gateway-provider") return
|
||||
if (evt.options.baseURL) return
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import os from "os"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
const providerID = ProviderV2.ID.make("cloudflare-workers-ai")
|
||||
@@ -21,8 +21,7 @@ export const CloudflareWorkersAIPlugin = define({
|
||||
})
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== providerID) return
|
||||
if (evt.package !== "@ai-sdk/openai-compatible") return
|
||||
@@ -38,8 +37,7 @@ export const CloudflareWorkersAIPlugin = define({
|
||||
)
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== providerID) return
|
||||
evt.language = evt.sdk.languageModel(evt.model.api.id)
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const CoherePlugin = define({
|
||||
id: "cohere",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/cohere") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/cohere"))
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const DeepInfraPlugin = define({
|
||||
id: "deepinfra",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/deepinfra") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra"))
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { Effect } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
import { Npm } from "../../npm"
|
||||
|
||||
export const DynamicProviderPlugin = define({
|
||||
id: "dynamic-provider",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
const npm = yield* Npm.Service
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.sdk) return
|
||||
|
||||
const installedPath = evt.package.startsWith("file://")
|
||||
? evt.package
|
||||
: (yield* ctx.npm.add(evt.package).pipe(Effect.orDie)).entrypoint
|
||||
: (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint
|
||||
if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`)
|
||||
|
||||
const mod = yield* Effect.promise(async () => {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const GatewayPlugin = define({
|
||||
id: "gateway",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/gateway") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/gateway"))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
function shouldUseResponses(modelID: string) {
|
||||
@@ -25,16 +25,14 @@ export const GithubCopilotPlugin = define({
|
||||
})
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/github-copilot") return
|
||||
const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider"))
|
||||
evt.sdk = mod.createOpenaiCompatible(evt.options)
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return
|
||||
if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) {
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import os from "os"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const GitLabPlugin = define({
|
||||
id: "gitlab",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "gitlab-ai-provider") return
|
||||
const mod = yield* Effect.promise(() => import("gitlab-ai-provider"))
|
||||
@@ -32,8 +31,7 @@ export const GitLabPlugin = define({
|
||||
})
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.gitlab) return
|
||||
const featureFlags =
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
function resolveProject(options: Record<string, any>) {
|
||||
@@ -84,8 +84,7 @@ export const GoogleVertexPlugin = define({
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) {
|
||||
evt.options.fetch = authFetch(evt.options.fetch)
|
||||
@@ -104,8 +103,7 @@ export const GoogleVertexPlugin = define({
|
||||
})
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.googleVertex) return
|
||||
evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim())
|
||||
@@ -139,8 +137,7 @@ export const GoogleVertexAnthropicPlugin = define({
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/google-vertex/anthropic") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic"))
|
||||
@@ -166,8 +163,7 @@ export const GoogleVertexAnthropicPlugin = define({
|
||||
})
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return
|
||||
evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim())
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const GooglePlugin = define({
|
||||
id: "google",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/google") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/google"))
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const GroqPlugin = define({
|
||||
id: "groq",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/groq") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/groq"))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const KiloPlugin = define({
|
||||
id: "kilo",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
import { Integration } from "../../integration"
|
||||
|
||||
export const LLMGatewayPlugin = define({
|
||||
id: "llmgateway",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
@@ -11,7 +13,7 @@ export const LLMGatewayPlugin = define({
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue
|
||||
if (!(yield* ctx.integration.get(item.provider.id))) continue
|
||||
if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
provider.request.headers["X-Title"] = "opencode"
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const MistralPlugin = define({
|
||||
id: "mistral",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/mistral") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/mistral"))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const NvidiaPlugin = define({
|
||||
id: "nvidia",
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const OpenAICompatiblePlugin = define({
|
||||
id: "openai-compatible",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.sdk) return
|
||||
if (!evt.package.includes("@ai-sdk/openai-compatible")) return
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import { Integration } from "../../integration"
|
||||
import { browser, headless } from "./openai-auth"
|
||||
@@ -27,16 +27,14 @@ export const OpenAIPlugin = define({
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/openai") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/openai"))
|
||||
evt.sdk = mod.createOpenAI(evt.options)
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.openai) return
|
||||
evt.language = evt.sdk.responses(evt.model.api.id)
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import { Integration } from "../../integration"
|
||||
|
||||
export const OpencodePlugin = define({
|
||||
id: "opencode",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const integrations = yield* Integration.Service
|
||||
let hasKey = false
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
const item = evt.provider.get(ProviderV2.ID.opencode)
|
||||
if (!item) return
|
||||
const integration = yield* ctx.integration.get(item.provider.id)
|
||||
const integration = yield* integrations.get(Integration.ID.make(item.provider.id))
|
||||
hasKey = Boolean(
|
||||
process.env.OPENCODE_API_KEY || integration?.connections.length || item.provider.request.body.apiKey,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const OpenRouterPlugin = define({
|
||||
id: "openrouter",
|
||||
@@ -25,8 +25,7 @@ export const OpenRouterPlugin = define({
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@openrouter/ai-sdk-provider") return
|
||||
const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider"))
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const PerplexityPlugin = define({
|
||||
id: "perplexity",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/perplexity") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity"))
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Effect } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
import { Npm } from "../../npm"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const SapAICorePlugin = define({
|
||||
id: "sap-ai-core",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
const npm = yield* Npm.Service
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
|
||||
const serviceKey =
|
||||
@@ -17,7 +18,7 @@ export const SapAICorePlugin = define({
|
||||
|
||||
const installedPath = evt.package.startsWith("file://")
|
||||
? evt.package
|
||||
: (yield* ctx.npm.add(evt.package).pipe(Effect.orDie)).entrypoint
|
||||
: (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint
|
||||
if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`)
|
||||
|
||||
const mod = yield* Effect.promise(async () => {
|
||||
@@ -35,8 +36,7 @@ export const SapAICorePlugin = define({
|
||||
)
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
|
||||
evt.language = evt.sdk(evt.model.api.id)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
|
||||
@@ -67,8 +67,7 @@ export function cortexFetch(upstream: FetchLike = fetch) {
|
||||
export const SnowflakeCortexPlugin = define({
|
||||
id: "snowflake-cortex",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return
|
||||
const token =
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const TogetherAIPlugin = define({
|
||||
id: "togetherai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/togetherai") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai"))
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const VenicePlugin = define({
|
||||
id: "venice",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "venice-ai-sdk-provider") return
|
||||
const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider"))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const VercelPlugin = define({
|
||||
id: "vercel",
|
||||
@@ -16,8 +16,7 @@ export const VercelPlugin = define({
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/vercel") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/vercel"))
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const XAIPlugin = define({
|
||||
id: "xai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/xai") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/xai"))
|
||||
evt.sdk = mod.createXai(evt.options)
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("xai")) return
|
||||
evt.language = evt.sdk.responses(evt.model.api.id)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const ZenmuxPlugin = define({
|
||||
id: "zenmux",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
export * as SkillPlugin from "./skill"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { define } from "./internal"
|
||||
import { Effect } from "effect"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import { SkillV2 } from "../skill"
|
||||
|
||||
@@ -13,7 +13,6 @@ import { Slug } from "../util/slug"
|
||||
import { EventV2 } from "../event"
|
||||
import { Database } from "../database/database"
|
||||
import { Location } from "../location"
|
||||
import { PluginBoot } from "../plugin/boot"
|
||||
|
||||
export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID"))
|
||||
export type StrategyID = typeof StrategyID.Type
|
||||
@@ -125,10 +124,8 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pr
|
||||
|
||||
export const refreshAfterBoot = Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const boot = yield* PluginBoot.Service
|
||||
const copies = yield* Service
|
||||
yield* Effect.gen(function* () {
|
||||
yield* boot.wait()
|
||||
yield* Effect.logInfo("project copy refresh started", { projectID: location.project.id })
|
||||
const result = yield* copies.refresh({ projectID: location.project.id })
|
||||
yield* Effect.logInfo("project copy refresh done", {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
export * as OpenCode from "./opencode"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Catalog } from "../catalog"
|
||||
import { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { LocationServiceMap } from "../location-layer"
|
||||
import { PluginBoot } from "../plugin/boot"
|
||||
import { ProjectV2 } from "../project"
|
||||
import { SessionV2 } from "../session"
|
||||
import * as SessionExecutionLocal from "../session/execution/local"
|
||||
@@ -23,69 +21,22 @@ export interface Interface {
|
||||
/** Intentional public native API for Effect applications embedding OpenCode. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/public/OpenCode") {}
|
||||
|
||||
class SessionModelValidation extends Context.Service<
|
||||
SessionModelValidation,
|
||||
{
|
||||
readonly validate: (
|
||||
input: Session.SwitchModelInput & { readonly location: Session.Info["location"] },
|
||||
) => Effect.Effect<void, Session.ModelUnavailableError | Session.VariantUnavailableError>
|
||||
}
|
||||
>()("@opencode/public/OpenCode/SessionModelValidation") {}
|
||||
|
||||
const ApplicationToolsLayer = ApplicationTools.layer
|
||||
const LocationServicesLayer = LocationServiceMap.layer.pipe(Layer.provide(ApplicationToolsLayer))
|
||||
const SessionModelValidationLayer = Layer.effect(
|
||||
SessionModelValidation,
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap
|
||||
return SessionModelValidation.of({
|
||||
validate: Effect.fn("OpenCode.sessions.validateModel")(function* (input) {
|
||||
yield* Effect.gen(function* () {
|
||||
yield* (yield* PluginBoot.Service).wait()
|
||||
const catalog = yield* Catalog.Service
|
||||
const model = (yield* catalog.model.available()).find(
|
||||
(model) => model.providerID === input.model.providerID && model.id === input.model.id,
|
||||
)
|
||||
if (!model)
|
||||
return yield* new Session.ModelUnavailableError({
|
||||
providerID: input.model.providerID,
|
||||
modelID: input.model.id,
|
||||
})
|
||||
if (
|
||||
input.model.variant !== undefined &&
|
||||
input.model.variant !== "default" &&
|
||||
!model.variants.some((variant) => variant.id === input.model.variant)
|
||||
)
|
||||
return yield* new Session.VariantUnavailableError({
|
||||
providerID: input.model.providerID,
|
||||
modelID: input.model.id,
|
||||
variant: input.model.variant,
|
||||
})
|
||||
}).pipe(Effect.provide(locations.get(input.location)))
|
||||
}),
|
||||
})
|
||||
}),
|
||||
const SessionsLayer = SessionV2.layer.pipe(
|
||||
Layer.provide(SessionProjector.layer),
|
||||
Layer.provide(SessionExecutionLocal.layer),
|
||||
Layer.provide(SessionStore.layer),
|
||||
Layer.provide(EventV2.layer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(LocationServiceMap.layer.pipe(Layer.provide(ApplicationTools.layer))),
|
||||
Layer.orDie,
|
||||
)
|
||||
|
||||
const SessionsLayer = Layer.merge(
|
||||
SessionV2.layer.pipe(
|
||||
Layer.provide(SessionProjector.layer),
|
||||
Layer.provide(SessionExecutionLocal.layer),
|
||||
Layer.provide(SessionStore.layer),
|
||||
Layer.provide(EventV2.layer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.orDie,
|
||||
),
|
||||
SessionModelValidationLayer,
|
||||
).pipe(Layer.provide(LocationServicesLayer))
|
||||
// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* SessionV2.Service
|
||||
const tools = yield* ApplicationTools.Service
|
||||
const validation = yield* SessionModelValidation
|
||||
return Service.of({
|
||||
tools: { register: tools.register },
|
||||
sessions: {
|
||||
@@ -98,11 +49,7 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
get: sessions.get,
|
||||
list: sessions.list,
|
||||
switchModel: Effect.fn("OpenCode.sessions.switchModel")(function* (input) {
|
||||
const session = yield* sessions.get(input.sessionID)
|
||||
yield* validation.validate({ ...input, location: session.location })
|
||||
yield* sessions.switchModel(input)
|
||||
}),
|
||||
switchModel: sessions.switchModel,
|
||||
interrupt: sessions.interrupt,
|
||||
prompt: (input) =>
|
||||
sessions.prompt({
|
||||
@@ -124,6 +71,6 @@ export const layer = Layer.effect(
|
||||
},
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(Layer.merge(ApplicationToolsLayer, SessionsLayer)))
|
||||
).pipe(Layer.provide(Layer.merge(ApplicationTools.layer, SessionsLayer)))
|
||||
|
||||
// TODO: Add OpenCode.create(...) as the Promise facade over the same native API semantics.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as Session from "./session"
|
||||
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { ModelV2 } from "../model"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { SessionV2 } from "../session"
|
||||
import { MessageDecodeError } from "../session/error"
|
||||
import { SessionEvent } from "../session/event"
|
||||
@@ -41,23 +40,6 @@ export type NotFoundError = SessionV2.NotFoundError
|
||||
export const PromptConflictError = SessionV2.PromptConflictError
|
||||
export type PromptConflictError = SessionV2.PromptConflictError
|
||||
|
||||
export class ModelUnavailableError extends Schema.TaggedErrorClass<ModelUnavailableError>()(
|
||||
"Session.ModelUnavailableError",
|
||||
{
|
||||
providerID: Model.Ref.fields.providerID,
|
||||
modelID: Model.Ref.fields.id,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
|
||||
"Session.VariantUnavailableError",
|
||||
{
|
||||
providerID: Model.Ref.fields.providerID,
|
||||
modelID: Model.Ref.fields.id,
|
||||
variant: ModelV2.VariantID,
|
||||
},
|
||||
) {}
|
||||
|
||||
export { MessageDecodeError }
|
||||
|
||||
export interface CreateInput {
|
||||
@@ -104,9 +86,7 @@ export interface Interface {
|
||||
readonly get: (sessionID: ID) => Effect.Effect<Info, NotFoundError>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
|
||||
readonly prompt: (input: PromptInput) => Effect.Effect<Admission, NotFoundError | PromptConflictError>
|
||||
readonly switchModel: (
|
||||
input: SwitchModelInput,
|
||||
) => Effect.Effect<void, NotFoundError | ModelUnavailableError | VariantUnavailableError>
|
||||
readonly switchModel: (input: SwitchModelInput) => Effect.Effect<void, NotFoundError>
|
||||
/** Interrupt the active V2 execution chain for one Session on this process. Interrupting an idle or missing Session is a no-op. */
|
||||
readonly interrupt: (sessionID: ID) => Effect.Effect<void>
|
||||
readonly messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
|
||||
|
||||
@@ -126,7 +126,7 @@ export const layer = Layer.effect(
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
rebuild: state.rebuild,
|
||||
reload: state.reload,
|
||||
list: Effect.fn("Reference.list")(function* () {
|
||||
return Array.from(materialized.values())
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as ReferenceGuidance from "./guidance"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { PluginBoot } from "../plugin/boot"
|
||||
import { Reference } from "../reference"
|
||||
import { SystemContext } from "../system-context/index"
|
||||
|
||||
@@ -34,12 +33,10 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const boot = yield* PluginBoot.Service
|
||||
const references = yield* Reference.Service
|
||||
|
||||
return Service.of({
|
||||
load: Effect.fn("ReferenceGuidance.load")(function* () {
|
||||
yield* boot.wait()
|
||||
const available = (yield* references.list())
|
||||
.filter((reference) => reference.description !== undefined)
|
||||
.map((reference) => ({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as SessionV2 from "./session"
|
||||
export * from "./session/schema"
|
||||
|
||||
import { Cause, DateTime, Effect, Layer, Schema, Context, Stream } from "effect"
|
||||
import { DateTime, Effect, Layer, Schema, Context, Stream } from "effect"
|
||||
import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { ProjectV2 } from "./project"
|
||||
import { WorkspaceV2 } from "./workspace"
|
||||
@@ -25,7 +25,6 @@ import { fromRow } from "./session/info"
|
||||
import { SessionRunner } from "./session/runner/index"
|
||||
import { SessionStore } from "./session/store"
|
||||
import { SessionExecution } from "./session/execution"
|
||||
import { logFailure } from "./session/logging"
|
||||
import { MessageDecodeError } from "./session/error"
|
||||
import { SessionEvent } from "./session/event"
|
||||
import { SessionInput } from "./session/input"
|
||||
@@ -126,10 +125,7 @@ export interface Interface {
|
||||
sessionID: SessionSchema.ID
|
||||
after?: number
|
||||
}) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
|
||||
readonly switchAgent: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
agent: string
|
||||
}) => Effect.Effect<void, OperationUnavailableError>
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchModel: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
model: ModelV2.Ref
|
||||
@@ -171,20 +167,6 @@ export const layer = Layer.effect(
|
||||
const store = yield* SessionStore.Service
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
const scope = yield* Effect.scope
|
||||
|
||||
const enqueueWake = (admitted: SessionInput.Admitted) =>
|
||||
execution.wake(admitted.sessionID, admitted.admittedSeq).pipe(
|
||||
Effect.tapCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.void
|
||||
: logFailure("Failed to wake Session", admitted.sessionID, cause),
|
||||
),
|
||||
Effect.ignore,
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
Effect.asVoid,
|
||||
)
|
||||
|
||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
Effect.mapError(
|
||||
@@ -345,10 +327,6 @@ export const layer = Layer.effect(
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
yield* result.get(input.sessionID)
|
||||
const returnPrompt = Effect.fnUntraced(function* (admitted: SessionInput.Admitted) {
|
||||
if (input.resume !== false) yield* enqueueWake(admitted)
|
||||
return admitted
|
||||
}, Effect.uninterruptible)
|
||||
const messageID = input.id ?? SessionMessage.ID.create()
|
||||
const delivery = input.delivery ?? "steer"
|
||||
const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery }
|
||||
@@ -366,7 +344,8 @@ export const layer = Layer.effect(
|
||||
)
|
||||
if (!SessionInput.equivalent(admitted, expected))
|
||||
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||
return yield* returnPrompt(admitted)
|
||||
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
|
||||
return admitted
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -376,8 +355,14 @@ export const layer = Layer.effect(
|
||||
skill: Effect.fn("V2Session.skill")(function* () {
|
||||
return yield* new OperationUnavailableError({ operation: "skill" })
|
||||
}),
|
||||
switchAgent: Effect.fn("V2Session.switchAgent")(function* () {
|
||||
return yield* new OperationUnavailableError({ operation: "switchAgent" })
|
||||
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)
|
||||
@@ -401,19 +386,7 @@ export const layer = Layer.effect(
|
||||
yield* execution.resume(sessionID)
|
||||
}),
|
||||
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* execution.interrupt(sessionID)
|
||||
const event = yield* events.publish(SessionEvent.InterruptRequested, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
})
|
||||
if (event.durable === undefined)
|
||||
return yield* Effect.die("Interrupt request event is missing aggregate sequence")
|
||||
yield* execution.interrupt(sessionID, event.durable.seq)
|
||||
}),
|
||||
),
|
||||
Effect.uninterruptible(execution.interrupt(sessionID)),
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,54 +1,31 @@
|
||||
export * as SessionContextEpoch from "./context-epoch"
|
||||
|
||||
import { and, eq, isNull, lt, or, sql } from "drizzle-orm"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import type { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { Location } from "../location"
|
||||
import { SystemContext } from "../system-context/index"
|
||||
import { ContextSnapshotDecodeError } from "./error"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionHistory } from "./history"
|
||||
import { SessionInput } from "./input"
|
||||
import { SessionMessageID } from "./message-id"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionContextEpochTable, SessionTable } from "./sql"
|
||||
import { SessionContextEpochTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
class RevisionMismatch extends Error {}
|
||||
class LocationMismatch extends Error {}
|
||||
export class AgentMismatch extends Error {}
|
||||
export class AgentReplacementBlocked extends Schema.TaggedErrorClass<AgentReplacementBlocked>()(
|
||||
"SessionContextEpoch.AgentReplacementBlocked",
|
||||
{ sessionID: SessionSchema.ID, previous: AgentV2.ID, current: AgentV2.ID },
|
||||
) {}
|
||||
|
||||
const retryRevisionMismatch = <A, E>(attempt: () => Effect.Effect<A, E>): Effect.Effect<A, E> =>
|
||||
attempt().pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof RevisionMismatch
|
||||
? Effect.yieldNow.pipe(Effect.andThen(retryRevisionMismatch(attempt)))
|
||||
: Effect.die(defect),
|
||||
),
|
||||
)
|
||||
|
||||
interface Prepared {
|
||||
readonly baseline: string
|
||||
readonly baselineSeq: number
|
||||
readonly revision: number
|
||||
}
|
||||
|
||||
export function initialize(
|
||||
db: DatabaseService,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
location: Location.Ref,
|
||||
agent: AgentV2.ID,
|
||||
): Effect.Effect<Prepared | undefined, SystemContext.InitializationBlocked> {
|
||||
return retryRevisionMismatch(() => initializeOnce(db, context, sessionID, location, agent)).pipe(
|
||||
Effect.withSpan("SessionContextEpoch.initialize"),
|
||||
)
|
||||
return initializeOnce(db, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.initialize"))
|
||||
}
|
||||
|
||||
export function prepare(
|
||||
@@ -56,12 +33,8 @@ export function prepare(
|
||||
events: EventV2.Interface,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
location: Location.Ref,
|
||||
agent: AgentV2.ID,
|
||||
): Effect.Effect<Prepared, SystemContext.InitializationBlocked | ContextSnapshotDecodeError | AgentReplacementBlocked> {
|
||||
return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID, location, agent)).pipe(
|
||||
Effect.withSpan("SessionContextEpoch.prepare"),
|
||||
)
|
||||
): Effect.Effect<Prepared, SystemContext.InitializationBlocked | ContextSnapshotDecodeError> {
|
||||
return prepareOnce(db, events, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.prepare"))
|
||||
}
|
||||
|
||||
const prepareOnce = Effect.fnUntraced(function* (
|
||||
@@ -69,57 +42,50 @@ const prepareOnce = Effect.fnUntraced(function* (
|
||||
events: EventV2.Interface,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
location: Location.Ref,
|
||||
agent: AgentV2.ID,
|
||||
) {
|
||||
const [value, stored] = yield* Effect.all([context, find(db, sessionID)], { concurrency: "unbounded" })
|
||||
const [value, stored, compaction] = yield* Effect.all(
|
||||
[context, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
if (!stored) {
|
||||
const generation = yield* SystemContext.initialize(value)
|
||||
const baselineSeq = yield* insert(db, sessionID, location, agent, generation)
|
||||
return { baseline: generation.baseline, baselineSeq, revision: 0 }
|
||||
const baselineSeq = yield* insert(db, sessionID, generation)
|
||||
return { baseline: generation.baseline, baselineSeq }
|
||||
}
|
||||
|
||||
const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe(
|
||||
Effect.mapError((error) => new ContextSnapshotDecodeError({ sessionID, details: String(error) })),
|
||||
)
|
||||
const replacingAgent = stored.agent !== agent
|
||||
const result =
|
||||
stored.replacement_seq === null && !replacingAgent
|
||||
? yield* SystemContext.reconcile(value, snapshot)
|
||||
: yield* SystemContext.replace(value, snapshot)
|
||||
if (result._tag === "ReplacementBlocked" && replacingAgent) {
|
||||
yield* fence(db, sessionID, agent, stored.revision)
|
||||
return yield* new AgentReplacementBlocked({ sessionID, previous: stored.agent, current: agent })
|
||||
}
|
||||
const replacementSeq = compaction !== undefined && compaction.seq > stored.baseline_seq ? compaction.seq : undefined
|
||||
const result = replacementSeq
|
||||
? yield* SystemContext.replace(value, snapshot)
|
||||
: yield* SystemContext.reconcile(value, snapshot)
|
||||
if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") {
|
||||
yield* fence(db, sessionID, agent, stored.revision)
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq, revision: stored.revision }
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||
}
|
||||
if (result._tag === "ReplacementReady") {
|
||||
const replacementSeq = stored.replacement_seq ?? (yield* SessionInput.latestSeq(db, sessionID))
|
||||
yield* replace(db, sessionID, agent, stored.revision, replacementSeq, result.generation)
|
||||
return { baseline: result.generation.baseline, baselineSeq: replacementSeq, revision: stored.revision + 1 }
|
||||
const baselineSeq = replacementSeq ?? (yield* EventV2.latestSequence(db, sessionID))
|
||||
yield* replace(db, sessionID, baselineSeq, result.generation)
|
||||
return { baseline: result.generation.baseline, baselineSeq }
|
||||
}
|
||||
|
||||
yield* events.publish(
|
||||
SessionEvent.ContextUpdated,
|
||||
{ sessionID, messageID: SessionMessageID.ID.create(), timestamp: yield* DateTime.now, text: result.text },
|
||||
{ commit: () => advance(db, sessionID, stored.revision, result.snapshot).pipe(Effect.orDie) },
|
||||
{ commit: () => advance(db, sessionID, result.snapshot).pipe(Effect.orDie) },
|
||||
)
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq, revision: stored.revision + 1 }
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||
})
|
||||
|
||||
const initializeOnce = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
location: Location.Ref,
|
||||
agent: AgentV2.ID,
|
||||
) {
|
||||
if (yield* exists(db, sessionID)) return
|
||||
const generation = yield* context.pipe(Effect.flatMap(SystemContext.initialize))
|
||||
const baselineSeq = yield* insert(db, sessionID, location, agent, generation)
|
||||
return { baseline: generation.baseline, baselineSeq, revision: 0 }
|
||||
const baselineSeq = yield* insert(db, sessionID, generation)
|
||||
return { baseline: generation.baseline, baselineSeq }
|
||||
})
|
||||
|
||||
const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
@@ -142,39 +108,6 @@ const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseServic
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const requireAgentSelection = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
agent: AgentV2.ID,
|
||||
) {
|
||||
const selected = yield* db
|
||||
.select({ agent: SessionTable.agent })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!selected || (selected.agent !== null && selected.agent !== agent)) return yield* Effect.die(new AgentMismatch())
|
||||
})
|
||||
|
||||
export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacement")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
seq: number,
|
||||
) {
|
||||
return yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.set({ replacement_seq: seq, revision: sql`${SessionContextEpochTable.revision} + 1` })
|
||||
.where(
|
||||
and(
|
||||
eq(SessionContextEpochTable.session_id, sessionID),
|
||||
lt(SessionContextEpochTable.baseline_seq, seq),
|
||||
or(isNull(SessionContextEpochTable.replacement_seq), lt(SessionContextEpochTable.replacement_seq, seq)),
|
||||
),
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export const reset = Effect.fn("SessionContextEpoch.reset")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
@@ -189,155 +122,53 @@ export const reset = Effect.fn("SessionContextEpoch.reset")(function* (
|
||||
const insert = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
location: Location.Ref,
|
||||
agent: AgentV2.ID,
|
||||
generation: SystemContext.Generation,
|
||||
) {
|
||||
return yield* db
|
||||
.transaction(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const placed = yield* db
|
||||
.select({ agent: SessionTable.agent })
|
||||
.from(SessionTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionTable.id, sessionID),
|
||||
eq(SessionTable.directory, location.directory),
|
||||
location.workspaceID === undefined
|
||||
? isNull(SessionTable.workspace_id)
|
||||
: eq(SessionTable.workspace_id, location.workspaceID),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!placed) return yield* Effect.die(new LocationMismatch())
|
||||
if (placed.agent !== null && placed.agent !== agent) return yield* Effect.die(new AgentMismatch())
|
||||
const baselineSeq = yield* SessionInput.latestSeq(db, sessionID)
|
||||
yield* db
|
||||
.insert(SessionContextEpochTable)
|
||||
.values({
|
||||
session_id: sessionID,
|
||||
baseline: generation.baseline,
|
||||
agent,
|
||||
snapshot: generation.snapshot,
|
||||
baseline_seq: baselineSeq,
|
||||
revision: 0,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ sessionID: SessionContextEpochTable.session_id })
|
||||
.get()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.flatMap((inserted) => (inserted ? Effect.void : Effect.die(new RevisionMismatch()))),
|
||||
)
|
||||
return baselineSeq
|
||||
}),
|
||||
{ behavior: "immediate" },
|
||||
)
|
||||
const baselineSeq = yield* EventV2.latestSequence(db, sessionID)
|
||||
yield* db
|
||||
.insert(SessionContextEpochTable)
|
||||
.values({
|
||||
session_id: sessionID,
|
||||
baseline: generation.baseline,
|
||||
snapshot: generation.snapshot,
|
||||
baseline_seq: baselineSeq,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return baselineSeq
|
||||
})
|
||||
|
||||
const replace = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
agent: AgentV2.ID,
|
||||
expectedRevision: number,
|
||||
baselineSeq: number,
|
||||
generation: SystemContext.Generation,
|
||||
) {
|
||||
yield* db
|
||||
.transaction(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* requireAgentSelection(db, sessionID, agent)
|
||||
const updated = yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.set({
|
||||
baseline: generation.baseline,
|
||||
agent,
|
||||
snapshot: generation.snapshot,
|
||||
baseline_seq: baselineSeq,
|
||||
replacement_seq: null,
|
||||
revision: expectedRevision + 1,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(SessionContextEpochTable.session_id, sessionID),
|
||||
eq(SessionContextEpochTable.revision, expectedRevision),
|
||||
),
|
||||
)
|
||||
.returning({ revision: SessionContextEpochTable.revision })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die(new RevisionMismatch())
|
||||
}),
|
||||
{ behavior: "immediate" },
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const fence = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
agent: AgentV2.ID,
|
||||
expectedRevision: number,
|
||||
) {
|
||||
const current = yield* db
|
||||
.select({ selected: SessionTable.agent, revision: SessionContextEpochTable.revision })
|
||||
.from(SessionContextEpochTable)
|
||||
.innerJoin(SessionTable, eq(SessionTable.id, SessionContextEpochTable.session_id))
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!current || (current.selected !== null && current.selected !== agent))
|
||||
return yield* Effect.die(new AgentMismatch())
|
||||
if (current.revision !== expectedRevision) return yield* Effect.die(new RevisionMismatch())
|
||||
})
|
||||
|
||||
export const current = Effect.fn("SessionContextEpoch.current")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
agent: AgentV2.ID,
|
||||
revision: number,
|
||||
) {
|
||||
const value = yield* db
|
||||
.select({
|
||||
agent: SessionContextEpochTable.agent,
|
||||
selected: SessionTable.agent,
|
||||
revision: SessionContextEpochTable.revision,
|
||||
const updated = yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.set({
|
||||
baseline: generation.baseline,
|
||||
snapshot: generation.snapshot,
|
||||
baseline_seq: baselineSeq,
|
||||
})
|
||||
.from(SessionContextEpochTable)
|
||||
.innerJoin(SessionTable, eq(SessionTable.id, SessionContextEpochTable.session_id))
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.returning({ sessionID: SessionContextEpochTable.session_id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return (
|
||||
value !== undefined &&
|
||||
value.agent === agent &&
|
||||
(value.selected === null || value.selected === agent) &&
|
||||
value.revision === revision
|
||||
)
|
||||
if (!updated) return yield* Effect.die("Context Epoch not found")
|
||||
})
|
||||
|
||||
const advance = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
expectedRevision: number,
|
||||
snapshot: SystemContext.Snapshot,
|
||||
) {
|
||||
const updated = yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.set({ snapshot, revision: expectedRevision + 1 })
|
||||
.where(
|
||||
and(
|
||||
eq(SessionContextEpochTable.session_id, sessionID),
|
||||
eq(SessionContextEpochTable.revision, expectedRevision),
|
||||
isNull(SessionContextEpochTable.replacement_seq),
|
||||
),
|
||||
)
|
||||
.returning({ revision: SessionContextEpochTable.revision })
|
||||
.set({ snapshot })
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.returning({ sessionID: SessionContextEpochTable.session_id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die(new RevisionMismatch())
|
||||
if (!updated) return yield* Effect.die("Context Epoch not found")
|
||||
})
|
||||
|
||||
@@ -25,6 +25,12 @@ const Base = {
|
||||
timestamp: V2Schema.DateTimeUtcFromMillis,
|
||||
sessionID: SessionSchema.ID,
|
||||
}
|
||||
const PromptFields = {
|
||||
...Base,
|
||||
messageID: SessionMessageID.ID,
|
||||
prompt: Prompt,
|
||||
delivery: Schema.Literals(["steer", "queue"]),
|
||||
}
|
||||
|
||||
const options = {
|
||||
durable: {
|
||||
@@ -83,47 +89,16 @@ export type Moved = typeof Moved.Type
|
||||
export const Prompted = EventV2.define({
|
||||
type: "session.next.prompted",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
messageID: SessionMessageID.ID,
|
||||
prompt: Prompt,
|
||||
delivery: Schema.Literals(["steer", "queue"]),
|
||||
},
|
||||
schema: PromptFields,
|
||||
})
|
||||
export type Prompted = typeof Prompted.Type
|
||||
|
||||
export namespace PromptLifecycle {
|
||||
export const Admitted = EventV2.define({
|
||||
type: "session.next.prompt.admitted",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
messageID: SessionMessageID.ID,
|
||||
prompt: Prompt,
|
||||
delivery: Schema.Literals(["steer", "queue"]),
|
||||
},
|
||||
})
|
||||
export type Admitted = typeof Admitted.Type
|
||||
|
||||
export const Promoted = EventV2.define({
|
||||
type: "session.next.prompt.promoted",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
messageID: SessionMessageID.ID,
|
||||
prompt: Prompt,
|
||||
timeCreated: V2Schema.DateTimeUtcFromMillis,
|
||||
},
|
||||
})
|
||||
export type Promoted = typeof Promoted.Type
|
||||
}
|
||||
|
||||
export const InterruptRequested = EventV2.define({
|
||||
type: "session.next.interrupt.requested",
|
||||
export const PromptAdmitted = EventV2.define({
|
||||
type: "session.next.prompt.admitted",
|
||||
...options,
|
||||
schema: Base,
|
||||
schema: PromptFields,
|
||||
})
|
||||
export type InterruptRequested = typeof InterruptRequested.Type
|
||||
export type PromptAdmitted = typeof PromptAdmitted.Type
|
||||
|
||||
export const ContextUpdated = EventV2.define({
|
||||
type: "session.next.context.updated",
|
||||
@@ -443,20 +418,9 @@ export namespace Compaction {
|
||||
})
|
||||
export type Delta = typeof Delta.Type
|
||||
|
||||
// Retain the unpublished v1 decoder so stored beta events remain replayable.
|
||||
export const EndedV1 = EventV2.define({
|
||||
type: "session.next.compaction.ended",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
text: Schema.String,
|
||||
include: Schema.String.pipe(Schema.optional),
|
||||
},
|
||||
})
|
||||
|
||||
export const Ended = EventV2.define({
|
||||
type: "session.next.compaction.ended",
|
||||
durable: { aggregate: "sessionID", version: 2 },
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
messageID: SessionMessageID.ID,
|
||||
@@ -473,9 +437,7 @@ const DurableDefinitions = [
|
||||
ModelSwitched,
|
||||
Moved,
|
||||
Prompted,
|
||||
PromptLifecycle.Admitted,
|
||||
PromptLifecycle.Promoted,
|
||||
InterruptRequested,
|
||||
PromptAdmitted,
|
||||
ContextUpdated,
|
||||
Synthetic,
|
||||
Shell.Started,
|
||||
|
||||
@@ -5,12 +5,12 @@ import { SessionRunner } from "./runner/index"
|
||||
import { SessionSchema } from "./schema"
|
||||
|
||||
export interface Interface {
|
||||
/** Explicitly drain one Session, making at least one provider attempt. */
|
||||
/** Starts execution while idle or joins the active execution. */
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
|
||||
/** Schedule a drain after durable work is recorded. Repeated wakeups may coalesce. */
|
||||
readonly wake: (sessionID: SessionSchema.ID, seq?: number) => Effect.Effect<void, SessionRunner.RunError>
|
||||
/** Registers newly recorded work. Repeated wakeups may coalesce. */
|
||||
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
|
||||
readonly interrupt: (sessionID: SessionSchema.ID, seq?: number) => Effect.Effect<void>
|
||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Cause, Effect, Layer } from "effect"
|
||||
import { LocationServiceMap } from "../../location-layer"
|
||||
import { SessionRunCoordinator } from "../run-coordinator"
|
||||
import { SessionRunner } from "../runner"
|
||||
import { SessionSchema } from "../schema"
|
||||
import { SessionStore } from "../store"
|
||||
import { SessionExecution } from "../execution"
|
||||
import { logFailure } from "../logging"
|
||||
|
||||
/** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
|
||||
export const layer = Layer.effect(
|
||||
@@ -13,15 +12,19 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, void, SessionRunner.RunError>({
|
||||
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, mode) {
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
|
||||
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
|
||||
return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force: mode === "run" })).pipe(
|
||||
return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force })).pipe(
|
||||
Effect.provide(locations.get(session.location)),
|
||||
Effect.tapCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.void
|
||||
: Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
|
||||
),
|
||||
)
|
||||
}),
|
||||
onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause),
|
||||
})
|
||||
|
||||
return SessionExecution.Service.of({
|
||||
|
||||
@@ -10,9 +10,9 @@ type DatabaseService = Database.Interface["db"]
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
|
||||
const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return yield* db
|
||||
.select()
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
|
||||
@@ -4,7 +4,6 @@ import { and, asc, eq, isNull, lte } from "drizzle-orm"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import type { Database } from "../database/database"
|
||||
import type { EventV2 } from "../event"
|
||||
import { EventSequenceTable } from "../event/sql"
|
||||
import { NonNegativeInt } from "../schema"
|
||||
import { V2Schema } from "../v2-schema"
|
||||
import { SessionEvent } from "./event"
|
||||
@@ -65,7 +64,7 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
|
||||
if (existing !== undefined) return existing
|
||||
const timestamp = yield* DateTime.now
|
||||
return yield* events
|
||||
.publish(SessionEvent.PromptLifecycle.Admitted, {
|
||||
.publish(SessionEvent.PromptAdmitted, {
|
||||
messageID: input.id,
|
||||
sessionID: input.sessionID,
|
||||
timestamp,
|
||||
@@ -93,19 +92,6 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
|
||||
)
|
||||
})
|
||||
|
||||
export const latestSeq = Effect.fn("SessionInput.latestSeq")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const row = yield* db
|
||||
.select({ seq: EventSequenceTable.seq })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row?.seq ?? -1
|
||||
})
|
||||
|
||||
export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(function* (
|
||||
db: DatabaseService,
|
||||
input: {
|
||||
@@ -117,6 +103,13 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio
|
||||
readonly timeCreated: DateTime.Utc
|
||||
},
|
||||
) {
|
||||
const message = yield* db
|
||||
.select({ id: SessionMessageTable.id })
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, input.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
const stored = yield* db
|
||||
.insert(SessionInputTable)
|
||||
.values({
|
||||
@@ -134,12 +127,13 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio
|
||||
if (!stored) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
})
|
||||
|
||||
export const projectPromoted = Effect.fn("SessionInput.projectPromoted")(function* (
|
||||
export const projectPrompted = Effect.fn("SessionInput.projectPrompted")(function* (
|
||||
db: DatabaseService,
|
||||
input: {
|
||||
readonly id: SessionMessage.ID
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly prompt: Prompt
|
||||
readonly delivery: Delivery
|
||||
readonly timeCreated: DateTime.Utc
|
||||
readonly promotedSeq: number
|
||||
},
|
||||
@@ -157,14 +151,32 @@ export const projectPromoted = Effect.fn("SessionInput.projectPromoted")(functio
|
||||
.returning()
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
const stored = fromRow(updated)
|
||||
if (
|
||||
!matchesPrompt(stored, input) ||
|
||||
DateTime.toEpochMillis(stored.timeCreated) !== DateTime.toEpochMillis(input.timeCreated)
|
||||
)
|
||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
return toMessage(stored)
|
||||
if (updated) {
|
||||
const stored = fromRow(updated)
|
||||
if (!matchesProjection(stored, input)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
return
|
||||
}
|
||||
|
||||
const stored = yield* find(db, input.id)
|
||||
if (stored) {
|
||||
if (!matchesProjection(stored, input) || stored.promotedSeq !== input.promotedSeq)
|
||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
return
|
||||
}
|
||||
|
||||
yield* db
|
||||
.insert(SessionInputTable)
|
||||
.values({
|
||||
id: input.id,
|
||||
session_id: input.sessionID,
|
||||
prompt: encodePrompt(input.prompt),
|
||||
delivery: input.delivery,
|
||||
admitted_seq: input.promotedSeq,
|
||||
promoted_seq: input.promotedSeq,
|
||||
time_created: DateTime.toEpochMillis(input.timeCreated),
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
|
||||
@@ -201,35 +213,17 @@ const matchesPrompt = (input: Admitted, expected: { readonly sessionID: SessionS
|
||||
input.sessionID === expected.sessionID &&
|
||||
JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt))
|
||||
|
||||
export const projectLegacyPrompted = Effect.fn("SessionInput.projectLegacyPrompted")(function* (
|
||||
db: DatabaseService,
|
||||
input: {
|
||||
readonly id: SessionMessage.ID
|
||||
const matchesProjection = (
|
||||
input: Admitted,
|
||||
expected: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly prompt: Prompt
|
||||
readonly delivery: Delivery
|
||||
readonly timeCreated: DateTime.Utc
|
||||
readonly promotedSeq: number
|
||||
},
|
||||
) {
|
||||
const inserted = yield* db
|
||||
.insert(SessionInputTable)
|
||||
.values({
|
||||
id: input.id,
|
||||
session_id: input.sessionID,
|
||||
admitted_seq: input.promotedSeq,
|
||||
prompt: encodePrompt(input.prompt),
|
||||
delivery: input.delivery,
|
||||
promoted_seq: input.promotedSeq,
|
||||
time_created: DateTime.toEpochMillis(input.timeCreated),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!inserted) return yield* Effect.die("Prompt projection conflicts with admitted input")
|
||||
return fromRow(inserted)
|
||||
})
|
||||
) =>
|
||||
equivalent(input, expected) &&
|
||||
DateTime.toEpochMillis(input.timeCreated) === DateTime.toEpochMillis(expected.timeCreated)
|
||||
|
||||
const publish = Effect.fn("SessionInput.publish")(function* (
|
||||
db: DatabaseService,
|
||||
@@ -238,18 +232,19 @@ const publish = Effect.fn("SessionInput.publish")(function* (
|
||||
rows: ReadonlyArray<typeof SessionInputTable.$inferSelect>,
|
||||
) {
|
||||
for (const row of rows) {
|
||||
const id = SessionMessage.ID.make(row.id)
|
||||
yield* events
|
||||
.publish(SessionEvent.PromptLifecycle.Promoted, {
|
||||
.publish(SessionEvent.Prompted, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
messageID: SessionMessage.ID.make(row.id),
|
||||
timestamp: DateTime.makeUnsafe(row.time_created),
|
||||
messageID: id,
|
||||
prompt: decodePrompt(row.prompt),
|
||||
timeCreated: DateTime.makeUnsafe(row.time_created),
|
||||
delivery: row.delivery,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof LifecycleConflict
|
||||
? find(db, SessionMessage.ID.make(row.id)).pipe(
|
||||
? find(db, id).pipe(
|
||||
Effect.flatMap((stored) => (stored?.promotedSeq === undefined ? Effect.die(defect) : Effect.void)),
|
||||
)
|
||||
: Effect.die(defect),
|
||||
@@ -303,13 +298,3 @@ export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(fun
|
||||
.pipe(Effect.orDie)
|
||||
return row === undefined ? false : yield* publish(db, events, sessionID, [row]).pipe(Effect.as(true))
|
||||
})
|
||||
|
||||
const toMessage = (input: Admitted) =>
|
||||
new SessionMessage.User({
|
||||
id: input.id,
|
||||
type: "user",
|
||||
text: input.prompt.text,
|
||||
files: input.prompt.files,
|
||||
agents: input.prompt.agents,
|
||||
time: { created: input.timeCreated },
|
||||
})
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Cause, Effect } from "effect"
|
||||
import { SessionSchema } from "./schema"
|
||||
|
||||
export const logFailure = (
|
||||
message: "Failed to drain Session" | "Failed to wake Session",
|
||||
sessionID: SessionSchema.ID,
|
||||
cause: Cause.Cause<unknown>,
|
||||
) => Effect.logError(message, cause).pipe(Effect.annotateLogs({ sessionID }))
|
||||
@@ -137,8 +137,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
)
|
||||
},
|
||||
"session.next.prompt.admitted": () => Effect.void,
|
||||
"session.next.prompt.promoted": () => Effect.void,
|
||||
"session.next.interrupt.requested": () => Effect.void,
|
||||
"session.next.context.updated": (event) =>
|
||||
adapter.appendMessage(
|
||||
new SessionMessage.System({
|
||||
|
||||
@@ -21,7 +21,6 @@ type DatabaseService = Database.Interface["db"]
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
|
||||
|
||||
class PromptAlreadyProjected extends Error {}
|
||||
export class SessionAlreadyProjected extends Error {}
|
||||
|
||||
type Usage = {
|
||||
@@ -329,19 +328,14 @@ export const layer = Layer.effectDiscard(
|
||||
if (next) yield* applyUsage(db, sessionID, next)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.AgentSwitched, (event) => {
|
||||
if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence")
|
||||
return db
|
||||
yield* events.project(SessionEvent.AgentSwitched, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.andThen(run(db, event)),
|
||||
Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.durable.seq)),
|
||||
)
|
||||
})
|
||||
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
|
||||
)
|
||||
yield* events.project(SessionEvent.ModelSwitched, (event) =>
|
||||
Effect.gen(function* () {
|
||||
yield* db
|
||||
@@ -351,33 +345,23 @@ export const layer = Layer.effectDiscard(
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* run(db, event)
|
||||
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
|
||||
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.durable.seq)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.Prompted, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const messageID = event.data.messageID
|
||||
const existing = yield* db
|
||||
.select({ id: SessionMessageTable.id })
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, messageID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (existing) return yield* Effect.die(new PromptAlreadyProjected())
|
||||
yield* run(db, event)
|
||||
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
|
||||
yield* SessionInput.projectLegacyPrompted(db, {
|
||||
id: messageID,
|
||||
yield* SessionInput.projectPrompted(db, {
|
||||
id: event.data.messageID,
|
||||
sessionID: event.data.sessionID,
|
||||
prompt: event.data.prompt,
|
||||
delivery: event.data.delivery,
|
||||
timeCreated: event.data.timestamp,
|
||||
promotedSeq: event.durable.seq,
|
||||
})
|
||||
yield* run(db, event)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.PromptLifecycle.Admitted, (event) =>
|
||||
yield* events.project(SessionEvent.PromptAdmitted, (event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
|
||||
yield* SessionInput.projectAdmitted(db, {
|
||||
@@ -390,24 +374,6 @@ export const layer = Layer.effectDiscard(
|
||||
})
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.PromptLifecycle.Promoted, (event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
|
||||
yield* insertMessage(
|
||||
db,
|
||||
event,
|
||||
yield* SessionInput.projectPromoted(db, {
|
||||
id: event.data.messageID,
|
||||
sessionID: event.data.sessionID,
|
||||
prompt: event.data.prompt,
|
||||
timeCreated: event.data.timeCreated,
|
||||
promotedSeq: event.durable.seq,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.InterruptRequested, () => Effect.void)
|
||||
// TODO: Reconstruct context epoch replacement state during replay without adding replay state to every EventV2 payload.
|
||||
yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
|
||||
@@ -426,15 +392,7 @@ export const layer = Layer.effectDiscard(
|
||||
yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
|
||||
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Compaction.Ended, (event) => {
|
||||
if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence")
|
||||
if (event.durable.version === 1) return Effect.void
|
||||
const seq = event.durable.seq
|
||||
return Effect.gen(function* () {
|
||||
yield* run(db, event)
|
||||
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, seq)
|
||||
})
|
||||
})
|
||||
yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event))
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,106 +1,43 @@
|
||||
export * as SessionRunCoordinator from "./run-coordinator"
|
||||
|
||||
import { Cause, Context, Deferred, Effect, Exit, Fiber, FiberSet, Layer, Scope } from "effect"
|
||||
import { SessionRunner } from "./runner"
|
||||
import { logFailure } from "./logging"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
|
||||
|
||||
export type Mode = "run" | "wake"
|
||||
|
||||
/** Why one drain generation should run. Explicit runs dominate advisory wakes when demands coalesce. */
|
||||
type Demand = { readonly _tag: "run" } | { readonly _tag: "wake"; readonly seq?: number }
|
||||
|
||||
/**
|
||||
* Runs at most one drain chain per key while allowing different keys to drain concurrently.
|
||||
*
|
||||
* For each key:
|
||||
*
|
||||
* idle --run/wake--> draining --run/wake--> draining + one coalesced rerun --> idle
|
||||
*
|
||||
* `run` is an explicit drain request. It starts a chain or joins the current chain and
|
||||
* upgrades a pending follow-up so the caller receives explicit-run semantics.
|
||||
*
|
||||
* `wake` reports that durable work may now be available. It starts a chain while idle or
|
||||
* requests one coalesced follow-up while draining. Repeated wakes collapse together.
|
||||
*
|
||||
* `interrupt` stops the current ownership chain. Advisory wakes from before the interrupt
|
||||
* boundary are suppressed; advisory wakes after the boundary run after cleanup.
|
||||
*/
|
||||
export interface Coordinator<Key, A, E> {
|
||||
/** Starts or joins one explicit drain generation. */
|
||||
readonly run: (key: Key) => Effect.Effect<A, E>
|
||||
/** Coalesces one wake-up after durable work is recorded. */
|
||||
readonly wake: (key: Key, seq?: number) => Effect.Effect<void>
|
||||
/** Waits until the current ownership chain settles. */
|
||||
readonly awaitIdle: (key: Key) => Effect.Effect<void, E>
|
||||
/** Interrupts the active ownership chain without automatically draining pending wakes. */
|
||||
readonly interrupt: (key: Key, seq?: number) => Effect.Effect<void>
|
||||
/** Serializes execution for each key while allowing different keys to run concurrently. */
|
||||
export interface Coordinator<Key, E> {
|
||||
/** Starts execution while idle or joins the active execution. */
|
||||
readonly run: (key: Key) => Effect.Effect<void, E>
|
||||
/** Registers one coalesced follow-up after newly recorded work. */
|
||||
readonly wake: (key: Key) => Effect.Effect<void>
|
||||
/** Stops active execution and waits for its cleanup. */
|
||||
readonly interrupt: (key: Key) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
/** One Session's process-local execution lane: one active demand and at most one coalesced follow-up. */
|
||||
type Entry<A, E> = {
|
||||
readonly done: Deferred.Deferred<A, E>
|
||||
readonly settled: Deferred.Deferred<Exit.Exit<A, E>>
|
||||
current: Demand
|
||||
pending?: Demand
|
||||
explicitWaiter?: Deferred.Deferred<A, E>
|
||||
interruptSeq?: number
|
||||
type Entry<E> = {
|
||||
readonly done: Deferred.Deferred<void, E>
|
||||
owner?: Fiber.Fiber<void, never>
|
||||
pendingWake: boolean
|
||||
stopping: boolean
|
||||
}
|
||||
|
||||
/** Combines follow-up demand: runs dominate, while wakes retain the newest durable admission sequence. */
|
||||
const coalesce = (left: Demand | undefined, right: Demand): Demand => {
|
||||
if (left?._tag === "run" || right._tag === "run") return { _tag: "run" }
|
||||
return { _tag: "wake", seq: maxSeq(left?.seq, right.seq) }
|
||||
}
|
||||
|
||||
const maxSeq = (left: number | undefined, right: number | undefined) => {
|
||||
if (left === undefined) return right
|
||||
if (right === undefined) return left
|
||||
return Math.max(left, right)
|
||||
}
|
||||
|
||||
/** Constructs a scoped coordinator. Every in-memory transition is synchronous. */
|
||||
export const make = <Key, A, E>(options: {
|
||||
readonly drain: (key: Key, mode: Mode) => Effect.Effect<A, E>
|
||||
readonly onFailure?: (key: Key, cause: Cause.Cause<E>) => Effect.Effect<void>
|
||||
}): Effect.Effect<Coordinator<Key, A, E>, never, Scope.Scope> =>
|
||||
export const make = <Key, E>(options: {
|
||||
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E>
|
||||
}): Effect.Effect<Coordinator<Key, E>, never, Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const active = new Map<Key, Entry<A, E>>()
|
||||
const interruptSeq = new Map<Key, number>()
|
||||
const report = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
const active = new Map<Key, Entry<E>>()
|
||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
const shutdown = Deferred.makeUnsafe<void>()
|
||||
let closed = false
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
closed = true
|
||||
Deferred.doneUnsafe(shutdown, Effect.void)
|
||||
active.clear()
|
||||
interruptSeq.clear()
|
||||
}),
|
||||
)
|
||||
|
||||
const makeEntry = (current: Demand, explicitWaiter?: Deferred.Deferred<A, E>): Entry<A, E> => ({
|
||||
done: Deferred.makeUnsafe<A, E>(),
|
||||
settled: Deferred.makeUnsafe<Exit.Exit<A, E>>(),
|
||||
current,
|
||||
explicitWaiter,
|
||||
const makeEntry = (): Entry<E> => ({
|
||||
done: Deferred.makeUnsafe<void, E>(),
|
||||
pendingWake: false,
|
||||
stopping: false,
|
||||
})
|
||||
|
||||
const start = (key: Key, entry: Entry<A, E>, demand: Demand, successor = false) => {
|
||||
const start = (key: Key, entry: Entry<E>, force: boolean, successor = false) => {
|
||||
const ready = Deferred.makeUnsafe<void>()
|
||||
const drain = Effect.suspend(() => options.drain(key, demand._tag))
|
||||
// Initial work retains immediate-start behavior but cannot run before ownership is published.
|
||||
// Observer-started successors yield once so synchronous drains cannot recurse on the JS stack.
|
||||
const owner = fork(
|
||||
(successor
|
||||
? Effect.yieldNow.pipe(Effect.andThen(drain))
|
||||
: Deferred.await(ready).pipe(Effect.andThen(drain))
|
||||
).pipe(
|
||||
Effect.onExit((exit) => Effect.sync(() => settle(key, entry, demand, exit))),
|
||||
(successor ? Effect.yieldNow : Deferred.await(ready)).pipe(
|
||||
Effect.andThen(Effect.suspend(() => options.drain(key, force))),
|
||||
Effect.onExit((exit) => Effect.sync(() => settle(key, entry, exit))),
|
||||
Effect.exit,
|
||||
Effect.asVoid,
|
||||
),
|
||||
@@ -109,176 +46,57 @@ export const make = <Key, A, E>(options: {
|
||||
if (!successor) Deferred.doneUnsafe(ready, Effect.void)
|
||||
}
|
||||
|
||||
const settle = (key: Key, entry: Entry<A, E>, demand: Demand, exit: Exit.Exit<A, E>) => {
|
||||
if (closed) {
|
||||
Deferred.doneUnsafe(entry.done, exit)
|
||||
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
|
||||
return
|
||||
}
|
||||
if (demand._tag === "run" && entry.explicitWaiter !== undefined) {
|
||||
Deferred.doneUnsafe(entry.explicitWaiter, exit)
|
||||
entry.explicitWaiter = undefined
|
||||
}
|
||||
if (entry.stopping && demand._tag === "wake" && entry.explicitWaiter !== undefined) {
|
||||
Deferred.doneUnsafe(entry.explicitWaiter, exit)
|
||||
entry.explicitWaiter = undefined
|
||||
}
|
||||
if (active.get(key) !== entry) {
|
||||
Deferred.doneUnsafe(entry.done, exit)
|
||||
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
|
||||
return
|
||||
}
|
||||
if (exit._tag === "Success" && !entry.stopping) {
|
||||
if (entry.pending !== undefined) {
|
||||
const pending = entry.pending
|
||||
entry.pending = undefined
|
||||
entry.current = pending
|
||||
start(key, entry, pending, true)
|
||||
return
|
||||
}
|
||||
active.delete(key)
|
||||
Deferred.doneUnsafe(entry.done, exit)
|
||||
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
|
||||
const settle = (key: Key, entry: Entry<E>, exit: Exit.Exit<void, E>) => {
|
||||
if (Exit.isSuccess(exit) && !entry.stopping && entry.pendingWake) {
|
||||
entry.pendingWake = false
|
||||
start(key, entry, false, true)
|
||||
return
|
||||
}
|
||||
|
||||
const successor = entry.pending !== undefined ? makeEntry(entry.pending, entry.explicitWaiter) : undefined
|
||||
const successor = entry.pendingWake ? makeEntry() : undefined
|
||||
if (successor === undefined) active.delete(key)
|
||||
else active.set(key, successor)
|
||||
if (successor !== undefined) start(key, successor, successor.current, true)
|
||||
Deferred.doneUnsafe(entry.done, exit)
|
||||
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
|
||||
if (
|
||||
exit._tag === "Failure" &&
|
||||
!(entry.stopping && Cause.hasInterruptsOnly(exit.cause)) &&
|
||||
demand._tag === "wake" &&
|
||||
options.onFailure !== undefined
|
||||
) {
|
||||
report(Effect.suspend(() => options.onFailure!(key, exit.cause)))
|
||||
else {
|
||||
active.set(key, successor)
|
||||
start(key, successor, false, true)
|
||||
}
|
||||
Deferred.doneUnsafe(entry.done, exit)
|
||||
}
|
||||
|
||||
const wake = (key: Key, seq?: number) =>
|
||||
Effect.sync(() => {
|
||||
if (closed) return
|
||||
if (!isAfterInterrupt(key, seq)) return
|
||||
const run = (key: Key): Effect.Effect<void, E> =>
|
||||
Effect.uninterruptibleMask((restore) => {
|
||||
const entry = active.get(key)
|
||||
if (entry !== undefined) {
|
||||
if (!acceptsWake(entry, seq)) return
|
||||
entry.pending = coalesce(entry.pending, { _tag: "wake", seq })
|
||||
if (entry.stopping) return restore(Deferred.await(entry.done).pipe(Effect.andThen(run(key))))
|
||||
return restore(Deferred.await(entry.done))
|
||||
}
|
||||
|
||||
const next = makeEntry()
|
||||
active.set(key, next)
|
||||
start(key, next, true)
|
||||
return restore(Deferred.await(next.done))
|
||||
})
|
||||
|
||||
const wake = (key: Key) =>
|
||||
Effect.sync(() => {
|
||||
const entry = active.get(key)
|
||||
if (entry !== undefined) {
|
||||
entry.pendingWake = true
|
||||
return
|
||||
}
|
||||
|
||||
const next = makeEntry({ _tag: "wake", seq })
|
||||
const next = makeEntry()
|
||||
active.set(key, next)
|
||||
start(key, next, next.current)
|
||||
start(key, next, false)
|
||||
})
|
||||
|
||||
const awaitIdle = (key: Key): Effect.Effect<void, E> =>
|
||||
Effect.gen(function* () {
|
||||
let firstFailure: Cause.Cause<E> | undefined
|
||||
while (!closed) {
|
||||
const entry = active.get(key)
|
||||
if (entry === undefined) break
|
||||
const exit = yield* Effect.raceFirst(
|
||||
Deferred.await(entry.settled),
|
||||
Deferred.await(shutdown).pipe(Effect.as(Exit.void)),
|
||||
)
|
||||
if (closed) break
|
||||
if (exit._tag === "Failure" && firstFailure === undefined) firstFailure = exit.cause
|
||||
}
|
||||
if (firstFailure !== undefined) return yield* Effect.failCause(firstFailure)
|
||||
})
|
||||
|
||||
const interrupt = (key: Key, seq?: number): Effect.Effect<void> =>
|
||||
const interrupt = (key: Key): Effect.Effect<void> =>
|
||||
Effect.suspend(() => {
|
||||
const entry = active.get(key)
|
||||
const latest = interruptSeq.get(key)
|
||||
if (seq !== undefined && latest !== undefined && seq <= latest)
|
||||
return entry?.stopping && entry.owner !== undefined ? Fiber.interrupt(entry.owner) : Effect.void
|
||||
if (seq !== undefined) interruptSeq.set(key, seq)
|
||||
if (entry?.owner === undefined) return Effect.void
|
||||
if (
|
||||
seq !== undefined &&
|
||||
entry.current._tag === "wake" &&
|
||||
entry.current.seq !== undefined &&
|
||||
entry.current.seq > seq
|
||||
)
|
||||
return Effect.void
|
||||
if (entry.stopping) {
|
||||
entry.interruptSeq = maxSeq(entry.interruptSeq, seq)
|
||||
suppressPendingAtOrBefore(entry, seq)
|
||||
return Fiber.interrupt(entry.owner)
|
||||
}
|
||||
entry.stopping = true
|
||||
entry.interruptSeq = seq
|
||||
suppressPendingAtOrBefore(entry, seq)
|
||||
entry.pendingWake = false
|
||||
return Fiber.interrupt(entry.owner)
|
||||
})
|
||||
|
||||
return { run, wake, awaitIdle, interrupt }
|
||||
|
||||
function run(key: Key): Effect.Effect<A, E> {
|
||||
return Effect.uninterruptibleMask((restore) => {
|
||||
if (closed) return Effect.interrupt
|
||||
const entry = active.get(key)
|
||||
if (entry !== undefined) {
|
||||
if (entry.stopping) {
|
||||
return restore(Deferred.await(entry.settled).pipe(Effect.andThen(run(key))))
|
||||
}
|
||||
if (entry.current._tag === "wake") {
|
||||
entry.pending = coalesce(entry.pending, { _tag: "run" })
|
||||
entry.explicitWaiter ??= Deferred.makeUnsafe<A, E>()
|
||||
return restore(awaitRun(entry.explicitWaiter))
|
||||
}
|
||||
return restore(awaitRun(entry.done))
|
||||
}
|
||||
|
||||
const next = makeEntry({ _tag: "run" })
|
||||
active.set(key, next)
|
||||
start(key, next, next.current)
|
||||
return restore(awaitRun(next.done))
|
||||
})
|
||||
}
|
||||
|
||||
function awaitRun(done: Deferred.Deferred<A, E>): Effect.Effect<A, E> {
|
||||
return Effect.raceFirst(Deferred.await(done), Deferred.await(shutdown).pipe(Effect.andThen(Effect.interrupt)))
|
||||
}
|
||||
|
||||
function acceptsWake(entry: Entry<A, E>, seq: number | undefined) {
|
||||
return !entry.stopping || (entry.interruptSeq !== undefined && seq !== undefined && seq > entry.interruptSeq)
|
||||
}
|
||||
|
||||
function isAfterInterrupt(key: Key, seq: number | undefined) {
|
||||
const latest = interruptSeq.get(key)
|
||||
return latest === undefined || (seq !== undefined && seq > latest)
|
||||
}
|
||||
|
||||
function suppressPendingAtOrBefore(entry: Entry<A, E>, seq: number | undefined) {
|
||||
if (
|
||||
entry.pending?._tag === "wake" &&
|
||||
seq !== undefined &&
|
||||
entry.pending.seq !== undefined &&
|
||||
entry.pending.seq > seq
|
||||
)
|
||||
return
|
||||
entry.pending = undefined
|
||||
}
|
||||
return { run, wake, interrupt }
|
||||
})
|
||||
|
||||
export interface Interface extends Coordinator<SessionSchema.ID, void, SessionRunner.RunError> {}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRunCoordinator") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
SessionRunner.Service.pipe(
|
||||
Effect.flatMap((runner) =>
|
||||
make<SessionSchema.ID, void, SessionRunner.RunError>({
|
||||
drain: (sessionID, mode) => runner.run({ sessionID, force: mode === "run" }),
|
||||
onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause),
|
||||
}),
|
||||
),
|
||||
Effect.map(Service.of),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -6,7 +6,6 @@ import { SessionSchema } from "../schema"
|
||||
import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
|
||||
import { SessionRunnerModel } from "./model"
|
||||
import type { SystemContext } from "../../system-context/index"
|
||||
import type { SessionContextEpoch } from "../context-epoch"
|
||||
import type { ToolOutputStore } from "../../tool-output-store"
|
||||
|
||||
export type RunError =
|
||||
@@ -15,7 +14,6 @@ export type RunError =
|
||||
| MessageDecodeError
|
||||
| ContextSnapshotDecodeError
|
||||
| SystemContext.InitializationBlocked
|
||||
| SessionContextEpoch.AgentReplacementBlocked
|
||||
| ToolOutputStore.Error
|
||||
|
||||
/** Runs one local continuation from already-recorded Session history. */
|
||||
@@ -23,7 +21,7 @@ export interface Interface {
|
||||
/** Drains eligible durable work. Explicit runs perform one provider attempt even when no work is eligible. */
|
||||
readonly run: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force?: boolean
|
||||
readonly force: boolean
|
||||
}) => Effect.Effect<void, RunError>
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
isContextOverflowFailure,
|
||||
type ProviderErrorEvent,
|
||||
} from "@opencode-ai/llm"
|
||||
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||
import { AgentV2 } from "../../agent"
|
||||
import { Config } from "../../config"
|
||||
import { Database } from "../../database/database"
|
||||
@@ -79,7 +79,7 @@ import { MAX_STEPS_PROMPT } from "./max-steps"
|
||||
* - [ ] Update title, summaries, compaction state, and cleanup in bounded background work.
|
||||
*
|
||||
* Use `llm.stream(request)` for each provider turn. Keep tool execution and continuation here.
|
||||
* Durable activity recovery remains a separate future slice with an explicit retry policy.
|
||||
* Durable continuation recovery remains a separate future slice with an explicit retry policy.
|
||||
*
|
||||
* The current slice loads V2 history, translates it, resolves a model through a core service, and persists one
|
||||
* provider turn. Registry definitions are advertised, local tool calls are settled durably, and an
|
||||
@@ -141,10 +141,10 @@ export const layer = Layer.effect(
|
||||
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
|
||||
|
||||
type TurnTransition =
|
||||
// Request preparation observed a concurrent Session change and must restart from durable state.
|
||||
| { readonly _tag: "RebuildPreparedTurn"; readonly promotion?: SessionInput.Delivery }
|
||||
// Automatic compaction completed; rebuild the request from compacted history.
|
||||
| { readonly _tag: "ContinueAfterCompaction"; readonly step: number }
|
||||
// Overflow compaction completed; rebuild once through the path without overflow recovery.
|
||||
| { readonly _tag: "ContinueAfterOverflowCompaction" }
|
||||
| { readonly _tag: "ContinueAfterOverflowCompaction"; readonly step: number }
|
||||
|
||||
class TurnTransitionError extends Error {
|
||||
constructor(readonly transition: TurnTransition) {
|
||||
@@ -152,20 +152,10 @@ export const layer = Layer.effect(
|
||||
}
|
||||
}
|
||||
|
||||
const rebuildPreparedTurn = (promotion?: SessionInput.Delivery) =>
|
||||
new TurnTransitionError({ _tag: "RebuildPreparedTurn", promotion })
|
||||
const continueAfterOverflowCompaction = new TurnTransitionError({
|
||||
_tag: "ContinueAfterOverflowCompaction",
|
||||
})
|
||||
const continueAfterCompaction = (step: number) => new TurnTransitionError({ _tag: "ContinueAfterCompaction", step })
|
||||
const continueAfterOverflowCompaction = (step: number) =>
|
||||
new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step })
|
||||
|
||||
const retryAgentMismatch = (promotion: SessionInput.Delivery | undefined) =>
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof SessionContextEpoch.AgentMismatch
|
||||
? Effect.die(rebuildPreparedTurn(promotion))
|
||||
: Effect.die(defect),
|
||||
)
|
||||
|
||||
const sameModel = Schema.toEquivalence(Schema.UndefinedOr(ModelV2.Ref))
|
||||
const loadSystemContext = (agent: AgentV2.Selection) =>
|
||||
Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], {
|
||||
concurrency: "unbounded",
|
||||
@@ -181,40 +171,26 @@ export const layer = Layer.effect(
|
||||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||
return yield* Effect.interrupt
|
||||
const agent = yield* agents.select(session.agent)
|
||||
const initialized = yield* SessionContextEpoch.initialize(
|
||||
db,
|
||||
loadSystemContext(agent),
|
||||
session.id,
|
||||
session.location,
|
||||
agent.id,
|
||||
).pipe(retryAgentMismatch(promotion))
|
||||
const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id)
|
||||
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
|
||||
let needsContinuation = false
|
||||
let currentStep = step
|
||||
if (promotion) {
|
||||
const cutoff = yield* SessionInput.latestSeq(db, session.id)
|
||||
if (promotion === "steer") yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
|
||||
const cutoff = yield* EventV2.latestSequence(db, session.id)
|
||||
let promoted = 0
|
||||
if (promotion === "steer") promoted = yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
|
||||
if (promotion === "queue") {
|
||||
yield* SessionInput.promoteNextQueued(db, events, session.id)
|
||||
yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
|
||||
promoted += Number(yield* SessionInput.promoteNextQueued(db, events, session.id))
|
||||
promoted += yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
|
||||
}
|
||||
if (promoted > 0) currentStep = 1
|
||||
}
|
||||
const system =
|
||||
initialized ??
|
||||
(yield* SessionContextEpoch.prepare(
|
||||
db,
|
||||
events,
|
||||
loadSystemContext(agent),
|
||||
session.id,
|
||||
session.location,
|
||||
agent.id,
|
||||
).pipe(retryAgentMismatch(undefined)))
|
||||
const current = yield* getSession(sessionID)
|
||||
if ((yield* agents.select(current.agent)).id !== agent.id || !sameModel(current.model, session.model))
|
||||
return yield* Effect.die(rebuildPreparedTurn())
|
||||
initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id))
|
||||
const model = yield* models.resolve(session)
|
||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
|
||||
const context = entries.map((entry) => entry.message)
|
||||
const isLastStep = agent.info?.steps !== undefined && step >= agent.info.steps
|
||||
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
|
||||
const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions)
|
||||
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
|
||||
const request = LLM.request({
|
||||
@@ -228,7 +204,7 @@ export const layer = Layer.effect(
|
||||
toolChoice: isLastStep ? "none" : undefined,
|
||||
})
|
||||
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
|
||||
return yield* Effect.die(rebuildPreparedTurn())
|
||||
return yield* Effect.die(continueAfterCompaction(currentStep))
|
||||
const publisher = createLLMEventPublisher(events, {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
@@ -242,8 +218,6 @@ export const layer = Layer.effect(
|
||||
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
|
||||
withPublication(publisher.publish(event, outputPaths))
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
if (!(yield* SessionContextEpoch.current(db, session.id, agent.id, system.revision)))
|
||||
return yield* Effect.die(rebuildPreparedTurn())
|
||||
const providerStream = llm.stream(request).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -300,19 +274,12 @@ export const layer = Layer.effect(
|
||||
isContextOverflowFailure(overflowFailure ?? failure) &&
|
||||
(yield* restore(recoverOverflow({ sessionID: session.id, entries, model, request })))
|
||||
)
|
||||
return yield* Effect.die(continueAfterOverflowCompaction)
|
||||
return yield* Effect.die(continueAfterOverflowCompaction(currentStep))
|
||||
if (overflowFailure) yield* publish(overflowFailure)
|
||||
const llmFailure = failure instanceof LLMError ? failure : undefined
|
||||
if (llmFailure && !publisher.hasProviderError()) {
|
||||
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
||||
yield* withPublication(
|
||||
events.publish(SessionEvent.Step.Failed, {
|
||||
sessionID: session.id,
|
||||
timestamp: yield* DateTime.now,
|
||||
assistantMessageID: yield* publisher.startAssistant(),
|
||||
error: { type: "unknown", message: llmFailure.reason.message },
|
||||
}),
|
||||
)
|
||||
yield* withPublication(publisher.failAssistant(llmFailure.reason.message))
|
||||
}
|
||||
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
|
||||
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
|
||||
@@ -327,6 +294,8 @@ export const layer = Layer.effect(
|
||||
) {
|
||||
yield* FiberSet.clear(toolFibers)
|
||||
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
if (publisher.hasActiveAssistant())
|
||||
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
|
||||
}
|
||||
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
|
||||
const failure = Cause.squash(settled.cause)
|
||||
@@ -339,7 +308,7 @@ export const layer = Layer.effect(
|
||||
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
||||
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
||||
if (settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
|
||||
return !publisher.hasProviderError() && needsContinuation
|
||||
return { needsContinuation: !publisher.hasProviderError() && needsContinuation, step: currentStep }
|
||||
}),
|
||||
)
|
||||
}, Effect.scoped)
|
||||
@@ -347,7 +316,7 @@ export const layer = Layer.effect(
|
||||
sessionID: SessionSchema.ID,
|
||||
promotion: SessionInput.Delivery | undefined,
|
||||
step: number,
|
||||
) => Effect.Effect<boolean, RunError>
|
||||
) => Effect.Effect<{ readonly needsContinuation: boolean; readonly step: number }, RunError>
|
||||
|
||||
const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) {
|
||||
return yield* runTurnAttempt(sessionID, promotion, step).pipe(
|
||||
@@ -357,7 +326,7 @@ export const layer = Layer.effect(
|
||||
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
|
||||
return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow")
|
||||
yield* Effect.yieldNow
|
||||
return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion, step)
|
||||
return yield* runAfterOverflowCompaction(sessionID, undefined, defect.transition.step)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -370,8 +339,8 @@ export const layer = Layer.effect(
|
||||
if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
|
||||
yield* Effect.yieldNow
|
||||
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
|
||||
return yield* runAfterOverflowCompaction(sessionID, undefined, step)
|
||||
return yield* runTurn(sessionID, defect.transition.promotion, step)
|
||||
return yield* runAfterOverflowCompaction(sessionID, undefined, defect.transition.step)
|
||||
return yield* runTurn(sessionID, undefined, defect.transition.step)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -379,23 +348,26 @@ export const layer = Layer.effect(
|
||||
|
||||
const run = Effect.fn("SessionRunner.run")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force?: boolean
|
||||
readonly force: boolean
|
||||
}) {
|
||||
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
||||
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
|
||||
if (input.force !== true && !hasSteer && !hasQueue) return
|
||||
if (!input.force && !hasSteer && !hasQueue) return
|
||||
yield* failInterruptedTools(input.sessionID)
|
||||
let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
|
||||
let openActivity = input.force === true || hasSteer || hasQueue
|
||||
while (openActivity) {
|
||||
let shouldRun = input.force || hasSteer || hasQueue
|
||||
while (shouldRun) {
|
||||
let needsContinuation = true
|
||||
for (let step = 1; needsContinuation; step++) {
|
||||
needsContinuation = yield* runTurn(input.sessionID, promotion, step)
|
||||
let step = 1
|
||||
while (needsContinuation) {
|
||||
const result = yield* runTurn(input.sessionID, promotion, step)
|
||||
needsContinuation = result.needsContinuation
|
||||
step = result.step + 1
|
||||
promotion = "steer"
|
||||
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
||||
}
|
||||
openActivity = yield* SessionInput.hasPending(db, input.sessionID, "queue")
|
||||
promotion = openActivity ? "queue" : undefined
|
||||
shouldRun = yield* SessionInput.hasPending(db, input.sessionID, "queue")
|
||||
promotion = shouldRun ? "queue" : undefined
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-message
|
||||
import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat"
|
||||
import * as OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses"
|
||||
import { Auth, type AnyRoute } from "@opencode-ai/llm/route"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { produce } from "immer"
|
||||
import { Catalog } from "../../catalog"
|
||||
import { Credential } from "../../credential"
|
||||
@@ -13,7 +13,6 @@ import { Integration } from "../../integration"
|
||||
import { IntegrationConnection } from "../../integration/connection"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { ModelRequest } from "../../model-request"
|
||||
import { PluginBoot } from "../../plugin/boot"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import { SessionSchema } from "../schema"
|
||||
|
||||
@@ -24,6 +23,23 @@ export class ModelNotSelectedError extends Schema.TaggedErrorClass<ModelNotSelec
|
||||
},
|
||||
) {}
|
||||
|
||||
export class ModelUnavailableError extends Schema.TaggedErrorClass<ModelUnavailableError>()(
|
||||
"SessionRunnerModel.ModelUnavailableError",
|
||||
{
|
||||
providerID: ProviderV2.ID,
|
||||
modelID: ModelV2.ID,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
|
||||
"SessionRunnerModel.VariantUnavailableError",
|
||||
{
|
||||
providerID: ProviderV2.ID,
|
||||
modelID: ModelV2.ID,
|
||||
variant: ModelV2.VariantID,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiError>()(
|
||||
"SessionRunnerModel.UnsupportedApiError",
|
||||
{
|
||||
@@ -33,7 +49,7 @@ export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiE
|
||||
},
|
||||
) {}
|
||||
|
||||
export type Error = ModelNotSelectedError | UnsupportedApiError
|
||||
export type Error = ModelNotSelectedError | ModelUnavailableError | VariantUnavailableError | UnsupportedApiError
|
||||
|
||||
export interface Interface {
|
||||
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Model, Error>
|
||||
@@ -44,7 +60,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
/** Test or embedding seam for supplying a model resolver directly. */
|
||||
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
|
||||
|
||||
const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Stored) => {
|
||||
const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Info) => {
|
||||
if (credential?.value.type === "key") return Auth.value(credential.value.key)
|
||||
if (credential?.value.type === "oauth") return Auth.value(credential.value.access)
|
||||
const value = model.request.body.apiKey ?? model.api.settings?.apiKey
|
||||
@@ -70,13 +86,27 @@ const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
|
||||
})
|
||||
}
|
||||
|
||||
const withVariant = (model: ModelV2.Info, variantID: ModelV2.VariantID | undefined) => {
|
||||
const withVariant = (
|
||||
model: ModelV2.Info,
|
||||
variantID: ModelV2.VariantID | undefined,
|
||||
): Effect.Effect<ModelV2.Info, VariantUnavailableError> => {
|
||||
const id = variantID === "default" || variantID === undefined ? model.request.variant : variantID
|
||||
const variant = model.variants.find((item) => item.id === id)
|
||||
if (!variant) return model
|
||||
return produce(model, (draft) => {
|
||||
ModelRequest.assign(draft.request, variant)
|
||||
})
|
||||
if (!variant && variantID !== undefined && variantID !== "default")
|
||||
return Effect.fail(
|
||||
new VariantUnavailableError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
variant: variantID,
|
||||
}),
|
||||
)
|
||||
return Effect.succeed(
|
||||
variant
|
||||
? produce(model, (draft) => {
|
||||
ModelRequest.assign(draft.request, variant)
|
||||
})
|
||||
: model,
|
||||
)
|
||||
}
|
||||
|
||||
const apiName = (model: ModelV2.Info) =>
|
||||
@@ -85,7 +115,7 @@ const apiName = (model: ModelV2.Info) =>
|
||||
export const fromCatalogModel = (
|
||||
model: ModelV2.Info,
|
||||
connection?: IntegrationConnection.Info,
|
||||
credential?: Credential.Stored,
|
||||
credential?: Credential.Info,
|
||||
): Effect.Effect<Model, UnsupportedApiError> => {
|
||||
const resolved =
|
||||
credential?.value.metadata === undefined
|
||||
@@ -124,8 +154,15 @@ export const fromCatalogModel = (
|
||||
)
|
||||
}
|
||||
|
||||
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info) =>
|
||||
fromCatalogModel(withVariant(model, session.model?.variant))
|
||||
export const resolve = (
|
||||
session: SessionSchema.Info,
|
||||
model: ModelV2.Info,
|
||||
connection?: IntegrationConnection.Info,
|
||||
credential?: Credential.Info,
|
||||
) =>
|
||||
withVariant(model, session.model?.variant).pipe(
|
||||
Effect.flatMap((model) => fromCatalogModel(model, connection, credential)),
|
||||
)
|
||||
|
||||
export const supported = (model: ModelV2.Info) =>
|
||||
model.api.type === "aisdk" &&
|
||||
@@ -140,21 +177,27 @@ export const locationLayer = Layer.effect(
|
||||
const catalog = yield* Catalog.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const boot = yield* PluginBoot.Service
|
||||
return Service.of({
|
||||
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
|
||||
// Location plugins populate and filter the catalog asynchronously during layer startup.
|
||||
yield* boot.wait()
|
||||
const defaultModel = session.model ? undefined : yield* catalog.model.default()
|
||||
const selected = session.model
|
||||
? yield* catalog.model.get(session.model.providerID, session.model.id)
|
||||
? (yield* catalog.model.available()).find(
|
||||
(model) => model.providerID === session.model?.providerID && model.id === session.model.id,
|
||||
)
|
||||
: defaultModel && supported(defaultModel)
|
||||
? defaultModel
|
||||
: (yield* catalog.model.available()).find(supported)
|
||||
if (!selected && session.model)
|
||||
return yield* new ModelUnavailableError({
|
||||
providerID: session.model.providerID,
|
||||
modelID: session.model.id,
|
||||
})
|
||||
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
|
||||
const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID))
|
||||
return yield* fromCatalogModel(
|
||||
withVariant(selected, session.model?.variant),
|
||||
return yield* resolve(
|
||||
session,
|
||||
selected,
|
||||
connection,
|
||||
connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined,
|
||||
)
|
||||
|
||||
@@ -65,11 +65,14 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
>()
|
||||
const timestamp = DateTime.now
|
||||
let assistantMessageID: SessionMessage.ID | undefined
|
||||
let assistantActive = false
|
||||
let assistantFailed = false
|
||||
let providerFailed = false
|
||||
|
||||
const startAssistant = Effect.fnUntraced(function* () {
|
||||
if (assistantMessageID !== undefined) return assistantMessageID
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
assistantActive = true
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
...input,
|
||||
assistantMessageID,
|
||||
@@ -190,6 +193,20 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
yield* flushFragments()
|
||||
})
|
||||
|
||||
const failAssistant = Effect.fnUntraced(function* (message: string) {
|
||||
if (assistantFailed) return
|
||||
yield* flush()
|
||||
const assistantMessageID = yield* startAssistant()
|
||||
assistantActive = false
|
||||
assistantFailed = true
|
||||
yield* events.publish(SessionEvent.Step.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* timestamp,
|
||||
assistantMessageID,
|
||||
error: { type: "unknown", message },
|
||||
})
|
||||
})
|
||||
|
||||
const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
|
||||
message: string,
|
||||
hostedOnly = false,
|
||||
@@ -375,6 +392,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
}
|
||||
case "step-finish":
|
||||
yield* flush()
|
||||
assistantActive = false
|
||||
yield* events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* timestamp,
|
||||
@@ -388,13 +406,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
return
|
||||
case "provider-error":
|
||||
providerFailed = true
|
||||
yield* flush()
|
||||
yield* events.publish(SessionEvent.Step.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* timestamp,
|
||||
assistantMessageID: yield* startAssistant(),
|
||||
error: { type: "unknown", message: event.message },
|
||||
})
|
||||
yield* failAssistant(event.message)
|
||||
return
|
||||
}
|
||||
})
|
||||
@@ -402,10 +414,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
return {
|
||||
publish,
|
||||
flush,
|
||||
failAssistant,
|
||||
failUnsettledTools,
|
||||
hasActiveAssistant: () => assistantActive,
|
||||
hasAssistantStarted: () => assistantMessageID !== undefined,
|
||||
hasProviderError: () => providerFailed,
|
||||
startAssistant,
|
||||
assistantMessageID: assistantMessageIDForTool,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,12 +82,21 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
|
||||
const result = toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined)
|
||||
return item.provider?.executed === true && result ? [call, result] : [call]
|
||||
})
|
||||
const meaningful = content.filter((part) => {
|
||||
if (part.type === "text") return part.text !== ""
|
||||
if (part.type !== "reasoning") return true
|
||||
return part.text !== "" || (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0)
|
||||
})
|
||||
const results = message.content
|
||||
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true)
|
||||
.map((item) => toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined))
|
||||
.filter((message) => message !== undefined)
|
||||
.map(Message.tool)
|
||||
return [Message.make({ id: message.id, role: "assistant", content, metadata: message.metadata }), ...results]
|
||||
if (meaningful.length === 0) return results
|
||||
return [
|
||||
Message.make({ id: message.id, role: "assistant", content: meaningful, metadata: message.metadata }),
|
||||
...results,
|
||||
]
|
||||
}
|
||||
|
||||
function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] {
|
||||
|
||||
@@ -170,9 +170,6 @@ export const SessionContextEpochTable = sqliteTable("session_context_epoch", {
|
||||
.primaryKey()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
baseline: text().notNull(),
|
||||
agent: text().$type<AgentV2.ID>().notNull().default(AgentV2.defaultID),
|
||||
snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Snapshot>(),
|
||||
baseline_seq: integer().notNull(),
|
||||
replacement_seq: integer(),
|
||||
revision: integer().notNull().default(0),
|
||||
})
|
||||
|
||||
@@ -148,7 +148,7 @@ export const layer = Layer.effect(
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
rebuild: state.rebuild,
|
||||
reload: state.reload,
|
||||
sources: Effect.fn("SkillV2.sources")(function* () {
|
||||
return state.get().sources
|
||||
}),
|
||||
|
||||
@@ -3,7 +3,6 @@ export * as SkillGuidance from "./guidance"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { PluginBoot } from "../plugin/boot"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { SystemContext } from "../system-context/index"
|
||||
|
||||
@@ -40,12 +39,10 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const boot = yield* PluginBoot.Service
|
||||
const skills = yield* SkillV2.Service
|
||||
|
||||
return Service.of({
|
||||
load: Effect.fn("SkillGuidance.load")(function* (selection) {
|
||||
yield* boot.wait()
|
||||
const agent = selection.info
|
||||
if (!agent) return SystemContext.empty
|
||||
const permitted = SkillV2.available(yield* skills.list(), agent)
|
||||
|
||||
+15
-15
@@ -3,7 +3,7 @@ export * as State from "./state"
|
||||
import { Context, Effect, Scope, Semaphore } from "effect"
|
||||
|
||||
/**
|
||||
* A replayable transform applied to a draft during rebuild.
|
||||
* A replayable transform applied to a draft during reload.
|
||||
*
|
||||
* Domain drafts expose readable and writable state while preserving concise
|
||||
* plugin/config code. Transforms may perform Effects before returning.
|
||||
@@ -19,14 +19,14 @@ export type Transform<DraftApi> = (
|
||||
transform: TransformCallback<DraftApi>,
|
||||
) => Effect.Effect<Registration, never, Scope.Scope>
|
||||
|
||||
export type Rebuild = () => Effect.Effect<void>
|
||||
export type Reload = () => Effect.Effect<void>
|
||||
|
||||
export interface Transformable<DraftApi> {
|
||||
readonly transform: Transform<DraftApi>
|
||||
readonly rebuild: Rebuild
|
||||
readonly reload: Reload
|
||||
}
|
||||
|
||||
const CurrentBatch = Context.Reference<Set<Rebuild> | undefined>("@opencode/State/CurrentBatch", {
|
||||
const CurrentBatch = Context.Reference<Set<Reload> | undefined>("@opencode/State/CurrentBatch", {
|
||||
defaultValue: () => undefined,
|
||||
})
|
||||
|
||||
@@ -34,15 +34,15 @@ export function batch<A, E, R>(effect: Effect.Effect<A, E, R>) {
|
||||
return Effect.gen(function* () {
|
||||
const current = yield* CurrentBatch
|
||||
if (current) return yield* effect
|
||||
const rebuilds = new Set<Rebuild>()
|
||||
const result = yield* effect.pipe(Effect.provideService(CurrentBatch, rebuilds))
|
||||
yield* Effect.forEach(rebuilds, (rebuild) => rebuild(), { discard: true })
|
||||
const reloads = new Set<Reload>()
|
||||
const result = yield* effect.pipe(Effect.provideService(CurrentBatch, reloads))
|
||||
yield* Effect.forEach(reloads, (reload) => reload(), { discard: true })
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
export interface Options<State, DraftApi> {
|
||||
/** Creates the base value for initial state and every scoped-transform rebuild. */
|
||||
/** Creates the base value for initial state and every scoped-transform reload. */
|
||||
readonly initial: () => State
|
||||
/** Wraps mutable state in a domain-specific draft API. */
|
||||
readonly draft: MakeDraft<State, DraftApi>
|
||||
@@ -54,7 +54,7 @@ export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
|
||||
readonly get: () => State
|
||||
/**
|
||||
* Registers and applies a scoped transform. Closing the owning Scope removes
|
||||
* the transform and rebuilds the materialized state.
|
||||
* the transform and reloads the materialized state.
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -78,11 +78,11 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
||||
const materialize = Effect.fnUntraced(function* () {
|
||||
const next = options.initial()
|
||||
const api = options.draft(next)
|
||||
for (const transform of transforms) yield* apply(transform.run, api).pipe(Effect.withSpan("State.rebuild.update"))
|
||||
for (const transform of transforms) yield* apply(transform.run, api).pipe(Effect.withSpan("State.reload.update"))
|
||||
yield* commit(next)
|
||||
})
|
||||
|
||||
const rebuild = () => semaphore.withPermit(materialize())
|
||||
const reload = () => semaphore.withPermit(materialize())
|
||||
|
||||
const result: Interface<State, DraftApi> = {
|
||||
get: () => state,
|
||||
@@ -101,7 +101,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
||||
return Effect.gen(function* () {
|
||||
const batch = yield* CurrentBatch
|
||||
if (batch) {
|
||||
batch.add(rebuild)
|
||||
batch.add(reload)
|
||||
return
|
||||
}
|
||||
yield* materialize()
|
||||
@@ -116,13 +116,13 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
||||
)
|
||||
yield* Scope.addFinalizer(scope, dispose)
|
||||
const batch = yield* CurrentBatch
|
||||
if (batch) batch.add(rebuild)
|
||||
else yield* rebuild()
|
||||
if (batch) batch.add(reload)
|
||||
else yield* reload()
|
||||
return { dispose }
|
||||
}),
|
||||
)
|
||||
}),
|
||||
rebuild,
|
||||
reload,
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Effect, Stream } from "effect"
|
||||
import { HttpClientResponse } from "effect/unstable/http"
|
||||
|
||||
export const collectBoundedResponseBody = (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
maximumBytes: number,
|
||||
tooLarge: () => Error,
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const contentLength = response.headers["content-length"]
|
||||
const parsedSize = contentLength ? Number.parseInt(contentLength, 10) : undefined
|
||||
const declaredSize =
|
||||
parsedSize !== undefined && Number.isSafeInteger(parsedSize) && parsedSize >= 0 ? parsedSize : undefined
|
||||
if (declaredSize !== undefined && declaredSize > maximumBytes) return yield* Effect.fail(tooLarge())
|
||||
let body = Buffer.allocUnsafe(Math.min(maximumBytes, declaredSize || 64 * 1024))
|
||||
let size = 0
|
||||
yield* Stream.runForEach(response.stream, (chunk) => {
|
||||
if (chunk.byteLength === 0) return Effect.void
|
||||
if (size + chunk.byteLength > maximumBytes) return Effect.fail(tooLarge())
|
||||
if (size + chunk.byteLength > body.byteLength) {
|
||||
const grown = Buffer.allocUnsafe(Math.min(maximumBytes, Math.max(size + chunk.byteLength, body.byteLength * 2)))
|
||||
body.copy(grown, 0, 0, size)
|
||||
body = grown
|
||||
}
|
||||
body.set(chunk, size)
|
||||
size += chunk.byteLength
|
||||
return Effect.void
|
||||
})
|
||||
return body.subarray(0, size)
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user