mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-09 23:08:29 +00:00
feat(core): add embedded v2 session runtime and tool foundation (#30632)
This commit is contained in:
parent
c35267776a
commit
76ee87ead8
215 changed files with 31344 additions and 3278 deletions
3
.github/workflows/test.yml
vendored
3
.github/workflows/test.yml
vendored
|
|
@ -64,7 +64,8 @@ jobs:
|
|||
turbo-${{ runner.os }}-
|
||||
|
||||
- name: Run unit tests
|
||||
run: bun turbo test:ci
|
||||
timeout-minutes: 20
|
||||
run: bun turbo test:ci --log-order=stream --log-prefix=task
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
|
|
|
|||
11
AGENTS.md
11
AGENTS.md
|
|
@ -138,3 +138,14 @@ const table = sqliteTable("session", {
|
|||
## Type Checking
|
||||
|
||||
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
|
||||
|
||||
## V2 Session Core
|
||||
|
||||
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_input` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries.
|
||||
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Historical projected prompts lazily synthesize promoted inbox records during exact retry.
|
||||
- Keep `SessionExecution` process-global and Session-ID based. It discovers placement through the read-side `SessionStore` and `LocationServiceMap.get(session.location)`; no layer should take a Session ID.
|
||||
- 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 EventV2 replay owner claims separate from clustered Session execution ownership.
|
||||
|
|
|
|||
77
CONTEXT.md
Normal file
77
CONTEXT.md
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
# OpenCode Session Runtime
|
||||
|
||||
OpenCode sessions preserve durable conversational history while assembling the runtime context an agent needs to act correctly in its current environment.
|
||||
|
||||
## Language
|
||||
|
||||
**System Context**:
|
||||
The structured collection of contextual facts presented to the model as initial instructions and chronological updates.
|
||||
_Avoid_: System prompt
|
||||
|
||||
**Context Component**:
|
||||
One independently loaded fact within the **System Context**, represented by a stable key and one effectfully loaded baseline/update rendering.
|
||||
_Avoid_: Prompt fragment
|
||||
|
||||
**Mid-Conversation System Message**:
|
||||
A durable chronological instruction that tells the model the newly effective state of a changed **Context Component**.
|
||||
_Avoid_: System update, system notification, raw text diff
|
||||
|
||||
**Context Epoch**:
|
||||
The span during which one initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition.
|
||||
|
||||
**Baseline System Context**:
|
||||
The full **System Context** rendered at the start of a **Context Epoch**.
|
||||
_Avoid_: Live system prompt
|
||||
|
||||
**Context Checkpoint**:
|
||||
The durable model-hidden comparison state used to detect which **Context Components** changed since context was last admitted to a provider turn.
|
||||
|
||||
**Unavailable Context**:
|
||||
An expected temporary inability to load a **Context Component** value; the runtime retains its prior effective state and emits no update, or omits it until first successfully loaded.
|
||||
|
||||
**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.
|
||||
|
||||
## Relationships
|
||||
|
||||
- A **System Context** contains one or more **Context Components**.
|
||||
- A changed **Context Component** may produce one **Mid-Conversation System Message** containing its newly effective state.
|
||||
- A **Mid-Conversation System Message** persists its originating **Context Component** key and the exact rendered text sent to the model.
|
||||
- A **Context Checkpoint** advances atomically with the corresponding durable **Mid-Conversation System Message**.
|
||||
- A **Context Checkpoint** stores one rendered-content hash per stable **Context Component** key so core and plugin-defined components can evolve independently.
|
||||
- Changes from multiple **Context Components** 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**.
|
||||
- The first provider turn renders the latest **Baseline System Context** and initializes its **Context Checkpoint** without emitting a redundant **Mid-Conversation System Message**.
|
||||
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Checkpoint**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
|
||||
- A **Context Checkpoint** is an evolvable component map; a newly registered core or plugin-defined **Context Component** absent from an existing checkpoint emits its current state once at the next **Safe Provider-Turn Boundary**.
|
||||
- **Context Component** keys are stable and namespaced; duplicate keys fail assembly. Built-in components preserve declaration order and plugin-defined components append in lexicographic key order so rendered context is deterministic.
|
||||
- Each **Context Component** loader returns its model-visible baseline string and absolute current-state update string from one coherent sample; the update string is hashed for change detection.
|
||||
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
|
||||
- Ordinary **Context Component** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
|
||||
- Nested project instruction files discovered while reading join the effective instructions returned by the instruction service and are admitted durably at the next **Safe Provider-Turn Boundary**.
|
||||
- A discovered nested project instruction remains active for the session while it stays in the same location and is folded into later **Baseline System Contexts** after compaction.
|
||||
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
|
||||
- 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.
|
||||
- Plugin-defined **Context Components** register through a scoped replayable registry so plugin hot reload adds and removes components predictably.
|
||||
- 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 model-projection history but are hidden from normal user-facing transcript surfaces.
|
||||
- The date **Context Component** 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 **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
|
||||
- A **Baseline System Context** durably preserves deterministic keyed top-level component strings rather than eagerly joining all text; request assembly lowers them into canonical LLM system parts.
|
||||
- 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.
|
||||
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
|
||||
- When an effective instruction file changes, its **Mid-Conversation System Message** includes the complete current contents and supersedes the prior version from that source; when it is removed, the message states that it no longer applies.
|
||||
|
||||
## Example dialogue
|
||||
|
||||
> **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?"
|
||||
> **Domain expert:** "No. Emit the newly effective date so the agent can act on the current **System Context**."
|
||||
|
||||
## Flagged ambiguities
|
||||
|
||||
- Legacy `experimental.chat.system.transform` can mutate the assembled baseline system prompt arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, replace dynamic uses with plugin-defined **Context Components**, or narrow its semantics.
|
||||
- A location change likely starts a new **Context Epoch** so location-dependent instructions and discovery can be rebuilt cleanly, but implementation should verify whether an append-only update is sufficient and meaningfully preserves cache.
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
DROP INDEX IF EXISTS `session_message_session_idx`;--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS `session_message_session_type_idx`;--> statement-breakpoint
|
||||
CREATE INDEX `event_aggregate_seq_idx` ON `event` (`aggregate_id`,`seq`);--> statement-breakpoint
|
||||
CREATE INDEX `session_message_session_time_created_id_idx` ON `session_message` (`session_id`,`time_created`,`id`);--> statement-breakpoint
|
||||
CREATE INDEX `session_message_session_type_time_created_id_idx` ON `session_message` (`session_id`,`type`,`time_created`,`id`);
|
||||
1708
packages/core/migration/20260603001617_session_message_projection_indexes/snapshot.json
generated
Normal file
1708
packages/core/migration/20260603001617_session_message_projection_indexes/snapshot.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,5 @@
|
|||
ALTER TABLE `session_message` ADD `seq` integer NOT NULL;--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS `session_message_session_time_created_id_idx`;--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS `session_message_session_type_time_created_id_idx`;--> statement-breakpoint
|
||||
CREATE INDEX `session_message_session_seq_idx` ON `session_message` (`session_id`,`seq`);--> statement-breakpoint
|
||||
CREATE INDEX `session_message_session_type_seq_idx` ON `session_message` (`session_id`,`type`,`seq`);
|
||||
1638
packages/core/migration/20260603040000_session_message_projection_order/snapshot.json
generated
Normal file
1638
packages/core/migration/20260603040000_session_message_projection_order/snapshot.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,12 @@
|
|||
CREATE TABLE `session_input` (
|
||||
`seq` integer PRIMARY KEY AUTOINCREMENT,
|
||||
`id` text NOT NULL UNIQUE,
|
||||
`session_id` text NOT NULL,
|
||||
`prompt` text NOT NULL,
|
||||
`delivery` text NOT NULL,
|
||||
`promoted_seq` integer,
|
||||
`time_created` integer NOT NULL,
|
||||
CONSTRAINT `fk_session_input_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `session_input_session_pending_seq_idx` ON `session_input` (`session_id`,`promoted_seq`,`seq`);
|
||||
1759
packages/core/migration/20260603141458_session_input_inbox/snapshot.json
generated
Normal file
1759
packages/core/migration/20260603141458_session_input_inbox/snapshot.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,4 @@
|
|||
DROP INDEX IF EXISTS `session_input_session_pending_seq_idx`;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS `event_aggregate_type_seq_idx` ON `event` (`aggregate_id`,`type`,`seq`);--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS `session_input_session_pending_delivery_seq_idx` ON `session_input` (`session_id`,`promoted_seq`,`delivery`,`seq`);--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS `session_message_session_time_created_id_idx` ON `session_message` (`session_id`,`time_created`,`id`);
|
||||
1956
packages/core/migration/20260603160727_jittery_ezekiel_stane/snapshot.json
generated
Normal file
1956
packages/core/migration/20260603160727_jittery_ezekiel_stane/snapshot.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -17,6 +17,7 @@
|
|||
"opencode": "./bin/opencode"
|
||||
},
|
||||
"exports": {
|
||||
"./session/runner": "./src/session/runner/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"imports": {
|
||||
|
|
@ -39,6 +40,7 @@
|
|||
"@types/npm-package-arg": "6.1.4",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/semver": "catalog:",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||
|
|
@ -48,6 +50,7 @@
|
|||
"@parcel/watcher-linux-x64-musl": "2.5.1",
|
||||
"@parcel/watcher-win32-arm64": "2.5.1",
|
||||
"@parcel/watcher-win32-x64": "2.5.1",
|
||||
"@opencode-ai/http-recorder": "workspace:*",
|
||||
"drizzle-kit": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -80,6 +83,7 @@
|
|||
"@npmcli/config": "10.8.1",
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
|
||||
|
|
@ -96,6 +100,7 @@
|
|||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
"htmlparser2": "8.0.2",
|
||||
"immer": "11.1.4",
|
||||
"ignore": "7.0.5",
|
||||
"jsonc-parser": "3.3.1",
|
||||
|
|
@ -103,6 +108,7 @@
|
|||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"semver": "^7.6.3",
|
||||
"turndown": "7.2.0",
|
||||
"venice-ai-sdk-provider": "2.0.2",
|
||||
"which": "6.0.1",
|
||||
"xdg-basedir": "5.1.0",
|
||||
|
|
|
|||
284
packages/core/src/background-job.ts
Normal file
284
packages/core/src/background-job.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
export * as BackgroundJob from "./background-job"
|
||||
|
||||
import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
|
||||
import { Identifier } from "./id/id"
|
||||
|
||||
export type Status = "running" | "completed" | "error" | "cancelled"
|
||||
|
||||
export type Info = {
|
||||
id: string
|
||||
type: string
|
||||
title?: string
|
||||
status: Status
|
||||
started_at: number
|
||||
completed_at?: number
|
||||
output?: string
|
||||
error?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type Active = {
|
||||
info: Info
|
||||
done: Deferred.Deferred<Info>
|
||||
scope: Scope.Closeable
|
||||
token: object
|
||||
pending: number
|
||||
next: number
|
||||
output?: { sequence: number; text: string }
|
||||
}
|
||||
|
||||
type State = {
|
||||
jobs: SynchronizedRef.SynchronizedRef<Map<string, Active>>
|
||||
scope: Scope.Scope
|
||||
}
|
||||
|
||||
type FinishResult = {
|
||||
info?: Info
|
||||
done?: Deferred.Deferred<Info>
|
||||
scope?: Scope.Closeable
|
||||
}
|
||||
|
||||
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable; token: object }
|
||||
|
||||
type ExtendResult = { extended: false } | { extended: true; scope: Scope.Closeable; token: object; sequence: number }
|
||||
|
||||
export type StartInput = {
|
||||
id?: string
|
||||
type: string
|
||||
title?: string
|
||||
metadata?: Record<string, unknown>
|
||||
run: Effect.Effect<string, unknown>
|
||||
}
|
||||
|
||||
export type ExtendInput = {
|
||||
id: string
|
||||
run: Effect.Effect<string, unknown>
|
||||
}
|
||||
|
||||
export type WaitInput = {
|
||||
id: string
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export type WaitResult = {
|
||||
info?: Info
|
||||
timedOut: boolean
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly get: (id: string) => Effect.Effect<Info | undefined>
|
||||
readonly start: (input: StartInput) => Effect.Effect<Info>
|
||||
readonly extend: (input: ExtendInput) => Effect.Effect<boolean>
|
||||
readonly wait: (input: WaitInput) => Effect.Effect<WaitResult>
|
||||
readonly cancel: (id: string) => Effect.Effect<Info | undefined>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/BackgroundJob") {}
|
||||
|
||||
function snapshot(job: Active): Info {
|
||||
return {
|
||||
...job.info,
|
||||
...(job.info.metadata ? { metadata: { ...job.info.metadata } } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function errorText(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
return String(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes one scoped, process-local registry. Entries are intentionally not
|
||||
* durable: process restart or owner-scope closure loses status and interrupts
|
||||
* live work. Persisted observation, restart recovery, and remote workers need a
|
||||
* separate durable ownership slice rather than pretending this registry has
|
||||
* those semantics.
|
||||
*/
|
||||
export const make = Effect.gen(function* () {
|
||||
const state: State = {
|
||||
jobs: yield* SynchronizedRef.make(new Map()),
|
||||
scope: yield* Scope.Scope,
|
||||
}
|
||||
|
||||
const settle = Effect.fn("BackgroundJob.settle")(function* (
|
||||
id: string,
|
||||
token: object,
|
||||
sequence: number,
|
||||
exit: Exit.Exit<string, unknown>,
|
||||
) {
|
||||
const completed_at = yield* Clock.currentTimeMillis
|
||||
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
|
||||
const job = jobs.get(id)
|
||||
if (!job) return [{}, jobs]
|
||||
if (job.token !== token) return [{}, jobs]
|
||||
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
|
||||
const pending = job.pending - 1
|
||||
const output =
|
||||
Exit.isSuccess(exit) && (!job.output || sequence > job.output.sequence)
|
||||
? { sequence, text: exit.value }
|
||||
: job.output
|
||||
if (Exit.isSuccess(exit) && pending > 0) {
|
||||
return [{}, new Map(jobs).set(id, { ...job, pending, output })]
|
||||
}
|
||||
const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
|
||||
? "completed"
|
||||
: Cause.hasInterruptsOnly(exit.cause)
|
||||
? "cancelled"
|
||||
: "error"
|
||||
const next = {
|
||||
...job,
|
||||
pending: 0,
|
||||
output,
|
||||
info: {
|
||||
...job.info,
|
||||
status,
|
||||
completed_at,
|
||||
...(output ? { output: output.text } : {}),
|
||||
...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}),
|
||||
},
|
||||
}
|
||||
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
|
||||
})
|
||||
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
|
||||
if (result.scope) {
|
||||
yield* Scope.close(result.scope, Exit.void).pipe(Effect.forkIn(state.scope, { startImmediately: true }))
|
||||
}
|
||||
return result.info
|
||||
})
|
||||
|
||||
const fork = Effect.fn("BackgroundJob.fork")(function* (
|
||||
scope: Scope.Scope,
|
||||
id: string,
|
||||
token: object,
|
||||
sequence: number,
|
||||
run: Effect.Effect<string, unknown>,
|
||||
) {
|
||||
return yield* run.pipe(
|
||||
Effect.matchCauseEffect({
|
||||
onSuccess: (output) => settle(id, token, sequence, Exit.succeed(output)),
|
||||
onFailure: (cause) => settle(id, token, sequence, Exit.failCause(cause)),
|
||||
}),
|
||||
Effect.asVoid,
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
})
|
||||
|
||||
const list: Interface["list"] = Effect.fn("BackgroundJob.list")(function* () {
|
||||
return Array.from((yield* SynchronizedRef.get(state.jobs)).values())
|
||||
.map(snapshot)
|
||||
.toSorted((a, b) => a.started_at - b.started_at)
|
||||
})
|
||||
|
||||
const get: Interface["get"] = Effect.fn("BackgroundJob.get")(function* (id) {
|
||||
const job = (yield* SynchronizedRef.get(state.jobs)).get(id)
|
||||
if (!job) return
|
||||
return snapshot(job)
|
||||
})
|
||||
|
||||
const start: Interface["start"] = Effect.fn("BackgroundJob.start")(function* (input) {
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const id = input.id ?? Identifier.ascending("job")
|
||||
const started_at = yield* Clock.currentTimeMillis
|
||||
const done = yield* Deferred.make<Info>()
|
||||
const result = yield* SynchronizedRef.modifyEffect(
|
||||
state.jobs,
|
||||
Effect.fnUntraced(function* (jobs) {
|
||||
const existing = jobs.get(id)
|
||||
if (existing?.info.status === "running") {
|
||||
return [{ info: snapshot(existing) }, jobs] as readonly [StartResult, Map<string, Active>]
|
||||
}
|
||||
const scope = yield* Scope.fork(state.scope, "parallel")
|
||||
const token = {}
|
||||
const job = {
|
||||
info: {
|
||||
id,
|
||||
type: input.type,
|
||||
title: input.title,
|
||||
status: "running" as const,
|
||||
started_at,
|
||||
metadata: input.metadata,
|
||||
},
|
||||
done,
|
||||
scope,
|
||||
token,
|
||||
pending: 1,
|
||||
next: 1,
|
||||
}
|
||||
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)] as readonly [
|
||||
StartResult,
|
||||
Map<string, Active>,
|
||||
]
|
||||
}),
|
||||
)
|
||||
if ("scope" in result) yield* fork(result.scope, id, result.token, 0, restore(input.run))
|
||||
return result.info
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const extend: Interface["extend"] = Effect.fn("BackgroundJob.extend")(function* (input) {
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* SynchronizedRef.modify(
|
||||
state.jobs,
|
||||
(jobs): readonly [ExtendResult, Map<string, Active>] => {
|
||||
const job = jobs.get(input.id)
|
||||
if (!job || job.info.status !== "running") return [{ extended: false }, jobs]
|
||||
return [
|
||||
{ extended: true, scope: job.scope, token: job.token, sequence: job.next },
|
||||
new Map(jobs).set(input.id, {
|
||||
...job,
|
||||
pending: job.pending + 1,
|
||||
next: job.next + 1,
|
||||
}),
|
||||
]
|
||||
},
|
||||
)
|
||||
if (!result.extended) return false
|
||||
yield* fork(result.scope, input.id, result.token, result.sequence, restore(input.run))
|
||||
return true
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const wait: Interface["wait"] = Effect.fn("BackgroundJob.wait")(function* (input) {
|
||||
const job = (yield* SynchronizedRef.get(state.jobs)).get(input.id)
|
||||
if (!job) return { timedOut: false }
|
||||
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
|
||||
if (input.timeout === undefined) return { info: yield* Deferred.await(job.done), timedOut: false }
|
||||
if (input.timeout <= 0) return { info: snapshot(job), timedOut: true }
|
||||
const info = yield* Deferred.await(job.done).pipe(Effect.timeoutOption(input.timeout))
|
||||
if (info._tag === "Some") return { info: info.value, timedOut: false }
|
||||
return { info: snapshot(job), timedOut: true }
|
||||
})
|
||||
|
||||
const cancel: Interface["cancel"] = Effect.fn("BackgroundJob.cancel")(function* (id) {
|
||||
const completed_at = yield* Clock.currentTimeMillis
|
||||
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
|
||||
const job = jobs.get(id)
|
||||
if (!job) return [{}, jobs]
|
||||
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
|
||||
const next = {
|
||||
...job,
|
||||
pending: 0,
|
||||
info: {
|
||||
...job.info,
|
||||
status: "cancelled" as const,
|
||||
completed_at,
|
||||
},
|
||||
}
|
||||
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
|
||||
})
|
||||
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
|
||||
if (result.scope) yield* Scope.close(result.scope, Exit.void)
|
||||
return result.info
|
||||
})
|
||||
|
||||
return Service.of({ list, get, start, extend, wait, cancel })
|
||||
})
|
||||
|
||||
export const layer = Layer.effect(Service, make)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
|
@ -6,7 +6,7 @@ import { Context, Effect, Layer, Option, Schema } from "effect"
|
|||
import { FSUtil } from "./fs-util"
|
||||
import { Global } from "./global"
|
||||
import { Location } from "./location"
|
||||
import { PermissionV2 } from "./permission"
|
||||
import { PermissionSchema } from "./permission/schema"
|
||||
import { Policy } from "./policy"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { ConfigAgent } from "./config/agent"
|
||||
|
|
@ -52,7 +52,7 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
|||
username: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Username displayed in conversations and used for telemetry identity",
|
||||
}),
|
||||
permissions: PermissionV2.Ruleset.pipe(Schema.optional).annotate({
|
||||
permissions: PermissionSchema.Ruleset.pipe(Schema.optional).annotate({
|
||||
description: "Ordered tool permission rules applied to agent tool use",
|
||||
}),
|
||||
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export * as ConfigAgent from "./agent"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { PermissionSchema } from "../permission/schema"
|
||||
import { ConfigProvider } from "./provider"
|
||||
import { PositiveInt } from "../schema"
|
||||
|
||||
|
|
@ -21,5 +21,5 @@ export class Info extends Schema.Class<Info>("ConfigV2.Agent")({
|
|||
color: Color.pipe(Schema.optional),
|
||||
steps: PositiveInt.pipe(Schema.optional),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
permissions: PermissionV2.Ruleset.pipe(Schema.optional),
|
||||
permissions: PermissionSchema.Ruleset.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ export const Plugin = PluginV2.define({
|
|||
const skill = yield* SkillV2.Service
|
||||
const transform = yield* skill.transform()
|
||||
const entries = yield* config.entries()
|
||||
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
|
||||
const items = entries.flatMap((entry) =>
|
||||
entry.type === "document" ? (entry.info.skills ?? []) : [path.join(entry.path, "skill"), path.join(entry.path, "skills")],
|
||||
)
|
||||
|
||||
yield* transform((editor) => {
|
||||
for (const item of items) {
|
||||
|
|
|
|||
4
packages/core/src/database/migration.gen.ts
generated
4
packages/core/src/database/migration.gen.ts
generated
|
|
@ -27,5 +27,9 @@ export const migrations = (
|
|||
import("./migration/20260601202201_amazing_prowler"),
|
||||
import("./migration/20260602002951_lowly_union_jack"),
|
||||
import("./migration/20260602182828_add_project_directories"),
|
||||
import("./migration/20260603001617_session_message_projection_indexes"),
|
||||
import("./migration/20260603040000_session_message_projection_order"),
|
||||
import("./migration/20260603141458_session_input_inbox"),
|
||||
import("./migration/20260603160727_jittery_ezekiel_stane"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
export * as DatabaseMigration from "./migration"
|
||||
|
||||
import { sql } from "drizzle-orm"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Semaphore } from "effect"
|
||||
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { migrations } from "./migration.gen"
|
||||
|
||||
type Database = EffectDrizzleSqlite.EffectSQLiteDatabase
|
||||
type Transaction = Parameters<Parameters<Database["transaction"]>[0]>[0]
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
export type Migration = {
|
||||
id: string
|
||||
|
|
@ -14,7 +15,7 @@ export type Migration = {
|
|||
}
|
||||
|
||||
export function apply(db: Database) {
|
||||
return applyOnly(db, migrations)
|
||||
return lock.withPermit(applyOnly(db, migrations))
|
||||
}
|
||||
|
||||
export function applyOnly(db: Database, input: Migration[]) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260603001617_session_message_projection_indexes",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`DROP INDEX IF EXISTS \`session_message_session_idx\`;`)
|
||||
yield* tx.run(`DROP INDEX IF EXISTS \`session_message_session_type_idx\`;`)
|
||||
yield* tx.run(`CREATE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_message_session_type_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`time_created\`,\`id\`);`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260603040000_session_message_projection_order",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session_message\` ADD COLUMN \`seq\` integer NOT NULL DEFAULT 0;`)
|
||||
yield* tx.run(`UPDATE \`session_message\` SET \`seq\` = COALESCE((SELECT \`seq\` + 1 FROM \`event\` WHERE \`event\`.\`id\` = \`session_message\`.\`id\`), 0);`)
|
||||
const unmatched = yield* tx.get<{ count: number }>(`SELECT COUNT(*) AS \`count\` FROM \`session_message\` WHERE \`seq\` = 0;`)
|
||||
if ((unmatched?.count ?? 0) > 0) return yield* Effect.die("Cannot migrate session_message projections without matching durable events")
|
||||
yield* tx.run(`UPDATE \`session_message\` SET \`seq\` = \`seq\` - 1;`)
|
||||
yield* tx.run(`DROP INDEX IF EXISTS \`session_message_session_type_time_created_id_idx\`;`)
|
||||
yield* tx.run(`CREATE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260603141458_session_input_inbox",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`session_input\` (
|
||||
\`seq\` integer PRIMARY KEY AUTOINCREMENT,
|
||||
\`id\` text NOT NULL UNIQUE,
|
||||
\`session_id\` text NOT NULL,
|
||||
\`prompt\` text NOT NULL,
|
||||
\`delivery\` text NOT NULL,
|
||||
\`promoted_seq\` integer,
|
||||
\`time_created\` integer NOT NULL,
|
||||
CONSTRAINT \`fk_session_input_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_input_session_pending_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`seq\`);`,
|
||||
)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260603160727_jittery_ezekiel_stane",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`DROP INDEX IF EXISTS \`session_input_session_pending_seq_idx\`;`)
|
||||
yield* tx.run(`CREATE INDEX IF NOT EXISTS \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX IF NOT EXISTS \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX IF NOT EXISTS \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`,
|
||||
)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
|
@ -175,8 +175,9 @@ const drizzleLayer = Layer.effect(
|
|||
}),
|
||||
)
|
||||
|
||||
export const layer = (config: Config) =>
|
||||
Layer.merge(
|
||||
nativeLayer(config),
|
||||
Layer.merge(sqliteLayer(config), drizzleLayer).pipe(Layer.provide(nativeLayer(config))),
|
||||
).pipe(Layer.provide(Reactivity.layer))
|
||||
export const layer = (config: Config) => {
|
||||
const native = nativeLayer(config)
|
||||
return Layer.merge(native, Layer.merge(sqliteLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
|
||||
Layer.provide(Reactivity.layer),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -170,8 +170,9 @@ const drizzleLayer = Layer.effect(
|
|||
}),
|
||||
)
|
||||
|
||||
export const layer = (config: Config) =>
|
||||
Layer.merge(
|
||||
nativeLayer(config),
|
||||
Layer.merge(sqliteLayer(config), drizzleLayer).pipe(Layer.provide(nativeLayer(config))),
|
||||
).pipe(Layer.provide(Reactivity.layer))
|
||||
export const layer = (config: Config) => {
|
||||
const native = nativeLayer(config)
|
||||
return Layer.merge(native, Layer.merge(sqliteLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
|
||||
Layer.provide(Reactivity.layer),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
43
packages/core/src/effect/keyed-mutex.ts
Normal file
43
packages/core/src/effect/keyed-mutex.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
export * as KeyedMutex from "./keyed-mutex"
|
||||
|
||||
import { Effect, Semaphore } from "effect"
|
||||
|
||||
export interface KeyedMutex<in Key> {
|
||||
readonly size: Effect.Effect<number>
|
||||
readonly withLock: (key: Key) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an in-memory mutex with one lock per key. Entries are removed when no
|
||||
* holder or waiter remains.
|
||||
*
|
||||
* same key -> queue
|
||||
* different key -> run independently
|
||||
*
|
||||
* `users` counts holders and waiters so an entry is not removed while a waiter
|
||||
* will reuse it.
|
||||
*/
|
||||
export const makeUnsafe = <Key>(): KeyedMutex<Key> => {
|
||||
const locks = new Map<Key, { readonly semaphore: Semaphore.Semaphore; users: number }>()
|
||||
|
||||
const withLock = (key: Key) => <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.suspend(() => {
|
||||
const current = locks.get(key)
|
||||
const entry = current ?? { semaphore: Semaphore.makeUnsafe(1), users: 0 }
|
||||
if (!current) locks.set(key, entry)
|
||||
entry.users++
|
||||
return entry.semaphore.withPermit(effect).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
entry.users--
|
||||
if (entry.users === 0) locks.delete(key)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
return { size: Effect.sync(() => locks.size), withLock }
|
||||
}
|
||||
|
||||
/** Creates an in-memory keyed mutex inside an Effect workflow. */
|
||||
export const make = <Key>(): Effect.Effect<KeyedMutex<Key>> => Effect.sync(makeUnsafe<Key>)
|
||||
|
|
@ -1,19 +1,29 @@
|
|||
export * as EventV2 from "./event"
|
||||
|
||||
import { Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { and, asc, eq, gt } from "drizzle-orm"
|
||||
import { Database } from "./database/database"
|
||||
import { EventSequenceTable, EventTable } from "./event/sql"
|
||||
import { Location } from "./location"
|
||||
import { withStatics } from "./schema"
|
||||
import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema"
|
||||
import { Identifier } from "./util/identifier"
|
||||
|
||||
export const ID = Schema.String.pipe(
|
||||
Schema.brand("Event.ID"),
|
||||
withStatics((schema) => ({ create: () => schema.make("evt_" + Identifier.ascending()) })),
|
||||
withStatics((schema) => ({
|
||||
create: () => schema.make("evt_" + Identifier.ascending()),
|
||||
fromExternal: (input: ExternalID) => schema.make(externalID("evt", input)),
|
||||
})),
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
/**
|
||||
* Durable aggregate continuation position for embedded replay streams.
|
||||
* TODO: Decide whether a future HTTP / SDK surface should expose an opaque cursor instead.
|
||||
*/
|
||||
export const Cursor = NonNegativeInt.pipe(Schema.brand("EventV2.Cursor"))
|
||||
export type Cursor = typeof Cursor.Type
|
||||
|
||||
export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = {
|
||||
readonly type: Type
|
||||
readonly sync?: {
|
||||
|
|
@ -29,6 +39,8 @@ export type Payload<D extends Definition = Definition> = {
|
|||
readonly id: ID
|
||||
readonly type: D["type"]
|
||||
readonly data: Data<D>
|
||||
/** Durable aggregate order, populated while synchronized events are projected. */
|
||||
readonly seq?: number
|
||||
readonly version?: number
|
||||
readonly location?: Location.Ref
|
||||
readonly metadata?: Record<string, unknown>
|
||||
|
|
@ -36,6 +48,7 @@ export type Payload<D extends Definition = Definition> = {
|
|||
|
||||
export type Projector<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
|
||||
type AnyProjector = (event: Payload) => Effect.Effect<void>
|
||||
export type CommitGuard = (event: Payload) => Effect.Effect<void>
|
||||
export type Listener = (event: Payload) => Effect.Effect<void>
|
||||
export type Sync = (event: Payload) => Effect.Effect<void>
|
||||
export type Unsubscribe = Effect.Effect<void>
|
||||
|
|
@ -48,6 +61,11 @@ export type SerializedEvent = {
|
|||
readonly data: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type CursorEvent<E extends Payload = Payload> = {
|
||||
readonly cursor: Cursor
|
||||
readonly event: E
|
||||
}
|
||||
|
||||
export class InvalidSyncEventError extends Schema.TaggedErrorClass<InvalidSyncEventError>()(
|
||||
"EventV2.InvalidSyncEvent",
|
||||
{
|
||||
|
|
@ -61,7 +79,15 @@ export function versionedType(type: string, version: number) {
|
|||
}
|
||||
|
||||
export const registry = new Map<string, Definition>()
|
||||
const syncRegistry = new Map<string, Definition & { readonly sync: NonNullable<Definition["sync"]> }>()
|
||||
type SyncDefinition = Definition & {
|
||||
readonly sync: NonNullable<Definition["sync"]>
|
||||
readonly encode: (data: unknown) => unknown
|
||||
readonly decode: (data: unknown) => unknown
|
||||
}
|
||||
const syncRegistry = new Map<string, SyncDefinition>()
|
||||
|
||||
// Synchronized events cross a JSON boundary, so their data schemas must encode and decode without services.
|
||||
const syncCodec = (definition: Definition) => definition.data as Schema.Codec<unknown, unknown, never, never>
|
||||
|
||||
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
|
||||
readonly type: Type
|
||||
|
|
@ -93,7 +119,10 @@ export function define<const Type extends string, Fields extends Schema.Struct.F
|
|||
if (input.sync)
|
||||
syncRegistry.set(
|
||||
versionedType(input.type, input.sync.version),
|
||||
definition as Definition & { readonly sync: NonNullable<Definition["sync"]> },
|
||||
Object.assign(definition, {
|
||||
encode: Schema.encodeUnknownSync(syncCodec(definition)),
|
||||
decode: Schema.decodeUnknownSync(syncCodec(definition)),
|
||||
}) as SyncDefinition,
|
||||
)
|
||||
return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> &
|
||||
Definition<Type, Schema.Struct<Fields>>
|
||||
|
|
@ -117,16 +146,18 @@ export interface Interface {
|
|||
) => Effect.Effect<Payload<D>>
|
||||
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
|
||||
readonly all: () => Stream.Stream<Payload>
|
||||
readonly aggregateEvents: (input: { readonly aggregateID: string; readonly after?: Cursor }) => Stream.Stream<CursorEvent>
|
||||
readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
|
||||
readonly listen: (listener: Listener) => Effect.Effect<Unsubscribe>
|
||||
readonly beforeCommit: (guard: CommitGuard) => Effect.Effect<void>
|
||||
readonly project: <D extends Definition>(definition: D, projector: Projector<D>) => Effect.Effect<void>
|
||||
readonly replay: (
|
||||
event: SerializedEvent,
|
||||
options?: { readonly publish?: boolean; readonly ownerID?: string },
|
||||
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
|
||||
) => Effect.Effect<void>
|
||||
readonly replayAll: (
|
||||
events: SerializedEvent[],
|
||||
options?: { readonly publish?: boolean; readonly ownerID?: string },
|
||||
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
|
||||
) => Effect.Effect<string | undefined>
|
||||
readonly remove: (aggregateID: string) => Effect.Effect<void>
|
||||
readonly claim: (aggregateID: string, ownerID: string) => Effect.Effect<void>
|
||||
|
|
@ -134,12 +165,18 @@ export interface Interface {
|
|||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
export interface LayerOptions {
|
||||
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export const layerWith = (options?: LayerOptions) => Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const all = yield* PubSub.unbounded<Payload>()
|
||||
const synchronized = new Map<string, Set<PubSub.PubSub<void>>>()
|
||||
const typed = new Map<string, PubSub.PubSub<Payload>>()
|
||||
const projectors = new Map<string, AnyProjector[]>()
|
||||
const commitGuards = new Array<CommitGuard>()
|
||||
const listeners = new Array<Listener>()
|
||||
const syncHandlers = new Array<Sync>()
|
||||
const { db } = yield* Database.Service
|
||||
|
|
@ -156,13 +193,16 @@ export const layer = Layer.effect(
|
|||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
yield* PubSub.shutdown(all)
|
||||
yield* Effect.forEach(synchronized.values(), (pubsubs) =>
|
||||
Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }),
|
||||
{ discard: true })
|
||||
yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true })
|
||||
}),
|
||||
)
|
||||
|
||||
function commitSyncEvent(
|
||||
event: Payload,
|
||||
input?: { readonly seq: number; readonly aggregateID: string; readonly ownerID?: string },
|
||||
input?: { readonly seq: number; readonly aggregateID: string; readonly ownerID?: string; readonly strictOwner?: boolean },
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const definition = registry.get(event.type)
|
||||
|
|
@ -185,58 +225,93 @@ export const layer = Layer.effect(
|
|||
}),
|
||||
)
|
||||
} else {
|
||||
const list = projectors.get(event.type) ?? []
|
||||
yield* db
|
||||
.transaction(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* db
|
||||
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const latest = row?.seq ?? -1
|
||||
if (input && input.seq <= latest) return
|
||||
if (input && row?.ownerID && row.ownerID !== input.ownerID) return
|
||||
const seq = input?.seq ?? latest + 1
|
||||
if (input && seq !== latest + 1) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
type: event.type,
|
||||
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
for (const projector of list) {
|
||||
yield* projector(event as Payload)
|
||||
}
|
||||
yield* db
|
||||
.insert(EventSequenceTable)
|
||||
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
|
||||
.onConflictDoUpdate({
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
set: { seq },
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(EventTable)
|
||||
.values([
|
||||
{
|
||||
id: event.id,
|
||||
aggregate_id: aggregateID,
|
||||
seq,
|
||||
type: versionedType(definition.type, sync.version),
|
||||
data: event.data as Record<string, unknown>,
|
||||
},
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
{ behavior: "immediate" },
|
||||
if (input && input.aggregateID !== aggregateID) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
type: event.type,
|
||||
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
const list = projectors.get(event.type) ?? []
|
||||
return yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const committed = yield* db
|
||||
.transaction(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* db
|
||||
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const latest = row?.seq ?? -1
|
||||
if (input && input.seq <= latest) return
|
||||
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
|
||||
if (input.strictOwner) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
type: event.type,
|
||||
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
const seq = input?.seq ?? latest + 1
|
||||
if (input && seq !== latest + 1) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
type: event.type,
|
||||
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
for (const guard of commitGuards) {
|
||||
yield* guard(event)
|
||||
}
|
||||
for (const projector of list) {
|
||||
yield* projector({ ...event, seq } as Payload)
|
||||
}
|
||||
const encoded = syncRegistry.get(versionedType(definition.type, sync.version))!.encode(event.data)
|
||||
yield* db
|
||||
.insert(EventSequenceTable)
|
||||
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
|
||||
.onConflictDoUpdate({
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
set: { seq, ...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}) },
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(EventTable)
|
||||
.values([
|
||||
{
|
||||
id: event.id,
|
||||
aggregate_id: aggregateID,
|
||||
seq,
|
||||
type: versionedType(definition.type, sync.version),
|
||||
data: encoded as Record<string, unknown>,
|
||||
},
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return { aggregateID, seq }
|
||||
}),
|
||||
{ behavior: "immediate" },
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (committed) {
|
||||
yield* Effect.forEach(
|
||||
synchronized.get(committed.aggregateID) ?? [],
|
||||
(pubsub) => PubSub.publish(pubsub, undefined),
|
||||
{ discard: true },
|
||||
)
|
||||
}
|
||||
return committed
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -244,10 +319,14 @@ export const layer = Layer.effect(
|
|||
|
||||
function publishEvent<D extends Definition>(event: Payload<D>) {
|
||||
return Effect.gen(function* () {
|
||||
for (const sync of syncHandlers) {
|
||||
yield* sync(event as Payload)
|
||||
const durable = registry.get(event.type)?.sync !== undefined
|
||||
if (durable) {
|
||||
for (const sync of syncHandlers) {
|
||||
yield* sync(event as Payload)
|
||||
}
|
||||
const committed = yield* commitSyncEvent(event as Payload)
|
||||
if (committed) event = { ...event, seq: committed.seq }
|
||||
}
|
||||
yield* commitSyncEvent(event as Payload)
|
||||
for (const listener of listeners) {
|
||||
yield* listener(event as Payload)
|
||||
}
|
||||
|
|
@ -277,7 +356,7 @@ export const layer = Layer.effect(
|
|||
})
|
||||
}
|
||||
|
||||
function replay(event: SerializedEvent, options?: { readonly publish?: boolean; readonly ownerID?: string }) {
|
||||
function replay(event: SerializedEvent, options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }) {
|
||||
return Effect.gen(function* () {
|
||||
const definition = syncRegistry.get(event.type)
|
||||
if (!definition) {
|
||||
|
|
@ -289,22 +368,23 @@ export const layer = Layer.effect(
|
|||
id: event.id,
|
||||
type: definition.type,
|
||||
version: definition.sync.version,
|
||||
data: event.data,
|
||||
data: definition.decode(event.data),
|
||||
} as Payload
|
||||
yield* commitSyncEvent(payload, { seq: event.seq, aggregateID: event.aggregateID, ownerID: options?.ownerID })
|
||||
if (options?.publish) {
|
||||
const committed = yield* commitSyncEvent(payload, { seq: event.seq, aggregateID: event.aggregateID, ownerID: options?.ownerID, strictOwner: options?.strictOwner })
|
||||
if (committed && options?.publish) {
|
||||
const published = { ...payload, seq: committed.seq }
|
||||
for (const listener of listeners) {
|
||||
yield* listener(payload)
|
||||
yield* listener(published)
|
||||
}
|
||||
const pubsub = typed.get(payload.type)
|
||||
if (pubsub) yield* PubSub.publish(pubsub, payload)
|
||||
yield* PubSub.publish(all, payload)
|
||||
if (pubsub) yield* PubSub.publish(pubsub, published)
|
||||
yield* PubSub.publish(all, published)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function replayAll(events: SerializedEvent[], options?: { readonly publish?: boolean; readonly ownerID?: string }) {
|
||||
function replayAll(events: SerializedEvent[], options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }) {
|
||||
return Effect.gen(function* () {
|
||||
const source = events[0]?.aggregateID
|
||||
if (!source) return undefined
|
||||
|
|
@ -362,6 +442,88 @@ export const layer = Layer.effect(
|
|||
|
||||
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all)
|
||||
|
||||
const decodeSerializedEvent = (event: SerializedEvent): CursorEvent => {
|
||||
const definition = syncRegistry.get(event.type)
|
||||
if (!definition) {
|
||||
throw new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` })
|
||||
}
|
||||
return {
|
||||
cursor: Cursor.make(event.seq),
|
||||
event: {
|
||||
id: event.id,
|
||||
type: definition.type,
|
||||
version: definition.sync.version,
|
||||
seq: event.seq,
|
||||
data: definition.decode(event.data),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const readAfter = (aggregateID: string, after: number) =>
|
||||
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
|
||||
Effect.andThen(
|
||||
db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(and(eq(EventTable.aggregate_id, aggregateID), gt(EventTable.seq, after)))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all(),
|
||||
),
|
||||
Effect.orDie,
|
||||
Effect.map((rows) =>
|
||||
rows.map((event) =>
|
||||
decodeSerializedEvent({
|
||||
id: event.id,
|
||||
aggregateID: event.aggregate_id,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const subscribeSynchronized = (aggregateID: string) =>
|
||||
Effect.gen(function* () {
|
||||
const pubsub = yield* PubSub.sliding<void>(1)
|
||||
const subscription = yield* PubSub.subscribe(pubsub)
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const pubsubs = synchronized.get(aggregateID) ?? new Set()
|
||||
pubsubs.add(pubsub)
|
||||
synchronized.set(aggregateID, pubsubs)
|
||||
}),
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
const pubsubs = synchronized.get(aggregateID)
|
||||
pubsubs?.delete(pubsub)
|
||||
if (pubsubs?.size === 0) synchronized.delete(aggregateID)
|
||||
}).pipe(Effect.andThen(PubSub.shutdown(pubsub))),
|
||||
)
|
||||
return subscription
|
||||
})
|
||||
|
||||
const streamEvents = (input: { readonly aggregateID: string; readonly after?: Cursor }): Stream.Stream<CursorEvent> =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const synchronized = yield* subscribeSynchronized(input.aggregateID)
|
||||
let cursor = input.after ?? -1
|
||||
const read = Effect.suspend(() => readAfter(input.aggregateID, cursor)).pipe(
|
||||
Effect.tap((events) =>
|
||||
Effect.sync(() => {
|
||||
cursor = events.at(-1)?.cursor ?? cursor
|
||||
}),
|
||||
),
|
||||
)
|
||||
const historical = yield* read
|
||||
const live = Stream.fromSubscription(synchronized).pipe(
|
||||
Stream.mapEffect(() => read),
|
||||
Stream.flattenIterable,
|
||||
)
|
||||
return Stream.concat(Stream.fromIterable(historical), live)
|
||||
}),
|
||||
)
|
||||
|
||||
const listen = (listener: Listener): Effect.Effect<Unsubscribe> =>
|
||||
Effect.sync(() => {
|
||||
listeners.push(listener)
|
||||
|
|
@ -380,6 +542,11 @@ export const layer = Layer.effect(
|
|||
})
|
||||
})
|
||||
|
||||
const beforeCommit = (guard: CommitGuard): Effect.Effect<void> =>
|
||||
Effect.sync(() => {
|
||||
commitGuards.push(guard)
|
||||
})
|
||||
|
||||
const project = <D extends Definition>(definition: D, projector: Projector<D>): Effect.Effect<void> =>
|
||||
Effect.sync(() => {
|
||||
const list = projectors.get(definition.type) ?? []
|
||||
|
|
@ -387,8 +554,10 @@ export const layer = Layer.effect(
|
|||
projectors.set(definition.type, list)
|
||||
})
|
||||
|
||||
return Service.of({ publish, subscribe, all: streamAll, sync, listen, project, replay, replayAll, remove, claim })
|
||||
return Service.of({ publish, subscribe, all: streamAll, aggregateEvents: streamEvents, sync, listen, beforeCommit, project, replay, replayAll, remove, claim })
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = layerWith()
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
|
||||
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"
|
||||
import type { EventV2 } from "../event"
|
||||
|
||||
export const EventSequenceTable = sqliteTable("event_sequence", {
|
||||
|
|
@ -7,12 +7,19 @@ export const EventSequenceTable = sqliteTable("event_sequence", {
|
|||
owner_id: text(),
|
||||
})
|
||||
|
||||
export const EventTable = sqliteTable("event", {
|
||||
id: text().$type<EventV2.ID>().primaryKey(),
|
||||
aggregate_id: text()
|
||||
.notNull()
|
||||
.references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }),
|
||||
seq: integer().notNull(),
|
||||
type: text().notNull(),
|
||||
data: text({ mode: "json" }).$type<Record<string, unknown>>().notNull(),
|
||||
})
|
||||
export const EventTable = sqliteTable(
|
||||
"event",
|
||||
{
|
||||
id: text().$type<EventV2.ID>().primaryKey(),
|
||||
aggregate_id: text()
|
||||
.notNull()
|
||||
.references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }),
|
||||
seq: integer().notNull(),
|
||||
type: text().notNull(),
|
||||
data: text({ mode: "json" }).$type<Record<string, unknown>>().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("event_aggregate_seq_idx").on(table.aggregate_id, table.seq),
|
||||
index("event_aggregate_type_seq_idx").on(table.aggregate_id, table.type, table.seq),
|
||||
],
|
||||
)
|
||||
|
|
|
|||
202
packages/core/src/file-mutation.ts
Normal file
202
packages/core/src/file-mutation.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
export * as FileMutation from "./file-mutation"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { dirname } from "path"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { LocationMutation } from "./location-mutation"
|
||||
|
||||
export interface WriteInput {
|
||||
readonly plan: LocationMutation.Plan
|
||||
readonly content: string | Uint8Array
|
||||
}
|
||||
|
||||
export interface TextWriteInput {
|
||||
readonly plan: LocationMutation.Plan
|
||||
readonly content: string
|
||||
}
|
||||
|
||||
export interface ConditionalWriteInput extends WriteInput {
|
||||
readonly expected: Uint8Array
|
||||
}
|
||||
|
||||
export interface RemoveInput {
|
||||
readonly plan: LocationMutation.Plan
|
||||
}
|
||||
|
||||
export class StaleContentError extends Schema.TaggedErrorClass<StaleContentError>()("FileMutation.StaleContentError", {
|
||||
path: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class TargetExistsError extends Schema.TaggedErrorClass<TargetExistsError>()("FileMutation.TargetExistsError", {
|
||||
path: Schema.String,
|
||||
}) {}
|
||||
|
||||
export interface WriteResult {
|
||||
readonly operation: "write"
|
||||
/** Canonical target actually passed to the filesystem mutation. */
|
||||
readonly target: string
|
||||
/** Permission resource captured during planning. */
|
||||
readonly resource: string
|
||||
readonly existed: boolean
|
||||
}
|
||||
|
||||
export interface RemoveResult {
|
||||
readonly operation: "remove"
|
||||
/** Canonical target actually passed to the filesystem mutation. */
|
||||
readonly target: string
|
||||
/** Permission resource captured during planning. */
|
||||
readonly resource: string
|
||||
readonly existed: boolean
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/** Create only while the planned target remains absent. */
|
||||
readonly create: (input: WriteInput) => Effect.Effect<WriteResult, TargetExistsError | LocationMutation.RevalidationError | FSUtil.Error>
|
||||
/** Write after immediately revalidating the planned target. */
|
||||
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, LocationMutation.RevalidationError | FSUtil.Error>
|
||||
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
|
||||
readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, LocationMutation.RevalidationError | FSUtil.Error>
|
||||
/** Commit only if an existing target still has the expected bytes. */
|
||||
readonly writeIfUnchanged: (
|
||||
input: ConditionalWriteInput,
|
||||
) => Effect.Effect<WriteResult, StaleContentError | LocationMutation.RevalidationError | FSUtil.Error>
|
||||
/** Remove after immediately revalidating the planned target. */
|
||||
readonly remove: (input: RemoveInput) => Effect.Effect<RemoveResult, LocationMutation.RevalidationError | FSUtil.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileMutation") {}
|
||||
|
||||
/**
|
||||
* Commit planned file changes.
|
||||
*
|
||||
* resolve(path) -> approve -> lock target -> revalidate(plan) -> mutate
|
||||
*
|
||||
* The caller approves the plan first. This service locks the canonical target,
|
||||
* revalidates the plan immediately before the filesystem operation, then mutates.
|
||||
*
|
||||
* `writeIfUnchanged` compares and writes while holding the same in-memory lock,
|
||||
* so cooperating calls in this process cannot overwrite from the same stale
|
||||
* content. Locks apply only within this service layer and only to identical
|
||||
* canonical targets.
|
||||
*
|
||||
* Revalidation reduces the race window but is not atomic with the next
|
||||
* path-based filesystem operation. A hostile local process can still race it.
|
||||
*
|
||||
* TODO: Use descriptor-relative no-follow operations where supported to close
|
||||
* the final race.
|
||||
*/
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const locks = KeyedMutex.makeUnsafe<string>()
|
||||
const withTargetLock = (target: string) => <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
locks.withLock(target)(Effect.uninterruptible(effect))
|
||||
|
||||
const withValidatedTarget = (plan: LocationMutation.Plan) => <A, E, R>(
|
||||
commit: (target: LocationMutation.Target) => Effect.Effect<A, E, R>,
|
||||
) => withTargetLock(plan.target.canonical)(mutation.revalidate(plan).pipe(Effect.flatMap(commit)))
|
||||
|
||||
const writeResult = (target: LocationMutation.Target, existed = target.exists): WriteResult => ({
|
||||
operation: "write",
|
||||
target: target.canonical,
|
||||
resource: target.resource,
|
||||
existed,
|
||||
})
|
||||
|
||||
const removeResult = (target: LocationMutation.Target): RemoveResult => ({
|
||||
operation: "remove",
|
||||
target: target.canonical,
|
||||
resource: target.resource,
|
||||
existed: target.exists,
|
||||
})
|
||||
|
||||
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
|
||||
withValidatedTarget(input.plan)((target) =>
|
||||
Effect.gen(function* () {
|
||||
yield* fs.writeWithDirs(target.canonical, input.content)
|
||||
return writeResult(target)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) =>
|
||||
withValidatedTarget(input.plan)((target) =>
|
||||
Effect.gen(function* () {
|
||||
const next = splitBom(input.content)
|
||||
const preserveBom = target.exists && hasUtf8Bom(yield* fs.readFile(target.canonical))
|
||||
yield* fs.writeWithDirs(target.canonical, joinBom(next.text, preserveBom || next.bom))
|
||||
return writeResult(target)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const create = Effect.fn("FileMutation.create")((input: WriteInput) =>
|
||||
withValidatedTarget(input.plan)((target) =>
|
||||
Effect.gen(function* () {
|
||||
if (target.exists) return yield* new TargetExistsError({ path: target.canonical })
|
||||
yield* fs.ensureDir(dirname(target.canonical))
|
||||
if (typeof input.content === "string") yield* fs.writeFileString(target.canonical, input.content, { flag: "wx" })
|
||||
else yield* fs.writeFile(target.canonical, input.content, { flag: "wx" })
|
||||
return writeResult(target, false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const writeIfUnchanged = Effect.fn("FileMutation.writeIfUnchanged")((input: ConditionalWriteInput) =>
|
||||
withValidatedTarget(input.plan)((target) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* fs.readFile(target.canonical)
|
||||
if (!sameBytes(current, input.expected)) return yield* new StaleContentError({ path: target.canonical })
|
||||
yield* fs.writeWithDirs(target.canonical, input.content)
|
||||
return writeResult(target)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const remove = Effect.fn("FileMutation.remove")((input: RemoveInput) =>
|
||||
withValidatedTarget(input.plan)((target) =>
|
||||
Effect.gen(function* () {
|
||||
yield* fs.remove(target.canonical)
|
||||
return removeResult(target)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({ create, write, writeTextPreservingBom, writeIfUnchanged, remove })
|
||||
}),
|
||||
)
|
||||
|
||||
function splitBom(text: string) {
|
||||
const stripped = text.replace(/^\uFEFF+/, "")
|
||||
return { bom: stripped.length !== text.length, text: stripped }
|
||||
}
|
||||
|
||||
function joinBom(text: string, bom: boolean) {
|
||||
const stripped = splitBom(text).text
|
||||
return bom ? `\uFEFF${stripped}` : stripped
|
||||
}
|
||||
|
||||
function hasUtf8Bom(content: Uint8Array) {
|
||||
return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
|
||||
}
|
||||
|
||||
function sameBytes(left: Uint8Array, right: Uint8Array) {
|
||||
if (left.length !== right.length) return false
|
||||
return left.every((byte, index) => byte === right[index])
|
||||
}
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
/**
|
||||
* Deferred until the corresponding V2 integrations exist.
|
||||
*/
|
||||
// TODO: Add formatter integration after V2 formatter runtime exists.
|
||||
// TODO: Publish watcher/file-edit events after V2 watcher integration exists.
|
||||
// TODO: Add snapshots / undo after V2 snapshot design exists.
|
||||
// TODO: Notify LSP and collect diagnostics after V2 LSP runtime exists.
|
||||
// TODO: Design multi-file transactions / rollback if apply_patch needs atomic edits.
|
||||
// Until then, edits are sequential and report partial application.
|
||||
// TODO: Define crash recovery and idempotency for side effects between Tool.Called and durable settlement.
|
||||
|
|
@ -4,7 +4,7 @@ import path from "path"
|
|||
import { pathToFileURL } from "url"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import ignore from "ignore"
|
||||
import { Context, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Context, Effect, Layer, Option, Schema, Stream } from "effect"
|
||||
import { EventV2 } from "./event"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Global } from "./global"
|
||||
|
|
@ -16,17 +16,22 @@ import { Ripgrep } from "./filesystem/ripgrep"
|
|||
|
||||
export const ReadInput = Schema.Struct({
|
||||
path: RelativePath,
|
||||
reference: Schema.String.pipe(Schema.optional),
|
||||
reference: Schema.NonEmptyString.pipe(Schema.optional),
|
||||
})
|
||||
export type ReadInput = typeof ReadInput.Type
|
||||
|
||||
export class TextContent extends Schema.Class<TextContent>("LocationFileSystem.TextContent")({
|
||||
export const MAX_READ_LINES = 2_000
|
||||
export const MAX_READ_BYTES = 50 * 1024
|
||||
const MAX_LINE_LENGTH = 2_000
|
||||
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
||||
|
||||
export class TextContent extends Schema.Class<TextContent>("FileSystem.TextContent")({
|
||||
type: Schema.Literal("text"),
|
||||
content: Schema.String,
|
||||
mime: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class BinaryContent extends Schema.Class<BinaryContent>("LocationFileSystem.BinaryContent")({
|
||||
export class BinaryContent extends Schema.Class<BinaryContent>("FileSystem.BinaryContent")({
|
||||
type: Schema.Literal("binary"),
|
||||
content: Schema.String,
|
||||
encoding: Schema.Literal("base64"),
|
||||
|
|
@ -36,19 +41,80 @@ export class BinaryContent extends Schema.Class<BinaryContent>("LocationFileSyst
|
|||
export const Content = Schema.Union([TextContent, BinaryContent]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Content = typeof Content.Type
|
||||
|
||||
export const TextPageInput = Schema.Struct({
|
||||
offset: PositiveInt.pipe(Schema.optional),
|
||||
limit: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES)).pipe(Schema.optional),
|
||||
})
|
||||
export type TextPageInput = typeof TextPageInput.Type
|
||||
|
||||
export class TextPage extends Schema.Class<TextPage>("FileSystem.TextPage")({
|
||||
type: Schema.Literal("text-page"),
|
||||
content: Schema.String,
|
||||
mime: Schema.String,
|
||||
offset: PositiveInt,
|
||||
truncated: Schema.Boolean,
|
||||
next: PositiveInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class ReadTarget extends Schema.Class<ReadTarget>("FileSystem.ReadTarget")({
|
||||
real: Schema.String,
|
||||
resource: Schema.String,
|
||||
size: NonNegativeInt,
|
||||
dev: Schema.Number,
|
||||
ino: Schema.Number.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const ListInput = Schema.Struct({
|
||||
path: RelativePath.pipe(Schema.optional),
|
||||
reference: Schema.String.pipe(Schema.optional),
|
||||
reference: Schema.NonEmptyString.pipe(Schema.optional),
|
||||
})
|
||||
export type ListInput = typeof ListInput.Type
|
||||
|
||||
export class Entry extends Schema.Class<Entry>("LocationFileSystem.Entry")({
|
||||
export const ListPageInput = Schema.Struct({
|
||||
...ListInput.fields,
|
||||
offset: PositiveInt.pipe(Schema.optional),
|
||||
limit: PositiveInt.check(Schema.isLessThanOrEqualTo(2_000)).pipe(Schema.optional),
|
||||
})
|
||||
export type ListPageInput = typeof ListPageInput.Type
|
||||
|
||||
export class ListTarget extends Schema.Class<ListTarget>("FileSystem.ListTarget")({
|
||||
absolute: Schema.String,
|
||||
real: Schema.String,
|
||||
directory: Schema.String,
|
||||
root: Schema.String,
|
||||
resource: Schema.String,
|
||||
}) {}
|
||||
|
||||
/** Canonical read authority for Location-scoped search and metadata leaves. */
|
||||
export class RootTarget extends Schema.Class<RootTarget>("FileSystem.RootTarget")({
|
||||
absolute: Schema.String,
|
||||
real: Schema.String,
|
||||
directory: Schema.String,
|
||||
root: Schema.String,
|
||||
resource: Schema.String,
|
||||
reference: Schema.NonEmptyString.pipe(Schema.optional),
|
||||
type: Schema.Literals(["file", "directory"]),
|
||||
dev: Schema.Number,
|
||||
ino: Schema.Number.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export type ReadPathTarget =
|
||||
| { readonly type: "file"; readonly target: ReadTarget }
|
||||
| { readonly type: "directory"; readonly target: ListTarget }
|
||||
|
||||
export class Entry extends Schema.Class<Entry>("FileSystem.Entry")({
|
||||
path: RelativePath,
|
||||
uri: Schema.String,
|
||||
type: Schema.Literals(["file", "directory"]),
|
||||
mime: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class ListPage extends Schema.Class<ListPage>("FileSystem.ListPage")({
|
||||
entries: Schema.Array(Entry),
|
||||
truncated: Schema.Boolean,
|
||||
next: PositiveInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const FindInput = Schema.Struct({
|
||||
query: Schema.String,
|
||||
type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
|
||||
|
|
@ -63,7 +129,7 @@ export const GrepInput = Schema.Struct({
|
|||
})
|
||||
export type GrepInput = typeof GrepInput.Type
|
||||
|
||||
export class GrepMatch extends Schema.Class<GrepMatch>("LocationFileSystem.GrepMatch")({
|
||||
export class GrepMatch extends Schema.Class<GrepMatch>("FileSystem.GrepMatch")({
|
||||
path: RelativePath,
|
||||
lines: Schema.String,
|
||||
line: PositiveInt,
|
||||
|
|
@ -88,7 +154,21 @@ export const Event = {
|
|||
|
||||
export interface Interface {
|
||||
readonly read: (input: ReadInput) => Effect.Effect<Content>
|
||||
readonly resolveReadPath: (input: ReadInput) => Effect.Effect<ReadPathTarget>
|
||||
readonly resolveRead: (input: ReadInput) => Effect.Effect<ReadTarget>
|
||||
readonly readResolved: (target: ReadTarget, maximumBytes?: number) => Effect.Effect<Content>
|
||||
readonly readTextPageResolved: (target: ReadTarget, page?: TextPageInput) => Effect.Effect<TextPage>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
|
||||
/** Select a contained canonical read root without asserting leaf policy. */
|
||||
readonly resolveRoot: (input?: ListInput) => Effect.Effect<RootTarget>
|
||||
readonly revalidateRoot: (target: RootTarget) => Effect.Effect<RootTarget>
|
||||
readonly resolveList: (input?: ListInput) => Effect.Effect<ListTarget>
|
||||
readonly listResolved: (target: ListTarget) => Effect.Effect<Entry[]>
|
||||
readonly listPage: (input?: ListPageInput) => Effect.Effect<ListPage>
|
||||
readonly listPageResolved: (
|
||||
target: ListTarget,
|
||||
page?: Pick<ListPageInput, "offset" | "limit">,
|
||||
) => Effect.Effect<ListPage>
|
||||
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
|
||||
readonly grep: (input: GrepInput) => Effect.Effect<GrepMatch[]>
|
||||
readonly isIgnored: (path: RelativePath, type: "file" | "directory") => boolean
|
||||
|
|
@ -183,13 +263,36 @@ export const layer = Layer.effect(
|
|||
return [...files, ...dirs]
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
read: Effect.fn("FileSystem.read")(function* (input) {
|
||||
const file = yield* resolve(input.path, input.reference)
|
||||
const info = yield* fs.stat(file.real).pipe(Effect.orDie)
|
||||
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
||||
const bytes = yield* fs.readFile(file.real).pipe(Effect.orDie)
|
||||
const mime = FSUtil.mimeType(file.real)
|
||||
const resolveReadPath = Effect.fn("FileSystem.resolveReadPath")(function* (input: ReadInput) {
|
||||
const file = yield* resolve(input.path, input.reference)
|
||||
const info = yield* fs.stat(file.real).pipe(Effect.orDie)
|
||||
const relative = path.relative(file.root, file.real).replaceAll("\\", "/")
|
||||
const resource = input.reference === undefined ? relative || "." : `${input.reference}:${relative || "."}`
|
||||
if (info.type === "File") {
|
||||
return {
|
||||
type: "file" as const,
|
||||
target: new ReadTarget({
|
||||
real: file.real,
|
||||
resource,
|
||||
size: Number(info.size),
|
||||
dev: info.dev,
|
||||
ino: Option.getOrUndefined(info.ino),
|
||||
}),
|
||||
}
|
||||
}
|
||||
if (info.type === "Directory") {
|
||||
return { type: "directory" as const, target: new ListTarget({ ...file, resource }) }
|
||||
}
|
||||
return yield* Effect.die(new Error("Path is not a file or directory"))
|
||||
})
|
||||
const resolveRead = Effect.fn("FileSystem.resolveRead")(function* (input: ReadInput) {
|
||||
const resolved = yield* resolveReadPath(input)
|
||||
if (resolved.type !== "file") return yield* Effect.die(new Error("Path is not a file"))
|
||||
return resolved.target
|
||||
})
|
||||
const content = (target: ReadTarget, bytes: Uint8Array) =>
|
||||
Effect.gen(function* () {
|
||||
const mime = FSUtil.mimeType(target.real)
|
||||
if (!bytes.includes(0)) {
|
||||
const content = yield* Effect.sync(() => new TextDecoder("utf-8", { fatal: true }).decode(bytes)).pipe(
|
||||
Effect.option,
|
||||
|
|
@ -202,25 +305,222 @@ export const layer = Layer.effect(
|
|||
encoding: "base64",
|
||||
mime,
|
||||
})
|
||||
})
|
||||
const readResolved = Effect.fn("FileSystem.readResolved")(function* (target: ReadTarget, maximumBytes?: number) {
|
||||
if (maximumBytes === undefined) return yield* content(target, yield* fs.readFile(target.real).pipe(Effect.orDie))
|
||||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const file = yield* fs.open(target.real, { flag: "r" }).pipe(Effect.orDie)
|
||||
const info = yield* file.stat.pipe(Effect.orDie)
|
||||
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
||||
if (info.dev !== target.dev || Option.getOrUndefined(info.ino) !== target.ino)
|
||||
return yield* Effect.die(new Error("File changed after permission approval"))
|
||||
if (info.size > maximumBytes)
|
||||
return yield* Effect.die(new Error(`File exceeds ${maximumBytes} byte read limit`))
|
||||
const bytes = yield* file.readAlloc(maximumBytes + 1).pipe(Effect.orDie)
|
||||
if (bytes._tag === "Some" && bytes.value.length > maximumBytes)
|
||||
return yield* Effect.die(new Error(`File exceeds ${maximumBytes} byte read limit`))
|
||||
return yield* content(target, bytes._tag === "Some" ? bytes.value : new Uint8Array())
|
||||
}),
|
||||
)
|
||||
})
|
||||
const readTextPageResolved = Effect.fn("FileSystem.readTextPageResolved")(function* (
|
||||
target: ReadTarget,
|
||||
page: TextPageInput = {},
|
||||
) {
|
||||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const file = yield* fs.open(target.real, { flag: "r" }).pipe(Effect.orDie)
|
||||
const info = yield* file.stat.pipe(Effect.orDie)
|
||||
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
||||
if (info.dev !== target.dev || Option.getOrUndefined(info.ino) !== target.ino)
|
||||
return yield* Effect.die(new Error("File changed after permission approval"))
|
||||
|
||||
const offset = page.offset ?? 1
|
||||
const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES)
|
||||
const lines: string[] = []
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true })
|
||||
let pending = ""
|
||||
let discard = false
|
||||
let line = 1
|
||||
let bytes = 0
|
||||
let found = false
|
||||
let truncated = false
|
||||
let next: number | undefined
|
||||
|
||||
const append = (input: string) => {
|
||||
if (line < offset) {
|
||||
line++
|
||||
return true
|
||||
}
|
||||
if (lines.length >= limit) {
|
||||
truncated = true
|
||||
next = line
|
||||
return false
|
||||
}
|
||||
found = true
|
||||
const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input
|
||||
const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0)
|
||||
if (bytes + size > MAX_READ_BYTES) {
|
||||
truncated = true
|
||||
next = line
|
||||
return false
|
||||
}
|
||||
lines.push(text)
|
||||
bytes += size
|
||||
line++
|
||||
return true
|
||||
}
|
||||
|
||||
let done = false
|
||||
while (!done) {
|
||||
const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie)
|
||||
if (Option.isNone(chunk)) break
|
||||
if (chunk.value.includes(0)) return yield* Effect.die(new Error("Cannot page binary file"))
|
||||
let text = decoder.decode(chunk.value, { stream: true })
|
||||
while (true) {
|
||||
const index = text.indexOf("\n")
|
||||
if (index === -1) {
|
||||
if (!discard) {
|
||||
pending += text
|
||||
if (pending.length > MAX_LINE_LENGTH) {
|
||||
pending = pending.slice(0, MAX_LINE_LENGTH + 1)
|
||||
discard = true
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
const current = pending + (discard ? "" : text.slice(0, index))
|
||||
pending = ""
|
||||
discard = false
|
||||
text = text.slice(index + 1)
|
||||
if (!append(current.endsWith("\r") ? current.slice(0, -1) : current)) {
|
||||
done = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!done) {
|
||||
const tail = decoder.decode()
|
||||
if (!discard) pending += tail
|
||||
if (pending && !append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)) done = true
|
||||
}
|
||||
if (!done && !found && offset !== 1) return yield* Effect.die(new Error(`Offset ${offset} is out of range`))
|
||||
|
||||
return new TextPage({
|
||||
type: "text-page",
|
||||
content: lines.join("\n"),
|
||||
mime: FSUtil.mimeType(target.real),
|
||||
offset,
|
||||
truncated,
|
||||
...(next === undefined ? {} : { next }),
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
const resolveList = Effect.fn("FileSystem.resolveList")(function* (input: ListInput = {}) {
|
||||
const directory = yield* resolve(input.path, input.reference)
|
||||
const info = yield* fs.stat(directory.real).pipe(Effect.orDie)
|
||||
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
|
||||
const relative = path.relative(directory.root, directory.real).replaceAll("\\", "/") || "."
|
||||
return new ListTarget({
|
||||
...directory,
|
||||
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
|
||||
})
|
||||
})
|
||||
const resolveRoot = Effect.fn("FileSystem.resolveRoot")(function* (input: ListInput = {}) {
|
||||
const target = yield* resolve(input.path, input.reference)
|
||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
||||
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
|
||||
if (!type) return yield* Effect.die(new Error("Path is not a file or directory"))
|
||||
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
|
||||
return new RootTarget({
|
||||
...target,
|
||||
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
|
||||
reference: input.reference,
|
||||
type,
|
||||
dev: info.dev,
|
||||
ino: Option.getOrUndefined(info.ino),
|
||||
})
|
||||
})
|
||||
const revalidateRoot = Effect.fn("FileSystem.revalidateRoot")(function* (target: RootTarget) {
|
||||
const canonical = yield* fs.realPath(target.absolute).pipe(Effect.orDie)
|
||||
if (canonical !== target.real) return yield* Effect.die(new Error("Search root changed after approval"))
|
||||
const info = yield* fs.stat(canonical).pipe(Effect.orDie)
|
||||
if (info.type !== (target.type === "file" ? "File" : "Directory") || info.dev !== target.dev || Option.getOrUndefined(info.ino) !== target.ino)
|
||||
return yield* Effect.die(new Error("Search root identity changed after approval"))
|
||||
return target
|
||||
})
|
||||
const listResolved = Effect.fn("FileSystem.listResolved")(function* (directory: ListTarget) {
|
||||
return yield* fs.readDirectoryEntries(directory.real).pipe(
|
||||
Effect.orDie,
|
||||
Effect.flatMap((items) =>
|
||||
Effect.forEach(items, (item) => entry(path.join(directory.absolute, item.name), directory), {
|
||||
concurrency: "unbounded",
|
||||
}),
|
||||
),
|
||||
Effect.map((items) =>
|
||||
items
|
||||
.filter((item): item is Entry => item !== undefined)
|
||||
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
|
||||
),
|
||||
)
|
||||
})
|
||||
const listPageResolved = Effect.fn("FileSystem.listPageResolved")(function* (
|
||||
target: ListTarget,
|
||||
page: Pick<ListPageInput, "offset" | "limit"> = {},
|
||||
) {
|
||||
type Candidate = Entry | { readonly name: string; readonly type: "file" | "directory" }
|
||||
const offset = page.offset ?? 1
|
||||
const limit = Math.min(page.limit ?? 2_000, 2_000)
|
||||
const items = yield* fs.readDirectoryEntries(target.real).pipe(Effect.orDie)
|
||||
const candidates = yield* Effect.forEach(
|
||||
items,
|
||||
(item): Effect.Effect<Candidate | undefined> => {
|
||||
if (item.type === "other") return Effect.succeed(undefined)
|
||||
if (item.type === "symlink") return entry(path.join(target.absolute, item.name), target)
|
||||
return Effect.succeed({ name: item.name, type: item.type } as const)
|
||||
},
|
||||
{ concurrency: 16 },
|
||||
).pipe(Effect.map((items) => items.filter((item): item is Candidate => item !== undefined)))
|
||||
candidates.sort((a, b) => {
|
||||
return a.type === b.type
|
||||
? (a instanceof Entry ? a.path : a.name).localeCompare(b instanceof Entry ? b.path : b.name)
|
||||
: a.type === "directory"
|
||||
? -1
|
||||
: 1
|
||||
})
|
||||
const selected = candidates.slice(offset - 1, offset - 1 + limit)
|
||||
const entries = yield* Effect.forEach(
|
||||
selected,
|
||||
(item) => (item instanceof Entry ? Effect.succeed(item) : entry(path.join(target.absolute, item.name), target)),
|
||||
{
|
||||
concurrency: 16,
|
||||
},
|
||||
).pipe(Effect.map((items) => items.filter((item): item is Entry => item !== undefined)))
|
||||
const truncated = offset - 1 + selected.length < candidates.length
|
||||
return new ListPage({ entries, truncated, ...(truncated ? { next: offset + selected.length } : {}) })
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
read: Effect.fn("FileSystem.read")(function* (input) {
|
||||
return yield* readResolved(yield* resolveRead(input))
|
||||
}),
|
||||
list: Effect.fn("FileSystem.list")(function* (input = {}) {
|
||||
const directory = yield* resolve(input.path, input.reference)
|
||||
const info = yield* fs.stat(directory.real).pipe(Effect.orDie)
|
||||
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
|
||||
return yield* fs.readDirectoryEntries(directory.real).pipe(
|
||||
Effect.orDie,
|
||||
Effect.flatMap((items) =>
|
||||
Effect.forEach(items, (item) => entry(path.join(directory.absolute, item.name), directory), {
|
||||
concurrency: "unbounded",
|
||||
}),
|
||||
),
|
||||
Effect.map((items) =>
|
||||
items
|
||||
.filter((item): item is Entry => item !== undefined)
|
||||
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
|
||||
),
|
||||
)
|
||||
resolveReadPath,
|
||||
resolveRead,
|
||||
readResolved,
|
||||
readTextPageResolved,
|
||||
list: Effect.fn("FileSystem.list")(function* (input) {
|
||||
return yield* listResolved(yield* resolveList(input))
|
||||
}),
|
||||
resolveRoot,
|
||||
revalidateRoot,
|
||||
resolveList,
|
||||
listResolved,
|
||||
listPage: Effect.fn("FileSystem.listPage")(function* (input) {
|
||||
return yield* listPageResolved(yield* resolveList(input), input)
|
||||
}),
|
||||
listPageResolved,
|
||||
find: Effect.fn("FileSystem.find")(function* (input) {
|
||||
const items = (yield* scan()).filter((item) => input.type !== "file" || !item.endsWith("/"))
|
||||
const filtered = items.filter((item) => input.type !== "directory" || item.endsWith("/"))
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ export interface TreeInput {
|
|||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly filepath: Effect.Effect<string, Error>
|
||||
readonly files: (input: FilesInput) => Stream.Stream<string, PlatformError | Error>
|
||||
readonly tree: (input: TreeInput) => Effect.Effect<string, PlatformError | Error>
|
||||
readonly search: (input: SearchInput) => Effect.Effect<SearchResult, PlatformError | Error>
|
||||
|
|
@ -471,7 +472,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | ChildProcessSpa
|
|||
return lines.join("\n")
|
||||
})
|
||||
|
||||
return Service.of({ files, tree, search })
|
||||
return Service.of({ filepath, files, tree, search })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Config } from "effect"
|
||||
|
||||
function truthy(key: string) {
|
||||
export function truthy(key: string) {
|
||||
const value = process.env[key]?.toLowerCase()
|
||||
return value === "true" || value === "1"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { dirname, join, relative, resolve as pathResolve } from "path"
|
||||
import { dirname, isAbsolute, join, relative, resolve as pathResolve, sep } from "path"
|
||||
import { realpathSync } from "fs"
|
||||
import * as NFS from "fs/promises"
|
||||
import { lookup } from "mime-types"
|
||||
|
|
@ -236,12 +236,11 @@ export namespace FSUtil {
|
|||
}
|
||||
|
||||
export function overlaps(a: string, b: string) {
|
||||
const relA = relative(a, b)
|
||||
const relB = relative(b, a)
|
||||
return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
|
||||
return contains(a, b) || contains(b, a)
|
||||
}
|
||||
|
||||
export function contains(parent: string, child: string) {
|
||||
return !relative(parent, child).startsWith("..")
|
||||
const result = relative(parent, child)
|
||||
return result === "" || (!isAbsolute(result) && result !== ".." && !result.startsWith(`..${sep}`))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,18 +16,35 @@ import { Global } from "./global"
|
|||
import { Database } from "./database/database"
|
||||
import { PermissionV2 } from "./permission"
|
||||
import { PermissionSaved } from "./permission/saved"
|
||||
import { SessionV2 } from "./session"
|
||||
import { FileSystem } from "./filesystem"
|
||||
import { Watcher } from "./filesystem/watcher"
|
||||
import { LocationMutation } from "./location-mutation"
|
||||
import { LocationSearch } from "./location-search"
|
||||
import { FileMutation } from "./file-mutation"
|
||||
import { ProjectReference } from "./project-reference"
|
||||
import { RepositoryCache } from "./repository-cache"
|
||||
import { Pty } from "./pty"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { BuiltInTools } from "./tool/builtins"
|
||||
import { ToolRegistry } from "./tool-registry"
|
||||
import { ToolOutputStore } from "./tool-output-store"
|
||||
import { AppProcess } from "./process"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
import { SessionStore } from "./session/store"
|
||||
import { SessionTodo } from "./session/todo"
|
||||
import { QuestionV2 } from "./question"
|
||||
import { LLMClient } from "@opencode-ai/llm"
|
||||
import { RequestExecutor } from "@opencode-ai/llm/route"
|
||||
import * as SessionRunnerLLM from "./session/runner/llm"
|
||||
import { SessionRunnerModel } from "./session/runner/model"
|
||||
import { SessionRunCoordinator } from "./session/run-coordinator"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
|
||||
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
|
||||
lookup: (ref: Location.Ref) => {
|
||||
const location = Location.layer(ref)
|
||||
return Layer.mergeAll(
|
||||
const permissionsAndTools = ToolRegistry.layer.pipe(Layer.provideMerge(PermissionV2.locationLayer))
|
||||
const services = Layer.mergeAll(
|
||||
location,
|
||||
Policy.locationLayer,
|
||||
Config.locationLayer,
|
||||
|
|
@ -36,12 +53,41 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
|||
Catalog.locationLayer,
|
||||
AgentV2.locationLayer,
|
||||
PluginBoot.locationLayer,
|
||||
PermissionV2.locationLayer,
|
||||
FileSystem.locationLayer,
|
||||
Watcher.locationLayer,
|
||||
Pty.locationLayer,
|
||||
SkillV2.locationLayer,
|
||||
).pipe(Layer.provideMerge(location), Layer.fresh)
|
||||
permissionsAndTools,
|
||||
LocationMutation.locationLayer.pipe(Layer.orDie),
|
||||
).pipe(Layer.provideMerge(location))
|
||||
const commits = FileMutation.locationLayer.pipe(Layer.provide(services))
|
||||
const searches = LocationSearch.layer.pipe(Layer.provide(Ripgrep.layer), Layer.provide(services))
|
||||
const resources = ToolOutputStore.layer.pipe(Layer.provide(services))
|
||||
const todos = SessionTodo.layer.pipe(Layer.provide(services))
|
||||
const questions = QuestionV2.locationLayer.pipe(Layer.provide(services))
|
||||
const builtInTools = BuiltInTools.locationLayer.pipe(
|
||||
Layer.provide(services),
|
||||
Layer.provide(commits),
|
||||
Layer.provide(searches),
|
||||
Layer.provide(resources),
|
||||
Layer.provide(todos),
|
||||
Layer.provide(questions),
|
||||
)
|
||||
const model = SessionRunnerModel.locationLayer.pipe(Layer.provide(services))
|
||||
const runner = SessionRunnerLLM.defaultLayer.pipe(Layer.provide(services), Layer.provide(model))
|
||||
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
|
||||
return Layer.mergeAll(
|
||||
services,
|
||||
commits,
|
||||
searches,
|
||||
resources,
|
||||
todos,
|
||||
questions,
|
||||
model,
|
||||
runner,
|
||||
coordinator,
|
||||
builtInTools,
|
||||
).pipe(Layer.fresh)
|
||||
},
|
||||
idleTimeToLive: "60 minutes",
|
||||
dependencies: [
|
||||
|
|
@ -51,10 +97,14 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
|||
Npm.defaultLayer,
|
||||
ModelsDev.defaultLayer,
|
||||
FSUtil.defaultLayer,
|
||||
AppProcess.defaultLayer,
|
||||
Global.defaultLayer,
|
||||
Database.defaultLayer,
|
||||
SessionV2.defaultLayer,
|
||||
SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
|
||||
PermissionSaved.defaultLayer,
|
||||
RepositoryCache.defaultLayer,
|
||||
LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)),
|
||||
FetchHttpClient.layer,
|
||||
ToolOutputStore.defaultCleanupLayer,
|
||||
],
|
||||
}) {}
|
||||
|
|
|
|||
305
packages/core/src/location-mutation.ts
Normal file
305
packages/core/src/location-mutation.ts
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
export * as LocationMutation from "./location-mutation"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Location } from "./location"
|
||||
|
||||
export const Kind = Schema.Literals(["file", "directory"])
|
||||
export type Kind = typeof Kind.Type
|
||||
|
||||
/**
|
||||
* Mutation paths do not accept project references. Relative paths must stay
|
||||
* inside the active Location. Absolute paths outside it require separate
|
||||
* `external_directory` approval.
|
||||
*/
|
||||
export const ResolveInput = Schema.Struct({
|
||||
path: Schema.String,
|
||||
/** Selects the external approval boundary; it does not validate the target type. */
|
||||
kind: Kind.pipe(Schema.optional),
|
||||
})
|
||||
export type ResolveInput = typeof ResolveInput.Type
|
||||
|
||||
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
|
||||
path: Schema.String,
|
||||
reason: Schema.Literals([
|
||||
"relative_escape",
|
||||
"location_escape",
|
||||
"non_directory_ancestor",
|
||||
"unresolved_symlink",
|
||||
"location_identity_changed",
|
||||
]),
|
||||
}) {}
|
||||
|
||||
export class RevalidationError extends Schema.TaggedErrorClass<RevalidationError>()(
|
||||
"LocationMutation.RevalidationError",
|
||||
{
|
||||
path: Schema.String,
|
||||
reason: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export interface Identity {
|
||||
/** Canonical path for this saved filesystem identity. */
|
||||
readonly canonical: string
|
||||
readonly dev: number
|
||||
readonly ino?: number
|
||||
}
|
||||
|
||||
export interface ExternalDirectoryAuthorization {
|
||||
readonly action: "external_directory"
|
||||
/** Canonical existing directory used as the external approval boundary. */
|
||||
readonly directory: string
|
||||
/** `external_directory` permission resource. */
|
||||
readonly resource: string
|
||||
readonly save: string
|
||||
/** Saved identity checked again after approval to detect swaps. */
|
||||
readonly authority: Identity
|
||||
}
|
||||
|
||||
/** Build the `external_directory` permission request. */
|
||||
export const externalDirectoryPermission = (input: ExternalDirectoryAuthorization) => ({
|
||||
action: input.action,
|
||||
resources: [input.resource],
|
||||
save: [input.save],
|
||||
})
|
||||
|
||||
export interface Target {
|
||||
/** Canonical existing path, or missing path below a canonical directory. */
|
||||
readonly canonical: string
|
||||
readonly exists: boolean
|
||||
readonly type?:
|
||||
| "File"
|
||||
| "Directory"
|
||||
| "SymbolicLink"
|
||||
| "BlockDevice"
|
||||
| "CharacterDevice"
|
||||
| "FIFO"
|
||||
| "Socket"
|
||||
| "Unknown"
|
||||
/** Permission resource: Location-relative for internal paths, canonical for external paths. */
|
||||
readonly resource: string
|
||||
readonly externalDirectory?: ExternalDirectoryAuthorization
|
||||
}
|
||||
|
||||
/**
|
||||
* A path checked before permission approval.
|
||||
*
|
||||
* resolve(path) -> Plan -> approve -> revalidate(plan) -> mutate immediately
|
||||
*
|
||||
* Tools must approve `target.externalDirectory`, when present, and their normal
|
||||
* mutation action before calling `revalidate`. Revalidation rejects escapes,
|
||||
* symlinks in missing suffixes, and changes made while approval is pending. It
|
||||
* cannot be atomic with the next filesystem call, so mutate immediately afterward.
|
||||
*/
|
||||
export interface Plan {
|
||||
readonly input: ResolveInput
|
||||
readonly target: Target
|
||||
/** Saved identity of the existing target or nearest existing ancestor. */
|
||||
readonly authority: Identity
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/**
|
||||
* Check a path before approval and derive its permission resources. Relative
|
||||
* paths must stay inside the Location. Absolute paths outside it require
|
||||
* separate `external_directory` approval. This does not approve the tool's
|
||||
* mutation action.
|
||||
*/
|
||||
readonly resolve: (input: ResolveInput) => Effect.Effect<Plan, PathError | FSUtil.Error>
|
||||
/**
|
||||
* Check the plan again immediately before mutation. Reject changes to the
|
||||
* target, its saved identity, or approval resources. Mutate the returned
|
||||
* target immediately.
|
||||
*/
|
||||
readonly revalidate: (plan: Plan) => Effect.Effect<Target, RevalidationError | FSUtil.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationMutation") {}
|
||||
|
||||
interface ResolvedPath {
|
||||
readonly canonical: string
|
||||
readonly exists: boolean
|
||||
readonly type?: Target["type"]
|
||||
readonly authority: Identity
|
||||
}
|
||||
|
||||
const slash = (value: string) => value.replaceAll("\\", "/")
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const locationRoot = yield* fs.realPath(location.directory)
|
||||
const locationAuthority = yield* identity(locationRoot)
|
||||
|
||||
function identityFrom(canonical: string, info: Effect.Success<ReturnType<typeof fs.stat>>): Identity {
|
||||
return {
|
||||
canonical,
|
||||
dev: info.dev,
|
||||
ino: Option.getOrUndefined(info.ino),
|
||||
}
|
||||
}
|
||||
|
||||
function identity(canonical: string) {
|
||||
return fs.stat(canonical).pipe(Effect.map((info) => identityFrom(canonical, info)))
|
||||
}
|
||||
|
||||
function notFound<A>(effect: Effect.Effect<A, FSUtil.Error>) {
|
||||
return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||
}
|
||||
|
||||
function sameIdentity(left: Identity, right: Identity) {
|
||||
return left.canonical === right.canonical && left.dev === right.dev && left.ino === right.ino
|
||||
}
|
||||
|
||||
/** Check whether a saved path still points to the same filesystem object. */
|
||||
const assertIdentity = Effect.fnUntraced(function* (expected: Identity) {
|
||||
const canonical = yield* notFound(fs.realPath(expected.canonical))
|
||||
if (canonical === undefined) return false
|
||||
const actual = yield* notFound(identity(canonical))
|
||||
if (actual === undefined) return false
|
||||
return canonical === expected.canonical && sameIdentity(expected, actual)
|
||||
})
|
||||
|
||||
const assertLocationIdentity = Effect.fnUntraced(function* (requested: string) {
|
||||
if (yield* assertIdentity(locationAuthority)) return
|
||||
return yield* new PathError({ path: requested, reason: "location_identity_changed" })
|
||||
})
|
||||
|
||||
const hasUnresolvedSymlink = Effect.fnUntraced(function* (anchor: string, suffix: string) {
|
||||
let current = anchor
|
||||
for (const part of suffix.split(path.sep)) {
|
||||
if (!part) continue
|
||||
current = path.join(current, part)
|
||||
if (
|
||||
yield* fs.readLink(current).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
)
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolve a path to a canonical target and save an existing filesystem
|
||||
* identity for later revalidation.
|
||||
*
|
||||
* existing path -> save target identity
|
||||
* missing path -> save nearest existing directory identity
|
||||
*
|
||||
* Missing suffixes must not contain symlinks.
|
||||
*/
|
||||
const resolvePath = Effect.fnUntraced(function* (absolute: string) {
|
||||
const existing = yield* notFound(fs.realPath(absolute))
|
||||
if (existing !== undefined) {
|
||||
const info = yield* fs.stat(existing)
|
||||
return {
|
||||
canonical: existing,
|
||||
exists: true,
|
||||
type: info.type,
|
||||
authority: identityFrom(existing, info),
|
||||
} satisfies ResolvedPath
|
||||
}
|
||||
|
||||
let anchor = path.dirname(absolute)
|
||||
while (true) {
|
||||
const canonical = yield* notFound(fs.realPath(anchor))
|
||||
if (canonical !== undefined) {
|
||||
const info = yield* fs.stat(canonical)
|
||||
if (info.type !== "Directory")
|
||||
return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
|
||||
const suffix = path.relative(anchor, absolute)
|
||||
if (yield* hasUnresolvedSymlink(anchor, suffix)) {
|
||||
return yield* new PathError({ path: absolute, reason: "unresolved_symlink" })
|
||||
}
|
||||
return {
|
||||
canonical: path.resolve(canonical, suffix),
|
||||
exists: false,
|
||||
authority: identityFrom(canonical, info),
|
||||
} satisfies ResolvedPath
|
||||
}
|
||||
const parent = path.dirname(anchor)
|
||||
if (parent === anchor) return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
|
||||
anchor = parent
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Choose the existing directory used for separate external approval.
|
||||
*
|
||||
* existing directory target -> "<target>/*"
|
||||
* file or missing target -> "<nearest existing parent>/*"
|
||||
*/
|
||||
const externalDirectory = Effect.fnUntraced(function* (resolved: ResolvedPath, kind: Kind) {
|
||||
const candidate =
|
||||
kind === "directory" && resolved.type === "Directory" ? resolved.canonical : path.dirname(resolved.canonical)
|
||||
const boundary = yield* resolvePath(candidate)
|
||||
const directory =
|
||||
boundary.exists && boundary.type === "Directory" ? boundary.canonical : boundary.authority.canonical
|
||||
const resource = slash(path.join(directory, "*"))
|
||||
return { action: "external_directory" as const, directory, resource, save: resource, authority: boundary.authority }
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
||||
yield* assertLocationIdentity(input.path)
|
||||
const relative = !path.isAbsolute(input.path)
|
||||
const absolute = path.resolve(location.directory, input.path)
|
||||
const lexicallyInternal = FSUtil.contains(location.directory, absolute)
|
||||
if (relative && !lexicallyInternal) return yield* new PathError({ path: input.path, reason: "relative_escape" })
|
||||
|
||||
const resolved = yield* resolvePath(absolute)
|
||||
if (lexicallyInternal && !FSUtil.contains(locationRoot, resolved.canonical)) {
|
||||
return yield* new PathError({ path: input.path, reason: "location_escape" })
|
||||
}
|
||||
|
||||
const external = !lexicallyInternal
|
||||
const resource = external
|
||||
? slash(resolved.canonical)
|
||||
: slash(path.relative(locationRoot, resolved.canonical) || ".")
|
||||
const target: Target = {
|
||||
canonical: resolved.canonical,
|
||||
exists: resolved.exists,
|
||||
type: resolved.type,
|
||||
resource,
|
||||
externalDirectory: external ? yield* externalDirectory(resolved, input.kind ?? "file") : undefined,
|
||||
}
|
||||
return { input, target, authority: resolved.authority } satisfies Plan
|
||||
})
|
||||
|
||||
/**
|
||||
* Re-resolve a plan immediately before mutation and reject any changed
|
||||
* identity, target, or approval resource. This reduces the race window but
|
||||
* cannot make the next filesystem call atomic.
|
||||
*/
|
||||
const revalidate = Effect.fn("LocationMutation.revalidate")(function* (plan: Plan) {
|
||||
const invalid = (reason: string) => new RevalidationError({ path: plan.input.path, reason })
|
||||
const fresh = yield* resolve(plan.input).pipe(
|
||||
Effect.mapError((error) => (error instanceof PathError ? invalid(error.reason) : error)),
|
||||
)
|
||||
if (!sameIdentity(fresh.authority, plan.authority)) return yield* invalid("mutation authority changed")
|
||||
if (fresh.target.canonical !== plan.target.canonical) return yield* invalid("canonical mutation target changed")
|
||||
if (fresh.target.resource !== plan.target.resource) return yield* invalid("mutation resource changed")
|
||||
if (Boolean(fresh.target.externalDirectory) !== Boolean(plan.target.externalDirectory)) {
|
||||
return yield* invalid("external directory authority changed")
|
||||
}
|
||||
if (
|
||||
fresh.target.externalDirectory &&
|
||||
plan.target.externalDirectory &&
|
||||
(fresh.target.externalDirectory.directory !== plan.target.externalDirectory.directory ||
|
||||
fresh.target.externalDirectory.resource !== plan.target.externalDirectory.resource ||
|
||||
!sameIdentity(fresh.target.externalDirectory.authority, plan.target.externalDirectory.authority))
|
||||
) {
|
||||
return yield* invalid("external directory authority changed")
|
||||
}
|
||||
return fresh.target
|
||||
})
|
||||
|
||||
return Service.of({ resolve, revalidate })
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
195
packages/core/src/location-search.ts
Normal file
195
packages/core/src/location-search.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
export * as LocationSearch from "./location-search"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { FileSystem } from "./filesystem"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
||||
|
||||
/**
|
||||
* Location-scoped raw search substrate. Search authority is selected only by
|
||||
* FileSystem, preserving Location-relative paths and named read
|
||||
* references. Model formatting, leaf-tool permissions, and HTTP transport stay
|
||||
* outside this service so future GlobTool, GrepTool, and HTTP consumers can
|
||||
* share the same bounded filesystem behavior.
|
||||
*
|
||||
* TODO: Expose this substrate through HTTP fs.search/fs.grep endpoints.
|
||||
* TODO: Reuse this substrate for instruction and skill discovery where suitable.
|
||||
*/
|
||||
|
||||
export const DEFAULT_RESULT_LIMIT = 100
|
||||
export const MAX_RESULT_LIMIT = 100
|
||||
export const MAX_LINE_PREVIEW_LENGTH = 2_000
|
||||
|
||||
export const ResultLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_RESULT_LIMIT))
|
||||
|
||||
const RootInput = {
|
||||
path: RelativePath.pipe(Schema.optional),
|
||||
reference: Schema.NonEmptyString.pipe(Schema.optional),
|
||||
}
|
||||
|
||||
export const FilesInput = Schema.Struct({
|
||||
pattern: Schema.String,
|
||||
...RootInput,
|
||||
limit: ResultLimit.pipe(Schema.optional),
|
||||
})
|
||||
export type FilesInput = typeof FilesInput.Type & { readonly signal?: AbortSignal }
|
||||
|
||||
export const GrepInput = Schema.Struct({
|
||||
pattern: Schema.String,
|
||||
include: Schema.String.pipe(Schema.optional),
|
||||
...RootInput,
|
||||
limit: ResultLimit.pipe(Schema.optional),
|
||||
})
|
||||
export type GrepInput = typeof GrepInput.Type & { readonly signal?: AbortSignal }
|
||||
|
||||
export class File extends Schema.Class<File>("LocationSearch.File")({
|
||||
path: RelativePath,
|
||||
canonical: Schema.String,
|
||||
resource: Schema.String,
|
||||
mtime: Schema.Number,
|
||||
}) {}
|
||||
|
||||
export class Submatch extends Schema.Class<Submatch>("LocationSearch.Submatch")({
|
||||
text: Schema.String,
|
||||
start: NonNegativeInt,
|
||||
end: NonNegativeInt,
|
||||
}) {}
|
||||
|
||||
export class Match extends Schema.Class<Match>("LocationSearch.Match")({
|
||||
path: RelativePath,
|
||||
canonical: Schema.String,
|
||||
resource: Schema.String,
|
||||
lines: Schema.String,
|
||||
linePreviewTruncated: Schema.Boolean,
|
||||
line: PositiveInt,
|
||||
offset: NonNegativeInt,
|
||||
submatches: Schema.Array(Submatch),
|
||||
mtime: Schema.Number,
|
||||
}) {}
|
||||
|
||||
export class FilesResult extends Schema.Class<FilesResult>("LocationSearch.FilesResult")({
|
||||
items: Schema.Array(File),
|
||||
truncated: Schema.Boolean,
|
||||
partial: Schema.Boolean,
|
||||
}) {}
|
||||
|
||||
export class GrepResult extends Schema.Class<GrepResult>("LocationSearch.GrepResult")({
|
||||
items: Schema.Array(Match),
|
||||
truncated: Schema.Boolean,
|
||||
partial: Schema.Boolean,
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly files: (input: FilesInput, root?: FileSystem.RootTarget) => Effect.Effect<FilesResult, Ripgrep.Error>
|
||||
readonly grep: (input: GrepInput, root?: FileSystem.RootTarget) => Effect.Effect<GrepResult, Ripgrep.Error | Ripgrep.InvalidPatternError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationSearch") {}
|
||||
|
||||
const slash = (value: string) => value.replaceAll("\\", "/")
|
||||
const cap = (limit?: number) => Math.min(limit ?? DEFAULT_RESULT_LIMIT, MAX_RESULT_LIMIT)
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
|
||||
const candidate = Effect.fnUntraced(function* (root: FileSystem.RootTarget, cwd: string, value: string) {
|
||||
const absolute = path.resolve(cwd, value)
|
||||
const lexicallyContained =
|
||||
root.type === "directory" ? FSUtil.contains(root.real, absolute) : absolute === root.real
|
||||
if (!lexicallyContained) return
|
||||
const canonical = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
|
||||
if (!canonical || !FSUtil.contains(root.root, canonical)) return
|
||||
const info = yield* fs.stat(canonical).pipe(Effect.catch(() => Effect.void))
|
||||
if (!info || info.type !== "File") return
|
||||
const relative = slash(path.relative(root.root, canonical))
|
||||
return {
|
||||
path: RelativePath.make(relative),
|
||||
canonical,
|
||||
resource: root.reference === undefined ? relative : `${root.reference}:${relative}`,
|
||||
mtime: info.mtime.pipe(
|
||||
Option.map((date) => date.getTime()),
|
||||
Option.getOrElse(() => 0),
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
files: Effect.fn("LocationSearch.files")(function* (input, approvedRoot) {
|
||||
const root = yield* filesystem.revalidateRoot(approvedRoot ?? (yield* filesystem.resolveRoot(input)))
|
||||
if (root.type !== "directory")
|
||||
return yield* Effect.die(new globalThis.Error("Files search path must be a directory"))
|
||||
const result = yield* ripgrep.files({
|
||||
cwd: root.real,
|
||||
pattern: input.pattern,
|
||||
limit: cap(input.limit),
|
||||
signal: input.signal,
|
||||
})
|
||||
const mapped = yield* Effect.forEach(result.items, (item) => candidate(root, root.real, item), {
|
||||
concurrency: 16,
|
||||
})
|
||||
const items = mapped.filter((item): item is File => item !== undefined).map((item) => new File(item))
|
||||
// TODO: Decide result ordering policy: V1 mtime sorting versus stable path ordering.
|
||||
// TODO: Report inaccessible paths discovered after bounded ripgrep termination when practical.
|
||||
return new FilesResult({
|
||||
items,
|
||||
truncated: result.truncated,
|
||||
partial: result.partial || items.length !== result.items.length,
|
||||
})
|
||||
}),
|
||||
grep: Effect.fn("LocationSearch.grep")(function* (input, approvedRoot) {
|
||||
const root = yield* filesystem.revalidateRoot(approvedRoot ?? (yield* filesystem.resolveRoot(input)))
|
||||
const cwd = root.type === "directory" ? root.real : path.dirname(root.real)
|
||||
const result = yield* ripgrep.grep({
|
||||
cwd,
|
||||
pattern: input.pattern,
|
||||
include: input.include,
|
||||
file: root.type === "file" ? path.basename(root.real) : undefined,
|
||||
limit: cap(input.limit),
|
||||
signal: input.signal,
|
||||
})
|
||||
const candidates = new Map<string, ReturnType<typeof candidate>>()
|
||||
for (const item of result.items) {
|
||||
if (!candidates.has(item.path.text)) {
|
||||
candidates.set(item.path.text, yield* Effect.cached(candidate(root, cwd, item.path.text)))
|
||||
}
|
||||
}
|
||||
const mapped = yield* Effect.forEach(
|
||||
result.items,
|
||||
(item) =>
|
||||
candidates.get(item.path.text)!.pipe(
|
||||
Effect.map(
|
||||
(file) =>
|
||||
file &&
|
||||
new Match({
|
||||
...file,
|
||||
lines: item.lines.text.slice(0, MAX_LINE_PREVIEW_LENGTH),
|
||||
linePreviewTruncated: item.lines.text.length > MAX_LINE_PREVIEW_LENGTH,
|
||||
line: item.line_number,
|
||||
offset: item.absolute_offset,
|
||||
submatches: item.submatches.map(
|
||||
(submatch) =>
|
||||
new Submatch({ text: submatch.match.text, start: submatch.start, end: submatch.end }),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
{ concurrency: 16 },
|
||||
)
|
||||
const items = mapped.filter((item): item is Match => item !== undefined)
|
||||
// TODO: Decide result ordering policy: V1 mtime sorting versus stable path ordering.
|
||||
// TODO: Report inaccessible paths discovered after bounded ripgrep termination when practical.
|
||||
return new GrepResult({
|
||||
items,
|
||||
truncated: result.truncated,
|
||||
partial: result.partial || items.length !== result.items.length,
|
||||
})
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
@ -1,18 +1,19 @@
|
|||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Project } from "./project"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { WorkspaceV2 } from "./workspace"
|
||||
|
||||
export * as Location from "./location"
|
||||
|
||||
export const Ref = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
workspaceID: Schema.optional(Schema.String),
|
||||
workspaceID: Schema.optional(WorkspaceV2.ID),
|
||||
}).annotate({ identifier: "Location.Ref" })
|
||||
export type Ref = typeof Ref.Type
|
||||
|
||||
export interface Interface {
|
||||
readonly directory: AbsolutePath
|
||||
readonly workspaceID?: string
|
||||
readonly workspaceID?: WorkspaceV2.ID
|
||||
readonly project: {
|
||||
readonly id: Project.ID
|
||||
readonly directory: AbsolutePath
|
||||
|
|
|
|||
|
|
@ -52,6 +52,18 @@ export const Api = Schema.Union([
|
|||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Api = typeof Api.Type
|
||||
|
||||
export const PublicApi = Schema.Union([
|
||||
Schema.Struct({
|
||||
id: ID,
|
||||
...ProviderV2.PublicAISDK.fields,
|
||||
}),
|
||||
Schema.Struct({
|
||||
id: ID,
|
||||
...ProviderV2.PublicNative.fields,
|
||||
}),
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type PublicApi = typeof PublicApi.Type
|
||||
|
||||
export class Info extends Schema.Class<Info>("ModelV2.Info")({
|
||||
id: ID,
|
||||
providerID: ProviderV2.ID,
|
||||
|
|
@ -113,6 +125,64 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
|
|||
}
|
||||
}
|
||||
|
||||
export class PublicInfo extends Schema.Class<PublicInfo>("ModelV2.PublicInfo")({
|
||||
id: ID,
|
||||
providerID: ProviderV2.ID,
|
||||
family: Family.pipe(Schema.optional),
|
||||
name: Schema.String,
|
||||
api: PublicApi,
|
||||
capabilities: Capabilities,
|
||||
variants: Schema.Struct({
|
||||
id: VariantID,
|
||||
}).pipe(Schema.Array),
|
||||
time: Schema.Struct({
|
||||
released: DateTimeUtcFromMillis,
|
||||
}),
|
||||
cost: Cost.pipe(Schema.Array),
|
||||
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
|
||||
enabled: Schema.Boolean,
|
||||
limit: Schema.Struct({
|
||||
context: Schema.Int,
|
||||
input: Schema.Int.pipe(Schema.optional),
|
||||
output: Schema.Int,
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export function toPublic(info: Info): PublicInfo {
|
||||
const api =
|
||||
info.api.type === "aisdk"
|
||||
? {
|
||||
id: info.api.id,
|
||||
type: info.api.type,
|
||||
package: info.api.package,
|
||||
url: ProviderV2.sanitizePublicUrl(info.api.url),
|
||||
}
|
||||
: { id: info.api.id, type: info.api.type, url: ProviderV2.sanitizePublicUrl(info.api.url) }
|
||||
return new PublicInfo({
|
||||
id: info.id,
|
||||
providerID: info.providerID,
|
||||
family: info.family,
|
||||
name: info.name,
|
||||
api,
|
||||
capabilities: {
|
||||
tools: info.capabilities.tools,
|
||||
input: [...info.capabilities.input],
|
||||
output: [...info.capabilities.output],
|
||||
},
|
||||
variants: info.variants.map((variant) => ({ id: variant.id })),
|
||||
time: { released: info.time.released },
|
||||
cost: info.cost.map((cost) => ({
|
||||
tier: cost.tier && { ...cost.tier },
|
||||
input: cost.input,
|
||||
output: cost.output,
|
||||
cache: { ...cost.cache },
|
||||
})),
|
||||
status: info.status,
|
||||
enabled: info.enabled,
|
||||
limit: { ...info.limit },
|
||||
})
|
||||
}
|
||||
|
||||
export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } {
|
||||
const [providerID, ...modelID] = input.split("/")
|
||||
return {
|
||||
|
|
|
|||
39
packages/core/src/opencode.ts
Normal file
39
packages/core/src/opencode.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
export * as OpenCode from "./opencode"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Database } from "./database/database"
|
||||
import { EventV2 } from "./event"
|
||||
import { LocationServiceMap } from "./location-layer"
|
||||
import { ProjectV2 } from "./project"
|
||||
import { SessionV2 } from "./session"
|
||||
import { SessionProjector } from "./session/projector"
|
||||
import * as SessionExecutionLocal from "./session/execution/local"
|
||||
import { SessionStore } from "./session/store"
|
||||
|
||||
export interface Interface {
|
||||
readonly sessions: SessionV2.Interface
|
||||
}
|
||||
|
||||
/** Public embedded OpenCode API for Effect-native applications. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/OpenCode") {}
|
||||
|
||||
const DefaultSessions = SessionV2.layer.pipe(
|
||||
Layer.provide(SessionProjector.layer),
|
||||
Layer.provide(SessionExecutionLocal.layer),
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(SessionStore.layer),
|
||||
Layer.provide(EventV2.layer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.orDie,
|
||||
)
|
||||
|
||||
// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
return Service.of({ sessions: yield* SessionV2.Service })
|
||||
}),
|
||||
).pipe(Layer.provide(DefaultSessions))
|
||||
|
||||
// TODO: Add OpenCode.create(...) as the Promise facade over the same embedded API semantics.
|
||||
179
packages/core/src/patch.ts
Normal file
179
packages/core/src/patch.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
export * as Patch from "./patch"
|
||||
|
||||
export type Hunk =
|
||||
| { readonly type: "add"; readonly path: string; readonly contents: string }
|
||||
| { readonly type: "delete"; readonly path: string }
|
||||
| { readonly type: "update"; readonly path: string; readonly movePath?: string; readonly chunks: ReadonlyArray<UpdateFileChunk> }
|
||||
|
||||
export interface UpdateFileChunk {
|
||||
readonly oldLines: ReadonlyArray<string>
|
||||
readonly newLines: ReadonlyArray<string>
|
||||
readonly changeContext?: string
|
||||
readonly endOfFile?: boolean
|
||||
}
|
||||
|
||||
export interface FileUpdate {
|
||||
readonly content: string
|
||||
readonly bom: boolean
|
||||
}
|
||||
|
||||
export function parse(patchText: string): ReadonlyArray<Hunk> {
|
||||
const lines = stripHeredoc(patchText.trim()).split("\n")
|
||||
const begin = lines.findIndex((line) => line.trim() === "*** Begin Patch")
|
||||
const end = lines.findIndex((line) => line.trim() === "*** End Patch")
|
||||
if (begin === -1 || end === -1 || begin >= end) throw new Error("Invalid patch format: missing Begin/End markers")
|
||||
|
||||
const hunks: Hunk[] = []
|
||||
let index = begin + 1
|
||||
while (index < end) {
|
||||
const line = lines[index]!
|
||||
if (line.startsWith("*** Add File:")) {
|
||||
const path = line.slice("*** Add File:".length).trim()
|
||||
if (!path) throw new Error("Invalid add file path")
|
||||
const parsed = parseAdd(lines, index + 1)
|
||||
hunks.push({ type: "add", path, contents: parsed.content })
|
||||
index = parsed.next
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("*** Delete File:")) {
|
||||
const path = line.slice("*** Delete File:".length).trim()
|
||||
if (!path) throw new Error("Invalid delete file path")
|
||||
hunks.push({ type: "delete", path })
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("*** Update File:")) {
|
||||
const path = line.slice("*** Update File:".length).trim()
|
||||
if (!path) throw new Error("Invalid update file path")
|
||||
let next = index + 1
|
||||
let movePath: string | undefined
|
||||
if (lines[next]?.startsWith("*** Move to:")) {
|
||||
movePath = lines[next]!.slice("*** Move to:".length).trim()
|
||||
if (!movePath) throw new Error("Invalid move file path")
|
||||
next++
|
||||
}
|
||||
const parsed = parseUpdate(lines, next)
|
||||
if (parsed.chunks.length === 0) throw new Error(`Invalid update hunk for ${path}: expected at least one @@ chunk`)
|
||||
hunks.push({ type: "update", path, movePath, chunks: parsed.chunks })
|
||||
index = parsed.next
|
||||
continue
|
||||
}
|
||||
throw new Error(`Invalid patch line: ${line}`)
|
||||
}
|
||||
return hunks
|
||||
}
|
||||
|
||||
export function derive(path: string, chunks: ReadonlyArray<UpdateFileChunk>, original: string): FileUpdate {
|
||||
const source = splitBom(original)
|
||||
const lines = source.text.split("\n")
|
||||
if (lines.at(-1) === "") lines.pop()
|
||||
const replacements = computeReplacements(lines, path, chunks)
|
||||
const updated = [...lines]
|
||||
for (const [start, remove, insert] of replacements.toReversed()) updated.splice(start, remove, ...insert)
|
||||
if (updated.at(-1) !== "") updated.push("")
|
||||
const next = splitBom(updated.join("\n"))
|
||||
return { content: next.text, bom: source.bom || next.bom }
|
||||
}
|
||||
|
||||
export function joinBom(text: string, bom: boolean) {
|
||||
const stripped = splitBom(text).text
|
||||
return bom ? `\uFEFF${stripped}` : stripped
|
||||
}
|
||||
|
||||
function parseAdd(lines: ReadonlyArray<string>, start: number) {
|
||||
const content: string[] = []
|
||||
let index = start
|
||||
while (index < lines.length && !lines[index]!.startsWith("***")) {
|
||||
if (!lines[index]!.startsWith("+")) throw new Error(`Invalid add file line: ${lines[index]}`)
|
||||
content.push(lines[index]!.slice(1))
|
||||
index++
|
||||
}
|
||||
return { content: content.join("\n"), next: index }
|
||||
}
|
||||
|
||||
function parseUpdate(lines: ReadonlyArray<string>, start: number) {
|
||||
const chunks: UpdateFileChunk[] = []
|
||||
let index = start
|
||||
while (index < lines.length && !lines[index]!.startsWith("***")) {
|
||||
if (!lines[index]!.startsWith("@@")) {
|
||||
throw new Error(`Invalid update file line: ${lines[index]}`)
|
||||
}
|
||||
const changeContext = lines[index]!.slice(2).trim() || undefined
|
||||
const oldLines: string[] = []
|
||||
const newLines: string[] = []
|
||||
let endOfFile = false
|
||||
index++
|
||||
while (index < lines.length && !lines[index]!.startsWith("@@")) {
|
||||
const line = lines[index]!
|
||||
if (line === "*** End of File") {
|
||||
endOfFile = true
|
||||
index++
|
||||
break
|
||||
}
|
||||
if (line.startsWith("***")) break
|
||||
if (line.startsWith(" ")) {
|
||||
oldLines.push(line.slice(1))
|
||||
newLines.push(line.slice(1))
|
||||
} else if (line.startsWith("-")) oldLines.push(line.slice(1))
|
||||
else if (line.startsWith("+")) newLines.push(line.slice(1))
|
||||
else throw new Error(`Invalid update chunk line: ${line}`)
|
||||
index++
|
||||
}
|
||||
chunks.push({ oldLines, newLines, changeContext, endOfFile: endOfFile || undefined })
|
||||
}
|
||||
return { chunks, next: index }
|
||||
}
|
||||
|
||||
function computeReplacements(lines: ReadonlyArray<string>, path: string, chunks: ReadonlyArray<UpdateFileChunk>) {
|
||||
const replacements: Array<readonly [start: number, remove: number, insert: ReadonlyArray<string>]> = []
|
||||
let lineIndex = 0
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.changeContext) {
|
||||
const context = seek(lines, [chunk.changeContext], lineIndex)
|
||||
if (context === -1) throw new Error(`Failed to find context '${chunk.changeContext}' in ${path}`)
|
||||
lineIndex = context + 1
|
||||
}
|
||||
if (chunk.oldLines.length === 0) {
|
||||
replacements.push([lines.length, 0, chunk.newLines])
|
||||
continue
|
||||
}
|
||||
let oldLines = chunk.oldLines
|
||||
let newLines = chunk.newLines
|
||||
let found = seek(lines, oldLines, lineIndex, chunk.endOfFile)
|
||||
if (found === -1 && oldLines.at(-1) === "") {
|
||||
oldLines = oldLines.slice(0, -1)
|
||||
if (newLines.at(-1) === "") newLines = newLines.slice(0, -1)
|
||||
found = seek(lines, oldLines, lineIndex, chunk.endOfFile)
|
||||
}
|
||||
if (found === -1) throw new Error(`Failed to find expected lines in ${path}:\n${chunk.oldLines.join("\n")}`)
|
||||
replacements.push([found, oldLines.length, newLines])
|
||||
lineIndex = found + oldLines.length
|
||||
}
|
||||
return replacements.toSorted((left, right) => left[0] - right[0])
|
||||
}
|
||||
|
||||
function seek(lines: ReadonlyArray<string>, pattern: ReadonlyArray<string>, start: number, eof = false) {
|
||||
if (pattern.length === 0) return -1
|
||||
for (const compare of [exact, rstrip, trim, normalized]) {
|
||||
if (eof) {
|
||||
const offset = lines.length - pattern.length
|
||||
if (offset >= start && matches(lines, pattern, offset, compare)) return offset
|
||||
}
|
||||
for (let offset = start; offset <= lines.length - pattern.length; offset++) {
|
||||
if (matches(lines, pattern, offset, compare)) return offset
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function matches(lines: ReadonlyArray<string>, pattern: ReadonlyArray<string>, offset: number, compare: (left: string, right: string) => boolean) {
|
||||
return pattern.every((line, index) => compare(lines[offset + index]!, line))
|
||||
}
|
||||
|
||||
const exact = (left: string, right: string) => left === right
|
||||
const rstrip = (left: string, right: string) => left.trimEnd() === right.trimEnd()
|
||||
const trim = (left: string, right: string) => left.trim() === right.trim()
|
||||
const normalized = (left: string, right: string) => normalize(left.trim()) === normalize(right.trim())
|
||||
const normalize = (value: string) => value.replace(/[‘’‚‛]/g, "'").replace(/[“”„‟]/g, '"').replace(/[‐‑‒–—―]/g, "-").replace(/…/g, "...").replace(/ /g, " ")
|
||||
const splitBom = (text: string) => text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text }
|
||||
const stripHeredoc = (input: string) => input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)?.[2] ?? input
|
||||
|
|
@ -5,6 +5,7 @@ import { EventV2 } from "./event"
|
|||
import { Location } from "./location"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { SessionV2 } from "./session"
|
||||
import { SessionStore } from "./session/store"
|
||||
import { withStatics } from "./schema"
|
||||
import { Identifier } from "./util/identifier"
|
||||
import { Wildcard } from "./util/wildcard"
|
||||
|
|
@ -135,7 +136,7 @@ export const layer = Layer.effect(
|
|||
const events = yield* EventV2.Service
|
||||
const location = yield* Location.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const sessions = yield* SessionV2.Service
|
||||
const sessions = yield* SessionStore.Service
|
||||
const saved = yield* PermissionSaved.Service
|
||||
const pending = new Map<ID, Pending>()
|
||||
|
||||
|
|
@ -159,8 +160,8 @@ export const layer = Layer.effect(
|
|||
|
||||
const configured = EffectRuntime.fn("PermissionV2.configured")(function* (sessionID: SessionV2.ID) {
|
||||
const session = yield* sessions.get(sessionID)
|
||||
if (!session.agent) return []
|
||||
return (yield* agents.get(AgentV2.ID.make(session.agent)))?.permissions ?? []
|
||||
if (!session) return yield* new SessionV2.NotFoundError({ sessionID })
|
||||
return (yield* agents.get(AgentV2.ID.make(session.agent ?? "build")))?.permissions ?? []
|
||||
})
|
||||
|
||||
function denied(input: AssertInput, rules: Ruleset) {
|
||||
|
|
@ -192,13 +193,19 @@ export const layer = Layer.effect(
|
|||
}
|
||||
}
|
||||
|
||||
const create = EffectRuntime.fnUntraced(function* (request: Request) {
|
||||
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
||||
const item = { request, deferred }
|
||||
pending.set(request.id, item)
|
||||
yield* events.publish(Event.Asked, request)
|
||||
return item
|
||||
})
|
||||
const create = (request: Request) =>
|
||||
EffectRuntime.uninterruptible(
|
||||
EffectRuntime.gen(function* () {
|
||||
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
||||
const item = { request, deferred }
|
||||
if (pending.has(request.id)) return yield* EffectRuntime.die(`Duplicate pending permission ID: ${request.id}`)
|
||||
pending.set(request.id, item)
|
||||
yield* events.publish(Event.Asked, request).pipe(
|
||||
EffectRuntime.onError(() => EffectRuntime.sync(() => pending.delete(request.id))),
|
||||
)
|
||||
return item
|
||||
}),
|
||||
)
|
||||
|
||||
const ask = EffectRuntime.fn("PermissionV2.ask")(function* (input: AssertInput) {
|
||||
const result = yield* evaluateInput(input)
|
||||
|
|
@ -207,28 +214,31 @@ export const layer = Layer.effect(
|
|||
return { id: value.id, effect: result.effect }
|
||||
})
|
||||
|
||||
const assert = EffectRuntime.fn("PermissionV2.assert")(function* (input: AssertInput) {
|
||||
const result = yield* evaluateInput(input)
|
||||
if (result.effect === "deny") {
|
||||
return yield* new DeniedError({
|
||||
rules: relevant(input, result.rules),
|
||||
})
|
||||
}
|
||||
if (result.effect === "allow") return
|
||||
const item = yield* create(request(input))
|
||||
return yield* Deferred.await(item.deferred).pipe(
|
||||
EffectRuntime.ensuring(
|
||||
EffectRuntime.sync(() => {
|
||||
pending.delete(item.request.id)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
const assert = EffectRuntime.fn("PermissionV2.assert")((input: AssertInput) =>
|
||||
EffectRuntime.uninterruptibleMask((restore) =>
|
||||
EffectRuntime.gen(function* () {
|
||||
const result = yield* evaluateInput(input)
|
||||
if (result.effect === "deny") {
|
||||
return yield* new DeniedError({
|
||||
rules: relevant(input, result.rules),
|
||||
})
|
||||
}
|
||||
if (result.effect === "allow") return
|
||||
const item = yield* create(request(input))
|
||||
return yield* restore(Deferred.await(item.deferred)).pipe(
|
||||
EffectRuntime.ensuring(
|
||||
EffectRuntime.sync(() => {
|
||||
pending.delete(item.request.id)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const reply = EffectRuntime.fn("PermissionV2.reply")(function* (input: ReplyInput) {
|
||||
const reply = EffectRuntime.fn("PermissionV2.reply")((input: ReplyInput) => EffectRuntime.uninterruptible(EffectRuntime.gen(function* () {
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
|
||||
pending.delete(input.requestID)
|
||||
yield* events.publish(Event.Replied, {
|
||||
sessionID: existing.request.sessionID,
|
||||
requestID: existing.request.id,
|
||||
|
|
@ -240,15 +250,16 @@ export const layer = Layer.effect(
|
|||
existing.deferred,
|
||||
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
|
||||
)
|
||||
pending.delete(input.requestID)
|
||||
for (const [id, item] of pending) {
|
||||
if (item.request.sessionID !== existing.request.sessionID) continue
|
||||
pending.delete(id)
|
||||
yield* events.publish(Event.Replied, {
|
||||
sessionID: item.request.sessionID,
|
||||
requestID: item.request.id,
|
||||
reply: "reject",
|
||||
})
|
||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||
pending.delete(id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -261,6 +272,7 @@ export const layer = Layer.effect(
|
|||
})
|
||||
}
|
||||
yield* Deferred.succeed(existing.deferred, undefined)
|
||||
pending.delete(input.requestID)
|
||||
if (input.reply !== "always" || !existing.request.save?.length) return
|
||||
|
||||
const rememberedRules = yield* savedRules()
|
||||
|
|
@ -278,15 +290,15 @@ export const layer = Layer.effect(
|
|||
)
|
||||
)
|
||||
continue
|
||||
pending.delete(id)
|
||||
yield* events.publish(Event.Replied, {
|
||||
sessionID: item.request.sessionID,
|
||||
requestID: item.request.id,
|
||||
reply: "always",
|
||||
})
|
||||
yield* Deferred.succeed(item.deferred, undefined)
|
||||
pending.delete(id)
|
||||
}
|
||||
})
|
||||
})))
|
||||
|
||||
const list = EffectRuntime.fn("PermissionV2.list")(function* () {
|
||||
return Array.from(pending.values(), (item) => item.request)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Context, Effect, Exit, Layer, Schema, Scope } from "effect"
|
|||
import type { ModelV2 } from "./model"
|
||||
import type { Catalog } from "./catalog"
|
||||
import { EventV2 } from "./event"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
|
||||
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
|
||||
export type ID = typeof ID.Type
|
||||
|
|
@ -105,29 +106,36 @@ export const layer = Layer.effect(
|
|||
scope: Scope.Closeable
|
||||
}[] = []
|
||||
const events = yield* EventV2.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const locks = KeyedMutex.makeUnsafe<ID>()
|
||||
|
||||
const svc = Service.of({
|
||||
add: Effect.fn("Plugin.add")(function* (input) {
|
||||
const existing = hooks.find((item) => item.id === input.id)
|
||||
if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
|
||||
const scope = yield* Scope.make()
|
||||
const result = yield* input.effect.pipe(
|
||||
Scope.provide(scope),
|
||||
Effect.withSpan("Plugin.load", {
|
||||
attributes: {
|
||||
"plugin.id": input.id,
|
||||
},
|
||||
yield* locks.withLock(input.id)(
|
||||
Effect.gen(function* () {
|
||||
const existing = hooks.find((item) => item.id === input.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": input.id,
|
||||
},
|
||||
}),
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(childScope, exit) : Effect.void)),
|
||||
)
|
||||
hooks = [
|
||||
...hooks.filter((item) => item.id !== input.id),
|
||||
{
|
||||
id: input.id,
|
||||
hooks: result ?? {},
|
||||
scope: childScope,
|
||||
},
|
||||
]
|
||||
yield* events.publish(Event.Added, { id: input.id })
|
||||
}),
|
||||
)
|
||||
hooks = [
|
||||
...hooks.filter((item) => item.id !== input.id),
|
||||
{
|
||||
id: input.id,
|
||||
hooks: result ?? {},
|
||||
scope,
|
||||
},
|
||||
]
|
||||
yield* events.publish(Event.Added, { id: input.id })
|
||||
}),
|
||||
trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) {
|
||||
return yield* svc.triggerFor(ID.make("*"), name, input, output)
|
||||
|
|
@ -167,9 +175,13 @@ export const layer = Layer.effect(
|
|||
return event as any
|
||||
}),
|
||||
remove: Effect.fn("Plugin.remove")(function* (id) {
|
||||
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)
|
||||
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)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
return svc
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ Guidelines:
|
|||
- Use Glob for broad file pattern matching
|
||||
- Use Grep for searching file contents with regex
|
||||
- Use Read when you know the specific file path you need to read
|
||||
- Use Bash for file operations like copying, moving, or listing directory contents
|
||||
- Adapt your search approach based on the thoroughness level specified by the caller
|
||||
- Return file paths as absolute paths in your final response
|
||||
- For clear communication, avoid using emojis
|
||||
|
|
@ -171,8 +170,6 @@ export const Plugin = PluginV2.define({
|
|||
{ action: "*", resource: "*", effect: "deny" },
|
||||
{ action: "grep", resource: "*", effect: "allow" },
|
||||
{ action: "glob", resource: "*", effect: "allow" },
|
||||
{ action: "list", resource: "*", effect: "allow" },
|
||||
{ action: "bash", resource: "*", effect: "allow" },
|
||||
{ action: "webfetch", resource: "*", effect: "allow" },
|
||||
{ action: "websearch", resource: "*", effect: "allow" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ const describeCommand = (command: ChildProcess.Command): string => {
|
|||
const wrapError = (description: string, cause: unknown): AppProcessError =>
|
||||
cause instanceof AppProcessError ? cause : new AppProcessError({ command: description, cause })
|
||||
|
||||
const abortError = (signal: AbortSignal): Error => {
|
||||
export const abortError = (signal: AbortSignal): Error => {
|
||||
const reason = signal.reason
|
||||
if (reason instanceof Error) return reason
|
||||
const err = new Error("Aborted")
|
||||
|
|
@ -87,7 +87,7 @@ const abortError = (signal: AbortSignal): Error => {
|
|||
return err
|
||||
}
|
||||
|
||||
const waitForAbort = (signal: AbortSignal) =>
|
||||
export const waitForAbort = (signal: AbortSignal) =>
|
||||
Effect.callback<never, Error>((resume) => {
|
||||
if (signal.aborted) {
|
||||
resume(Effect.fail(abortError(signal)))
|
||||
|
|
@ -107,7 +107,7 @@ const normalizeStdin = (
|
|||
? Stream.make(input)
|
||||
: input
|
||||
|
||||
const collectStream = (stream: Stream.Stream<Uint8Array, PlatformError>, maxOutputBytes: number | undefined) =>
|
||||
export const collectStream = (stream: Stream.Stream<Uint8Array, PlatformError>, maxOutputBytes: number | undefined) =>
|
||||
Stream.runFold(
|
||||
stream,
|
||||
() => ({ chunks: [] as Uint8Array[], bytes: 0, truncated: false }),
|
||||
|
|
|
|||
|
|
@ -41,6 +41,20 @@ export const Native = Schema.Struct({
|
|||
export const Api = Schema.Union([AISDK, Native]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Api = typeof Api.Type
|
||||
|
||||
export const PublicAISDK = Schema.Struct({
|
||||
type: Schema.Literal("aisdk"),
|
||||
package: Schema.String,
|
||||
url: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
export const PublicNative = Schema.Struct({
|
||||
type: Schema.Literal("native"),
|
||||
url: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
export const PublicApi = Schema.Union([PublicAISDK, PublicNative]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type PublicApi = typeof PublicApi.Type
|
||||
|
||||
export const Request = Schema.Struct({
|
||||
headers: Schema.Record(Schema.String, Schema.String),
|
||||
body: Schema.Record(Schema.String, Schema.Any),
|
||||
|
|
@ -86,3 +100,49 @@ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class PublicInfo extends Schema.Class<PublicInfo>("ProviderV2.PublicInfo")({
|
||||
id: ID,
|
||||
name: Schema.String,
|
||||
enabled: Schema.Union([
|
||||
Schema.Literal(false),
|
||||
Schema.Struct({
|
||||
via: Schema.Literal("env"),
|
||||
name: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
via: Schema.Literal("account"),
|
||||
service: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
via: Schema.Literal("custom"),
|
||||
}),
|
||||
]),
|
||||
env: Schema.String.pipe(Schema.Array),
|
||||
api: PublicApi,
|
||||
}) {}
|
||||
|
||||
export function sanitizePublicUrl(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? url.origin : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function toPublic(info: Info): PublicInfo {
|
||||
const enabled = info.enabled === false || info.enabled.via !== "custom" ? info.enabled : { via: "custom" as const }
|
||||
const api =
|
||||
info.api.type === "aisdk"
|
||||
? { type: info.api.type, package: info.api.package, url: sanitizePublicUrl(info.api.url) }
|
||||
: { type: info.api.type, url: sanitizePublicUrl(info.api.url) }
|
||||
return new PublicInfo({
|
||||
id: info.id,
|
||||
name: info.name,
|
||||
enabled,
|
||||
env: [...info.env],
|
||||
api,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
198
packages/core/src/question.ts
Normal file
198
packages/core/src/question.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
export * as QuestionV2 from "./question"
|
||||
|
||||
import { Context, Deferred, Effect, Layer, Schema } from "effect"
|
||||
import { EventV2 } from "./event"
|
||||
import { Identifier } from "./id/id"
|
||||
import { withStatics } from "./schema"
|
||||
import { SessionSchema } from "./session/schema"
|
||||
|
||||
export const ID = Schema.String.check(Schema.isStartsWith("que")).pipe(
|
||||
Schema.brand("QuestionV2.ID"),
|
||||
withStatics((schema) => ({ ascending: (id?: string) => schema.make(Identifier.ascending("question", id)) })),
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export const Option = Schema.Struct({
|
||||
label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }),
|
||||
description: Schema.String.annotate({ description: "Explanation of choice" }),
|
||||
}).annotate({ identifier: "QuestionV2.Option" })
|
||||
export type Option = typeof Option.Type
|
||||
|
||||
const base = {
|
||||
question: Schema.String.annotate({ description: "Complete question" }),
|
||||
header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }),
|
||||
options: Schema.Array(Option).annotate({ description: "Available choices" }),
|
||||
multiple: Schema.Boolean.pipe(Schema.optional).annotate({ description: "Allow selecting multiple choices" }),
|
||||
}
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
...base,
|
||||
custom: Schema.Boolean.pipe(Schema.optional).annotate({
|
||||
description: "Allow typing a custom answer (default: true)",
|
||||
}),
|
||||
}).annotate({ identifier: "QuestionV2.Info" })
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionV2.Prompt" })
|
||||
export type Prompt = typeof Prompt.Type
|
||||
|
||||
export const Tool = Schema.Struct({
|
||||
messageID: Schema.String,
|
||||
callID: Schema.String,
|
||||
}).annotate({ identifier: "QuestionV2.Tool" })
|
||||
export type Tool = typeof Tool.Type
|
||||
|
||||
export const Request = Schema.Struct({
|
||||
id: ID,
|
||||
sessionID: SessionSchema.ID,
|
||||
questions: Schema.Array(Info).annotate({ description: "Questions to ask" }),
|
||||
tool: Tool.pipe(Schema.optional),
|
||||
}).annotate({ identifier: "QuestionV2.Request" })
|
||||
export type Request = typeof Request.Type
|
||||
|
||||
export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionV2.Answer" })
|
||||
export type Answer = typeof Answer.Type
|
||||
|
||||
export const Reply = Schema.Struct({
|
||||
answers: Schema.Array(Answer).annotate({
|
||||
description: "User answers in order of questions (each answer is an array of selected labels)",
|
||||
}),
|
||||
}).annotate({ identifier: "QuestionV2.Reply" })
|
||||
export type Reply = typeof Reply.Type
|
||||
|
||||
export const Event = {
|
||||
Asked: EventV2.define({ type: "question.v2.asked", schema: Request.fields }),
|
||||
Replied: EventV2.define({
|
||||
type: "question.v2.replied",
|
||||
schema: {
|
||||
sessionID: SessionSchema.ID,
|
||||
requestID: ID,
|
||||
answers: Schema.Array(Answer),
|
||||
},
|
||||
}),
|
||||
Rejected: EventV2.define({
|
||||
type: "question.v2.rejected",
|
||||
schema: {
|
||||
sessionID: SessionSchema.ID,
|
||||
requestID: ID,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionV2.RejectedError", {}) {
|
||||
override get message() {
|
||||
return "The user dismissed this question"
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("QuestionV2.NotFoundError", {
|
||||
requestID: ID,
|
||||
}) {}
|
||||
|
||||
export interface AskInput {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly questions: ReadonlyArray<Info>
|
||||
readonly tool?: Tool
|
||||
}
|
||||
|
||||
export interface ReplyInput {
|
||||
readonly requestID: ID
|
||||
readonly answers: ReadonlyArray<Answer>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly ask: (input: AskInput) => Effect.Effect<ReadonlyArray<Answer>, RejectedError>
|
||||
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
|
||||
readonly reject: (requestID: ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Question") {}
|
||||
|
||||
interface Pending {
|
||||
readonly request: Request
|
||||
readonly deferred: Deferred.Deferred<ReadonlyArray<Answer>, RejectedError>
|
||||
}
|
||||
|
||||
/**
|
||||
* Location-owned pending prompts. The Location layer map must materialize this
|
||||
* layer once per embedded Location so replies cannot settle another Location's
|
||||
* deferred request.
|
||||
*/
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const pending = new Map<ID, Pending>()
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), {
|
||||
discard: true,
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
pending.clear()
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const ask = Effect.fn("QuestionV2.ask")((input: AskInput) =>
|
||||
Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const id = ID.ascending()
|
||||
const deferred = yield* Deferred.make<ReadonlyArray<Answer>, RejectedError>()
|
||||
const request: Request = { id, ...input }
|
||||
pending.set(id, { request, deferred })
|
||||
return yield* events.publish(Event.Asked, request).pipe(
|
||||
Effect.andThen(restore(Deferred.await(deferred))),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
pending.delete(id)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const reply = Effect.fn("QuestionV2.reply")((input: ReplyInput) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
|
||||
yield* events.publish(Event.Replied, {
|
||||
sessionID: existing.request.sessionID,
|
||||
requestID: existing.request.id,
|
||||
answers: input.answers.map((answer) => [...answer]),
|
||||
})
|
||||
yield* Deferred.succeed(existing.deferred, input.answers)
|
||||
pending.delete(input.requestID)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const reject = Effect.fn("QuestionV2.reject")((requestID: ID) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const existing = pending.get(requestID)
|
||||
if (!existing) return yield* new NotFoundError({ requestID })
|
||||
yield* events.publish(Event.Rejected, {
|
||||
sessionID: existing.request.sessionID,
|
||||
requestID: existing.request.id,
|
||||
})
|
||||
yield* Deferred.fail(existing.deferred, new RejectedError())
|
||||
pending.delete(requestID)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const list = Effect.fn("QuestionV2.list")(function* () {
|
||||
return Array.from(pending.values(), (item) => item.request)
|
||||
})
|
||||
|
||||
return Service.of({ ask, reply, reject, list })
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
182
packages/core/src/ripgrep.ts
Normal file
182
packages/core/src/ripgrep.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
export * as Ripgrep from "./ripgrep"
|
||||
|
||||
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Ripgrep as FileSystemRipgrep } from "./filesystem/ripgrep"
|
||||
import { AppProcess, collectStream, waitForAbort } from "./process"
|
||||
import { NonNegativeInt, PositiveInt } from "./schema"
|
||||
|
||||
/**
|
||||
* Small core-owned ripgrep execution adapter. It deliberately exposes raw
|
||||
* process-oriented rows, not model text or permission behavior. LocationSearch
|
||||
* supplies read authority and bounded substrate results; future leaf tools own
|
||||
* presentation and permission prompts.
|
||||
*/
|
||||
|
||||
const ERROR_BYTES = 8 * 1024
|
||||
export const MAX_RECORD_BYTES = 64 * 1024
|
||||
export const MAX_SUBMATCHES = 100
|
||||
|
||||
const RawMatch = Schema.Struct({
|
||||
type: Schema.Literal("match"),
|
||||
data: Schema.Struct({
|
||||
path: Schema.Struct({ text: Schema.String }),
|
||||
lines: Schema.Struct({ text: Schema.String }),
|
||||
line_number: PositiveInt,
|
||||
absolute_offset: NonNegativeInt,
|
||||
submatches: Schema.Array(
|
||||
Schema.Struct({
|
||||
match: Schema.Struct({ text: Schema.String }),
|
||||
start: NonNegativeInt,
|
||||
end: NonNegativeInt,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
export type Match = typeof RawMatch.Type["data"]
|
||||
|
||||
export class Error extends Schema.TaggedErrorClass<Error>()("Ripgrep.Error", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export class InvalidPatternError extends Schema.TaggedErrorClass<InvalidPatternError>()("Ripgrep.InvalidPatternError", {
|
||||
pattern: Schema.String,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export interface Result<A> {
|
||||
readonly items: A[]
|
||||
readonly truncated: boolean
|
||||
readonly partial: boolean
|
||||
}
|
||||
|
||||
export interface FilesInput {
|
||||
readonly cwd: string
|
||||
readonly pattern: string
|
||||
readonly limit: number
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface GrepInput {
|
||||
readonly cwd: string
|
||||
readonly pattern: string
|
||||
readonly file?: string
|
||||
readonly include?: string
|
||||
readonly limit: number
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly files: (input: FilesInput) => Effect.Effect<Result<string>, Error>
|
||||
readonly grep: (input: GrepInput) => Effect.Effect<Result<Match>, Error | InvalidPatternError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Ripgrep") {}
|
||||
|
||||
const failure = (message: string, cause?: unknown) => new Error({ message, cause })
|
||||
|
||||
const isInvalidPattern = (stderr: string) => stderr.includes("regex parse error") || stderr.includes("error parsing regex")
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const process = yield* AppProcess.Service
|
||||
const binary = yield* FileSystemRipgrep.Service
|
||||
|
||||
const run = <A>(input: {
|
||||
readonly cwd: string
|
||||
readonly args: string[]
|
||||
readonly limit: number
|
||||
readonly signal?: AbortSignal
|
||||
readonly parse: (line: string) => Effect.Effect<A | undefined, Error>
|
||||
readonly pattern?: string
|
||||
}) => {
|
||||
const program = Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* process.spawn(
|
||||
ChildProcess.make(yield* binary.filepath, input.args, { cwd: input.cwd, extendEnv: true, stdin: "ignore" }),
|
||||
)
|
||||
const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe(
|
||||
Effect.map((output) => output.buffer.toString("utf8")),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
const rows = yield* Stream.decodeText(handle.stdout).pipe(
|
||||
Stream.splitLines,
|
||||
Stream.filter((line) => line.length > 0),
|
||||
Stream.mapEffect(input.parse),
|
||||
Stream.filter((row): row is A => row !== undefined),
|
||||
Stream.take(input.limit + 1),
|
||||
Stream.runCollect,
|
||||
Effect.map((chunk) => [...chunk]),
|
||||
)
|
||||
const truncated = rows.length > input.limit
|
||||
if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false }
|
||||
|
||||
const code = yield* handle.exitCode
|
||||
const stderr = yield* Fiber.join(stderrFiber)
|
||||
if (input.pattern && code === 2 && isInvalidPattern(stderr)) {
|
||||
return yield* new InvalidPatternError({ pattern: input.pattern, message: stderr.trim() })
|
||||
}
|
||||
if (code !== 0 && code !== 1 && code !== 2) {
|
||||
return yield* failure(stderr.trim() || `ripgrep failed with code ${code}`)
|
||||
}
|
||||
return { items: code === 1 ? [] : rows, truncated: false, partial: code === 2 }
|
||||
}),
|
||||
)
|
||||
const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program
|
||||
return abortable.pipe(Effect.mapError((cause) => cause instanceof Error || cause instanceof InvalidPatternError ? cause : failure("ripgrep execution failed", cause)))
|
||||
}
|
||||
|
||||
return Service.of({
|
||||
files: (input) =>
|
||||
run<string>({
|
||||
...input,
|
||||
args: [
|
||||
"--no-config",
|
||||
"--files",
|
||||
"--glob=!.git/*", // TODO: Review .git exclusion policy before leaf tool exposure.
|
||||
`--glob=${input.pattern}`,
|
||||
"--glob=!.*",
|
||||
"--glob=!**/.*",
|
||||
".",
|
||||
],
|
||||
parse: (line) => Effect.succeed(line.replace(/^\.\//, "")),
|
||||
}).pipe(
|
||||
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
|
||||
),
|
||||
grep: (input) =>
|
||||
run<Match>({
|
||||
...input,
|
||||
args: [
|
||||
"--no-config",
|
||||
"--json",
|
||||
"--glob=!.git/*", // TODO: Review .git exclusion policy before leaf tool exposure.
|
||||
"--no-messages",
|
||||
...(input.include ? [`--glob=${input.include}`] : []),
|
||||
"--glob=!.*",
|
||||
"--glob=!**/.*",
|
||||
"--",
|
||||
input.pattern,
|
||||
input.file ?? ".",
|
||||
],
|
||||
parse: (line) =>
|
||||
(Buffer.byteLength(line, "utf8") > MAX_RECORD_BYTES
|
||||
? Effect.fail(failure(`Ripgrep JSON record exceeded ${MAX_RECORD_BYTES} bytes`))
|
||||
: Effect.try({
|
||||
try: () => JSON.parse(line) as unknown,
|
||||
catch: (cause) => failure("Invalid ripgrep JSON output", cause),
|
||||
})).pipe(
|
||||
Effect.flatMap((json) => {
|
||||
if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match") return Effect.succeed(undefined)
|
||||
return Schema.decodeUnknownEffect(RawMatch)(json).pipe(
|
||||
Effect.map((match) => ({ ...match.data, submatches: match.data.submatches.slice(0, MAX_SUBMATCHES) })),
|
||||
Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
}),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(FileSystemRipgrep.defaultLayer))
|
||||
|
|
@ -1,4 +1,12 @@
|
|||
import { Option, Schema, SchemaGetter } from "effect"
|
||||
import { Hash } from "./util/hash"
|
||||
|
||||
export type ExternalID = {
|
||||
readonly namespace: string
|
||||
readonly key: string
|
||||
}
|
||||
|
||||
export const externalID = (prefix: string, input: ExternalID) => `${prefix}_${Hash.sha256(JSON.stringify([input.namespace, input.key]))}`
|
||||
|
||||
/**
|
||||
* Integer greater than zero.
|
||||
|
|
|
|||
54
packages/core/src/session-system-context.ts
Normal file
54
packages/core/src/session-system-context.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
export * as SessionSystemContext from "./session-system-context"
|
||||
|
||||
import { Context, DateTime, Effect, Layer } from "effect"
|
||||
import { Location } from "./location"
|
||||
import { SystemContext } from "./system-context"
|
||||
|
||||
export interface Interface {
|
||||
readonly load: () => Effect.Effect<SystemContext.Snapshot>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionSystemContext") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const environment = [
|
||||
"<env>",
|
||||
` Working directory: ${location.directory}`,
|
||||
` Workspace root folder: ${location.project.directory}`,
|
||||
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
|
||||
` Platform: ${process.platform}`,
|
||||
"</env>",
|
||||
].join("\n")
|
||||
const context = SystemContext.struct({
|
||||
environment: SystemContext.value({
|
||||
key: SystemContext.Key.make("core/environment"),
|
||||
load: Effect.succeed({
|
||||
baseline: ["Here is some useful information about the environment you are running in:", environment].join(
|
||||
"\n",
|
||||
),
|
||||
update: ["The environment you are running in is now:", environment].join("\n"),
|
||||
}),
|
||||
}),
|
||||
date: SystemContext.value({
|
||||
key: SystemContext.Key.make("core/date"),
|
||||
load: DateTime.nowAsDate.pipe(
|
||||
Effect.map((date) => ({
|
||||
baseline: `Today's date: ${date.toDateString()}`,
|
||||
update: `Today's date is now: ${date.toDateString()}`,
|
||||
})),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
load: Effect.fn("SessionSystemContext.load")(function* () {
|
||||
return yield* SystemContext.load(context)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
export * as SessionV2 from "./session"
|
||||
export * from "./session/schema"
|
||||
|
||||
import { DateTime, Effect, Layer, Schema, Context } from "effect"
|
||||
import { and, asc, desc, eq, gt, gte, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { Cause, 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"
|
||||
import { ModelV2 } from "./model"
|
||||
import { Location } from "./location"
|
||||
import { SessionMessage } from "./session/message"
|
||||
import type { Prompt } from "./session/prompt"
|
||||
import { Prompt } from "./session/prompt"
|
||||
import { EventV2 } from "./event"
|
||||
import { ProviderV2 } from "./provider"
|
||||
import { Database } from "./database/database"
|
||||
|
|
@ -17,6 +17,18 @@ import { SessionMessageTable, SessionTable } from "./session/sql"
|
|||
import { SessionSchema } from "./session/schema"
|
||||
import { AbsolutePath, PositiveInt, RelativePath } from "./schema"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { SessionV1 } from "./v1/session"
|
||||
import { InstallationVersion } from "./installation/version"
|
||||
import { Slug } from "./util/slug"
|
||||
import { ProjectTable } from "./project/sql"
|
||||
import path from "path"
|
||||
import { fromRow } from "./session/info"
|
||||
import { SessionRunner } from "./session/runner/index"
|
||||
import { SessionStore } from "./session/store"
|
||||
import { SessionExecution } from "./session/execution"
|
||||
import { MessageDecodeError } from "./session/error"
|
||||
import { SessionEvent } from "./session/event"
|
||||
import { SessionInput } from "./session/input"
|
||||
|
||||
// get project -> project.locations
|
||||
//
|
||||
|
|
@ -60,7 +72,7 @@ export type ListInput = typeof ListInput.Type
|
|||
|
||||
type CreateInput = {
|
||||
id?: SessionSchema.ID
|
||||
agent?: string
|
||||
agent?: AgentV2.ID
|
||||
model?: ModelV2.Ref
|
||||
location: Location.Ref
|
||||
}
|
||||
|
|
@ -82,21 +94,23 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Ses
|
|||
export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
|
||||
"Session.OperationUnavailableError",
|
||||
{
|
||||
operation: Schema.Literals(["prompt", "compact", "wait"]),
|
||||
operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "switchModel", "compact", "wait"]),
|
||||
},
|
||||
) {}
|
||||
|
||||
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
|
||||
export { MessageDecodeError } from "./session/error"
|
||||
|
||||
export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictError>()("Session.PromptConflictError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
messageID: SessionMessage.ID,
|
||||
}) {}
|
||||
|
||||
export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError
|
||||
export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError | PromptConflictError
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (input?: ListInput) => Effect.Effect<SessionSchema.Info[]>
|
||||
readonly create: (input?: CreateInput) => Effect.Effect<SessionSchema.Info>
|
||||
readonly move: (input: MoveInput) => Effect.Effect<void, NotFoundError>
|
||||
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info>
|
||||
readonly move: (input: MoveInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
|
||||
readonly messages: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
|
|
@ -104,85 +118,74 @@ export interface Interface {
|
|||
order?: "asc" | "desc"
|
||||
cursor?: {
|
||||
id: SessionMessage.ID
|
||||
time: number
|
||||
direction: "previous" | "next"
|
||||
}
|
||||
}) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
|
||||
readonly message: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
messageID: SessionMessage.ID
|
||||
}) => Effect.Effect<SessionMessage.Message | undefined>
|
||||
readonly context: (
|
||||
sessionID: SessionSchema.ID,
|
||||
) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, never>
|
||||
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: ModelV2.Ref }) => Effect.Effect<void, never>
|
||||
readonly events: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
after?: EventV2.Cursor
|
||||
}) => Stream.Stream<EventV2.CursorEvent<SessionEvent.DurableEvent>, NotFoundError>
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, OperationUnavailableError>
|
||||
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: ModelV2.Ref }) => Effect.Effect<void, OperationUnavailableError>
|
||||
readonly prompt: (input: {
|
||||
id?: EventV2.ID
|
||||
id?: SessionMessage.ID
|
||||
sessionID: SessionSchema.ID
|
||||
prompt: Prompt
|
||||
delivery?: SessionSchema.Delivery
|
||||
delivery?: SessionInput.Delivery
|
||||
resume?: boolean
|
||||
}) => Effect.Effect<SessionMessage.User, NotFoundError | OperationUnavailableError>
|
||||
}) => Effect.Effect<SessionMessage.User, NotFoundError | PromptConflictError>
|
||||
readonly shell: (input: {
|
||||
id?: EventV2.ID
|
||||
sessionID: SessionSchema.ID
|
||||
command: string
|
||||
delivery?: SessionSchema.Delivery
|
||||
resume?: boolean
|
||||
}) => Effect.Effect<void, never>
|
||||
}) => Effect.Effect<void, OperationUnavailableError>
|
||||
readonly skill: (input: {
|
||||
id?: EventV2.ID
|
||||
sessionID: SessionSchema.ID
|
||||
skill: string
|
||||
delivery?: SessionSchema.Delivery
|
||||
resume?: boolean
|
||||
}) => Effect.Effect<void, never>
|
||||
}) => Effect.Effect<void, OperationUnavailableError>
|
||||
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
|
||||
|
||||
function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info {
|
||||
return SessionSchema.Info.make({
|
||||
id: SessionSchema.ID.make(row.id),
|
||||
projectID: ProjectV2.ID.make(row.project_id),
|
||||
title: row.title,
|
||||
parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined,
|
||||
agent: row.agent ? AgentV2.ID.make(row.agent) : undefined,
|
||||
model: row.model
|
||||
? {
|
||||
id: ModelV2.ID.make(row.model.id),
|
||||
providerID: ProviderV2.ID.make(row.model.providerID),
|
||||
variant: ModelV2.VariantID.make(row.model.variant ?? "default"),
|
||||
}
|
||||
: undefined,
|
||||
cost: row.cost,
|
||||
tokens: {
|
||||
input: row.tokens_input,
|
||||
output: row.tokens_output,
|
||||
reasoning: row.tokens_reasoning,
|
||||
cache: {
|
||||
read: row.tokens_cache_read,
|
||||
write: row.tokens_cache_write,
|
||||
},
|
||||
},
|
||||
location: Location.Ref.make({
|
||||
directory: AbsolutePath.make(row.directory),
|
||||
workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined,
|
||||
}),
|
||||
subpath: row.path ? RelativePath.make(row.path) : undefined,
|
||||
time: {
|
||||
created: DateTime.makeUnsafe(row.time_created),
|
||||
updated: DateTime.makeUnsafe(row.time_updated),
|
||||
archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const events = yield* EventV2.Service
|
||||
const projects = yield* ProjectV2.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
const scope = yield* Effect.scope
|
||||
|
||||
const enqueueWake = (sessionID: SessionSchema.ID) =>
|
||||
execution.wake(sessionID).pipe(
|
||||
Effect.tapCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.void
|
||||
: Effect.logError("Failed to wake Session").pipe(
|
||||
Effect.annotateLogs("sessionID", sessionID),
|
||||
Effect.annotateLogs("cause", 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(
|
||||
|
|
@ -195,14 +198,80 @@ export const layer = Layer.effect(
|
|||
),
|
||||
)
|
||||
|
||||
const findExistingPrompt = Effect.fnUntraced(function* (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
messageID: SessionMessage.ID
|
||||
prompt: Prompt
|
||||
delivery: SessionInput.Delivery
|
||||
}) {
|
||||
const stored = yield* SessionInput.find(db, input.messageID)
|
||||
if (!stored) return yield* SessionInput.reconcileProjected(db, { id: input.messageID, ...input })
|
||||
if (!SessionInput.equivalent(stored, input)) {
|
||||
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID: input.messageID })
|
||||
}
|
||||
return stored
|
||||
})
|
||||
|
||||
const result = Service.of({
|
||||
create: Effect.fn("V2Session.create")(function* () {
|
||||
return {} as SessionSchema.Info
|
||||
create: Effect.fn("V2Session.create")(function* (input) {
|
||||
const sessionID = input.id ?? SessionSchema.ID.create()
|
||||
const recorded = yield* store.get(sessionID)
|
||||
if (recorded) return recorded
|
||||
const project = yield* projects.resolve(input.location.directory)
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const now = Date.now()
|
||||
const info = SessionV1.SessionInfo.make({
|
||||
id: sessionID,
|
||||
slug: Slug.create(),
|
||||
version: InstallationVersion,
|
||||
projectID: project.id,
|
||||
directory: input.location.directory,
|
||||
path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"),
|
||||
workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined,
|
||||
title: `New session - ${new Date(now).toISOString()}`,
|
||||
agent: input.agent,
|
||||
model: input.model
|
||||
? {
|
||||
id: ProviderV2.ModelID.make(input.model.id),
|
||||
providerID: input.model.providerID,
|
||||
variant: input.model.variant,
|
||||
}
|
||||
: undefined,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: now, updated: now },
|
||||
})
|
||||
const projected = yield* events
|
||||
.publish(SessionV1.Event.Created, { sessionID, info }, { location: input.location })
|
||||
.pipe(
|
||||
Effect.as({ type: "created" } as const),
|
||||
Effect.catchDefect((defect) => {
|
||||
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
|
||||
return Effect.die(defect)
|
||||
}
|
||||
// Concurrent creation lost the projection race. The existing Session identity wins.
|
||||
return store
|
||||
.get(sessionID)
|
||||
.pipe(
|
||||
Effect.flatMap((session) =>
|
||||
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
if (projected.type === "existing") return projected.session
|
||||
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
|
||||
return yield* result.get(sessionID).pipe(Effect.orDie)
|
||||
}),
|
||||
get: Effect.fn("V2Session.get")(function* (sessionID) {
|
||||
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
|
||||
if (!row) return yield* new NotFoundError({ sessionID })
|
||||
return fromRow(row)
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* new NotFoundError({ sessionID })
|
||||
return session
|
||||
}),
|
||||
list: Effect.fn("V2Session.list")(function* (input = {}) {
|
||||
const direction = input.anchor?.direction ?? "next"
|
||||
|
|
@ -245,22 +314,19 @@ export const layer = Layer.effect(
|
|||
const direction = input.cursor?.direction ?? "next"
|
||||
const requestedOrder = input.order ?? "desc"
|
||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||
const boundary = input.cursor
|
||||
const anchor = input.cursor
|
||||
? yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
: undefined
|
||||
if (input.cursor && !anchor) return []
|
||||
const boundary = anchor
|
||||
? order === "asc"
|
||||
? or(
|
||||
gt(SessionMessageTable.time_created, input.cursor.time),
|
||||
and(
|
||||
eq(SessionMessageTable.time_created, input.cursor.time),
|
||||
gt(SessionMessageTable.id, input.cursor.id),
|
||||
),
|
||||
)
|
||||
: or(
|
||||
lt(SessionMessageTable.time_created, input.cursor.time),
|
||||
and(
|
||||
eq(SessionMessageTable.time_created, input.cursor.time),
|
||||
lt(SessionMessageTable.id, input.cursor.id),
|
||||
),
|
||||
)
|
||||
? gt(SessionMessageTable.seq, anchor.seq)
|
||||
: lt(SessionMessageTable.seq, anchor.seq)
|
||||
: undefined
|
||||
const where = boundary
|
||||
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
|
||||
|
|
@ -269,55 +335,68 @@ export const layer = Layer.effect(
|
|||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(where)
|
||||
.orderBy(
|
||||
order === "asc" ? asc(SessionMessageTable.time_created) : desc(SessionMessageTable.time_created),
|
||||
order === "asc" ? asc(SessionMessageTable.id) : desc(SessionMessageTable.id),
|
||||
)
|
||||
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode)
|
||||
}),
|
||||
message: Effect.fn("V2Session.message")(function* (input) {
|
||||
const stored = yield* store.message(input.messageID)
|
||||
return stored?.sessionID === input.sessionID ? stored.message : undefined
|
||||
}),
|
||||
context: Effect.fn("V2Session.context")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
const compaction = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
|
||||
.orderBy(desc(SessionMessageTable.time_created), desc(SessionMessageTable.id))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
compaction
|
||||
? or(
|
||||
gt(SessionMessageTable.time_created, compaction.time_created),
|
||||
and(
|
||||
eq(SessionMessageTable.time_created, compaction.time_created),
|
||||
gte(SessionMessageTable.id, compaction.id),
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.time_created), asc(SessionMessageTable.id))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return yield* Effect.forEach(rows, decode)
|
||||
return yield* store.context(sessionID)
|
||||
}),
|
||||
prompt: Effect.fn("V2Session.prompt")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
return yield* Effect.fail(new OperationUnavailableError({ operation: "prompt" }))
|
||||
events: (input) =>
|
||||
Stream.unwrap(
|
||||
result
|
||||
.get(input.sessionID)
|
||||
.pipe(Effect.as(events.aggregateEvents({ aggregateID: input.sessionID, after: input.after }))),
|
||||
).pipe(
|
||||
Stream.filter((event): event is EventV2.CursorEvent<SessionEvent.DurableEvent> =>
|
||||
isDurableSessionEvent(event.event),
|
||||
),
|
||||
),
|
||||
prompt: Effect.fn("V2Session.prompt")((input) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
yield* result.get(input.sessionID)
|
||||
const returnPrompt = Effect.fnUntraced(function* (admitted: SessionInput.Admitted) {
|
||||
if (input.resume !== false) yield* enqueueWake(input.sessionID)
|
||||
return SessionInput.toMessage(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 }
|
||||
const existing = yield* findExistingPrompt(expected)
|
||||
if (existing) return yield* returnPrompt(existing)
|
||||
const admitted = yield* SessionInput.admit(db, {
|
||||
id: messageID,
|
||||
sessionID: input.sessionID,
|
||||
prompt: input.prompt,
|
||||
delivery,
|
||||
})
|
||||
if (!admitted) return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||
if (!SessionInput.equivalent(admitted, expected))
|
||||
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||
return yield* returnPrompt(admitted)
|
||||
}),
|
||||
),
|
||||
),
|
||||
shell: Effect.fn("V2Session.shell")(function* () {
|
||||
return yield* new OperationUnavailableError({ operation: "shell" })
|
||||
}),
|
||||
skill: Effect.fn("V2Session.skill")(function* () {
|
||||
return yield* new OperationUnavailableError({ operation: "skill" })
|
||||
}),
|
||||
switchAgent: Effect.fn("V2Session.switchAgent")(function* () {
|
||||
return yield* new OperationUnavailableError({ operation: "switchAgent" })
|
||||
}),
|
||||
switchModel: Effect.fn("V2Session.switchModel")(function* () {
|
||||
return yield* new OperationUnavailableError({ operation: "switchModel" })
|
||||
}),
|
||||
shell: Effect.fn("V2Session.shell")(function* () {}),
|
||||
skill: Effect.fn("V2Session.skill")(function* () {}),
|
||||
switchAgent: Effect.fn("V2Session.switchAgent")(function* () {}),
|
||||
switchModel: Effect.fn("V2Session.switchModel")(function* () {}),
|
||||
compact: Effect.fn("V2Session.compact")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
return yield* new OperationUnavailableError({ operation: "compact" })
|
||||
|
|
@ -326,16 +405,33 @@ export const layer = Layer.effect(
|
|||
yield* result.get(sessionID)
|
||||
return yield* new OperationUnavailableError({ operation: "wait" })
|
||||
}),
|
||||
resume: Effect.fn("V2Session.resume")(function* () {}),
|
||||
move: Effect.fn("V2Session.move")(function* () {}),
|
||||
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
yield* execution.resume(sessionID)
|
||||
}),
|
||||
move: Effect.fn("V2Session.move")(function* () {
|
||||
return yield* new OperationUnavailableError({ operation: "move" })
|
||||
}),
|
||||
})
|
||||
|
||||
return result
|
||||
}),
|
||||
)
|
||||
|
||||
const DefaultDatabase = Database.defaultLayer
|
||||
const DefaultEvents = EventV2.layer.pipe(Layer.provide(DefaultDatabase))
|
||||
const DefaultProjector = SessionProjector.layer.pipe(Layer.provide(DefaultEvents), Layer.provide(DefaultDatabase))
|
||||
const DefaultStore = SessionStore.layer.pipe(Layer.provide(DefaultDatabase))
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(SessionProjector.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
DefaultDatabase,
|
||||
DefaultEvents,
|
||||
DefaultProjector,
|
||||
DefaultStore,
|
||||
SessionExecution.noopLayer,
|
||||
ProjectV2.defaultLayer,
|
||||
),
|
||||
),
|
||||
Layer.orDie,
|
||||
)
|
||||
|
|
|
|||
47
packages/core/src/session/context.ts
Normal file
47
packages/core/src/session/context.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { and, asc, desc, eq, gt, gte, or } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { MessageDecodeError } from "./error"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionMessageTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
|
||||
export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
const compaction = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
compaction ? or(gte(SessionMessageTable.seq, compaction.seq)) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return yield* Effect.forEach(rows, (row) =>
|
||||
decode({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new MessageDecodeError({
|
||||
sessionID: SessionSchema.ID.make(row.session_id),
|
||||
messageID: SessionMessage.ID.make(row.id),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
export * as SessionContext from "./context"
|
||||
8
packages/core/src/session/error.ts
Normal file
8
packages/core/src/session/error.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Schema } from "effect"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
|
||||
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
messageID: SessionMessage.ID,
|
||||
}) {}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { Schema } from "effect"
|
||||
import { ProviderMetadata } from "@opencode-ai/llm"
|
||||
import { EventV2 } from "../event"
|
||||
import { ModelV2 } from "../model"
|
||||
import { NonNegativeInt } from "../schema"
|
||||
|
|
@ -29,6 +30,12 @@ const options = {
|
|||
version: 1,
|
||||
},
|
||||
} as const
|
||||
const stepSettlementOptions = {
|
||||
sync: {
|
||||
aggregate: "sessionID",
|
||||
version: 2,
|
||||
},
|
||||
} as const
|
||||
|
||||
export const UnknownError = Schema.Struct({
|
||||
type: Schema.Literal("unknown"),
|
||||
|
|
@ -64,6 +71,7 @@ export const Prompted = EventV2.define({
|
|||
schema: {
|
||||
...Base,
|
||||
prompt: Prompt,
|
||||
delivery: Schema.Literals(["steer", "queue"]),
|
||||
},
|
||||
})
|
||||
export type Prompted = typeof Prompted.Type
|
||||
|
|
@ -117,9 +125,10 @@ export namespace Step {
|
|||
|
||||
export const Ended = EventV2.define({
|
||||
type: "session.next.step.ended",
|
||||
...options,
|
||||
...stepSettlementOptions,
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: EventV2.ID,
|
||||
finish: Schema.String,
|
||||
cost: Schema.Finite,
|
||||
tokens: Schema.Struct({
|
||||
|
|
@ -138,9 +147,10 @@ export namespace Step {
|
|||
|
||||
export const Failed = EventV2.define({
|
||||
type: "session.next.step.failed",
|
||||
...options,
|
||||
...stepSettlementOptions,
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: EventV2.ID,
|
||||
error: UnknownError,
|
||||
},
|
||||
})
|
||||
|
|
@ -153,15 +163,17 @@ export namespace Text {
|
|||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
textID: Schema.String,
|
||||
},
|
||||
})
|
||||
export type Started = typeof Started.Type
|
||||
|
||||
// Stream fragments are live-only; Text.Ended is the replayable full-value boundary.
|
||||
export const Delta = EventV2.define({
|
||||
type: "session.next.text.delta",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
textID: Schema.String,
|
||||
delta: Schema.String,
|
||||
},
|
||||
})
|
||||
|
|
@ -172,6 +184,7 @@ export namespace Text {
|
|||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
textID: Schema.String,
|
||||
text: Schema.String,
|
||||
},
|
||||
})
|
||||
|
|
@ -185,13 +198,14 @@ export namespace Reasoning {
|
|||
schema: {
|
||||
...Base,
|
||||
reasoningID: Schema.String,
|
||||
providerMetadata: ProviderMetadata.pipe(Schema.optional),
|
||||
},
|
||||
})
|
||||
export type Started = typeof Started.Type
|
||||
|
||||
// Stream fragments are live-only; Reasoning.Ended is the replayable full-value boundary.
|
||||
export const Delta = EventV2.define({
|
||||
type: "session.next.reasoning.delta",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
reasoningID: Schema.String,
|
||||
|
|
@ -207,30 +221,35 @@ export namespace Reasoning {
|
|||
...Base,
|
||||
reasoningID: Schema.String,
|
||||
text: Schema.String,
|
||||
providerMetadata: ProviderMetadata.pipe(Schema.optional),
|
||||
},
|
||||
})
|
||||
export type Ended = typeof Ended.Type
|
||||
}
|
||||
|
||||
export namespace Tool {
|
||||
const ToolBase = {
|
||||
...Base,
|
||||
assistantMessageID: EventV2.ID,
|
||||
callID: Schema.String,
|
||||
}
|
||||
|
||||
export namespace Input {
|
||||
export const Started = EventV2.define({
|
||||
type: "session.next.tool.input.started",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
callID: Schema.String,
|
||||
...ToolBase,
|
||||
name: Schema.String,
|
||||
},
|
||||
})
|
||||
export type Started = typeof Started.Type
|
||||
|
||||
// Stream fragments are live-only; Input.Ended is the replayable raw-input boundary.
|
||||
export const Delta = EventV2.define({
|
||||
type: "session.next.tool.input.delta",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
callID: Schema.String,
|
||||
...ToolBase,
|
||||
delta: Schema.String,
|
||||
},
|
||||
})
|
||||
|
|
@ -240,8 +259,7 @@ export namespace Tool {
|
|||
type: "session.next.tool.input.ended",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
callID: Schema.String,
|
||||
...ToolBase,
|
||||
text: Schema.String,
|
||||
},
|
||||
})
|
||||
|
|
@ -252,24 +270,26 @@ export namespace Tool {
|
|||
type: "session.next.tool.called",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
callID: Schema.String,
|
||||
...ToolBase,
|
||||
tool: Schema.String,
|
||||
input: Schema.Record(Schema.String, Schema.Unknown),
|
||||
provider: Schema.Struct({
|
||||
executed: Schema.Boolean,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
metadata: ProviderMetadata.pipe(Schema.optional),
|
||||
}),
|
||||
},
|
||||
})
|
||||
export type Called = typeof Called.Type
|
||||
|
||||
/**
|
||||
* Replayable bounded running-tool state. Tools should checkpoint semantic
|
||||
* transitions or at a bounded cadence, not persist every stdout/stderr chunk.
|
||||
*/
|
||||
export const Progress = EventV2.define({
|
||||
type: "session.next.tool.progress",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
callID: Schema.String,
|
||||
...ToolBase,
|
||||
structured: ToolOutput.Structured,
|
||||
content: Schema.Array(ToolOutput.Content),
|
||||
},
|
||||
|
|
@ -280,13 +300,13 @@ export namespace Tool {
|
|||
type: "session.next.tool.success",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
callID: Schema.String,
|
||||
...ToolBase,
|
||||
structured: ToolOutput.Structured,
|
||||
content: Schema.Array(ToolOutput.Content),
|
||||
result: Schema.Unknown.pipe(Schema.optional),
|
||||
provider: Schema.Struct({
|
||||
executed: Schema.Boolean,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
metadata: ProviderMetadata.pipe(Schema.optional),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
|
@ -296,12 +316,12 @@ export namespace Tool {
|
|||
type: "session.next.tool.failed",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
callID: Schema.String,
|
||||
...ToolBase,
|
||||
error: UnknownError,
|
||||
result: Schema.Unknown.pipe(Schema.optional),
|
||||
provider: Schema.Struct({
|
||||
executed: Schema.Boolean,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
metadata: ProviderMetadata.pipe(Schema.optional),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
|
@ -364,39 +384,39 @@ export namespace Compaction {
|
|||
export type Ended = typeof Ended.Type
|
||||
}
|
||||
|
||||
export const All = Schema.Union(
|
||||
[
|
||||
AgentSwitched,
|
||||
ModelSwitched,
|
||||
Prompted,
|
||||
Synthetic,
|
||||
Shell.Started,
|
||||
Shell.Ended,
|
||||
Step.Started,
|
||||
Step.Ended,
|
||||
Step.Failed,
|
||||
Text.Started,
|
||||
Text.Delta,
|
||||
Text.Ended,
|
||||
Tool.Input.Started,
|
||||
Tool.Input.Delta,
|
||||
Tool.Input.Ended,
|
||||
Tool.Called,
|
||||
Tool.Progress,
|
||||
Tool.Success,
|
||||
Tool.Failed,
|
||||
Reasoning.Started,
|
||||
Reasoning.Delta,
|
||||
Reasoning.Ended,
|
||||
Retried,
|
||||
Compaction.Started,
|
||||
Compaction.Delta,
|
||||
Compaction.Ended,
|
||||
],
|
||||
{
|
||||
mode: "oneOf",
|
||||
},
|
||||
).pipe(Schema.toTaggedUnion("type"))
|
||||
const DurableDefinitions = [
|
||||
AgentSwitched,
|
||||
ModelSwitched,
|
||||
Prompted,
|
||||
Synthetic,
|
||||
Shell.Started,
|
||||
Shell.Ended,
|
||||
Step.Started,
|
||||
Step.Ended,
|
||||
Step.Failed,
|
||||
Text.Started,
|
||||
Text.Ended,
|
||||
Tool.Input.Started,
|
||||
Tool.Input.Ended,
|
||||
Tool.Called,
|
||||
Tool.Progress,
|
||||
Tool.Success,
|
||||
Tool.Failed,
|
||||
Reasoning.Started,
|
||||
Reasoning.Ended,
|
||||
Retried,
|
||||
Compaction.Started,
|
||||
Compaction.Delta,
|
||||
Compaction.Ended,
|
||||
] as const
|
||||
const EphemeralDefinitions = [Text.Delta, Tool.Input.Delta, Reasoning.Delta] as const
|
||||
|
||||
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
|
||||
export type DurableEvent = typeof Durable.Type
|
||||
|
||||
export const All = Schema.Union([...DurableDefinitions, ...EphemeralDefinitions], { mode: "oneOf" }).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
)
|
||||
export type Event = typeof All.Type
|
||||
export type Type = Event["type"]
|
||||
|
||||
|
|
|
|||
18
packages/core/src/session/execution.ts
Normal file
18
packages/core/src/session/execution.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export * as SessionExecution from "./execution"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { SessionRunner } from "./runner/index"
|
||||
import { SessionSchema } from "./schema"
|
||||
|
||||
export interface Interface {
|
||||
/** Explicitly drain one Session, making at least one provider attempt. */
|
||||
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) => Effect.Effect<void, SessionRunner.RunError>
|
||||
}
|
||||
|
||||
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionExecution") {}
|
||||
|
||||
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
||||
export const noopLayer = Layer.succeed(Service, Service.of({ resume: () => Effect.void, wake: () => Effect.void }))
|
||||
35
packages/core/src/session/execution/local.ts
Normal file
35
packages/core/src/session/execution/local.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Effect, Layer } from "effect"
|
||||
import { LocationServiceMap } from "../../location-layer"
|
||||
import { SessionRunCoordinator } from "../run-coordinator"
|
||||
import { SessionSchema } from "../schema"
|
||||
import { SessionStore } from "../store"
|
||||
import { SessionExecution } from "../execution"
|
||||
|
||||
/** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
|
||||
export const layer = Layer.effect(
|
||||
SessionExecution.Service,
|
||||
Effect.gen(function* () {
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap
|
||||
const scope = yield* Effect.scope
|
||||
const withCoordinator = Effect.fnUntraced(function* <A, E>(
|
||||
sessionID: SessionSchema.ID,
|
||||
use: (coordinator: SessionRunCoordinator.Interface) => Effect.Effect<A, E>,
|
||||
) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
|
||||
return yield* SessionRunCoordinator.Service.use(use).pipe(Effect.provide(locations.get(session.location)))
|
||||
})
|
||||
|
||||
return SessionExecution.Service.of({
|
||||
resume: Effect.fn("SessionExecution.resume")(function* (sessionID) {
|
||||
return yield* withCoordinator(sessionID, (coordinator) => coordinator.run(sessionID))
|
||||
}),
|
||||
wake: Effect.fn("SessionExecution.wake")(function* (sessionID) {
|
||||
yield* withCoordinator(sessionID, (coordinator) =>
|
||||
coordinator.wake(sessionID).pipe(Effect.andThen(coordinator.awaitIdle(sessionID))),
|
||||
).pipe(Effect.forkIn(scope), Effect.asVoid)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
47
packages/core/src/session/info.ts
Normal file
47
packages/core/src/session/info.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { DateTime } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Location } from "../location"
|
||||
import { ModelV2 } from "../model"
|
||||
import { ProjectV2 } from "../project"
|
||||
import { ProviderV2 } from "../provider"
|
||||
import { AbsolutePath, RelativePath } from "../schema"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionTable } from "./sql"
|
||||
|
||||
export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info {
|
||||
return SessionSchema.Info.make({
|
||||
id: SessionSchema.ID.make(row.id),
|
||||
projectID: ProjectV2.ID.make(row.project_id),
|
||||
title: row.title,
|
||||
parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined,
|
||||
agent: row.agent ? AgentV2.ID.make(row.agent) : undefined,
|
||||
model: row.model
|
||||
? {
|
||||
id: ModelV2.ID.make(row.model.id),
|
||||
providerID: ProviderV2.ID.make(row.model.providerID),
|
||||
variant: ModelV2.VariantID.make(row.model.variant ?? "default"),
|
||||
}
|
||||
: undefined,
|
||||
cost: row.cost,
|
||||
tokens: {
|
||||
input: row.tokens_input,
|
||||
output: row.tokens_output,
|
||||
reasoning: row.tokens_reasoning,
|
||||
cache: {
|
||||
read: row.tokens_cache_read,
|
||||
write: row.tokens_cache_write,
|
||||
},
|
||||
},
|
||||
location: Location.Ref.make({
|
||||
directory: AbsolutePath.make(row.directory),
|
||||
workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined,
|
||||
}),
|
||||
subpath: row.path ? RelativePath.make(row.path) : undefined,
|
||||
time: {
|
||||
created: DateTime.makeUnsafe(row.time_created),
|
||||
updated: DateTime.makeUnsafe(row.time_updated),
|
||||
archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
285
packages/core/src/session/input.ts
Normal file
285
packages/core/src/session/input.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
export * as SessionInput from "./input"
|
||||
|
||||
import { and, asc, eq, inArray, isNull } from "drizzle-orm"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import type { Database } from "../database/database"
|
||||
import type { EventV2 } from "../event"
|
||||
import { EventTable } from "../event/sql"
|
||||
import { NonNegativeInt, PositiveInt } from "../schema"
|
||||
import { V2Schema } from "../v2-schema"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
import { Prompt } from "./prompt"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionInputTable, SessionMessageTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
export const Delivery = Schema.Literals(["steer", "queue"])
|
||||
export type Delivery = typeof Delivery.Type
|
||||
|
||||
export class Admitted extends Schema.Class<Admitted>("SessionInput.Admitted")({
|
||||
seq: PositiveInt,
|
||||
id: SessionMessage.ID,
|
||||
sessionID: SessionSchema.ID,
|
||||
prompt: Prompt,
|
||||
delivery: Delivery,
|
||||
timeCreated: V2Schema.DateTimeUtcFromMillis,
|
||||
promotedSeq: NonNegativeInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
const decodePrompt = Schema.decodeUnknownSync(Prompt)
|
||||
const encodePrompt = Schema.encodeSync(Prompt)
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
|
||||
|
||||
const fromRow = (row: typeof SessionInputTable.$inferSelect): Admitted =>
|
||||
new Admitted({
|
||||
seq: row.seq,
|
||||
id: SessionMessage.ID.make(row.id),
|
||||
sessionID: SessionSchema.ID.make(row.session_id),
|
||||
prompt: decodePrompt(row.prompt),
|
||||
delivery: row.delivery,
|
||||
timeCreated: DateTime.makeUnsafe(row.time_created),
|
||||
...(row.promoted_seq === null ? {} : { promotedSeq: row.promoted_seq }),
|
||||
})
|
||||
|
||||
export const find = Effect.fn("SessionInput.find")(function* (db: DatabaseService, id: SessionMessage.ID) {
|
||||
const row = yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie)
|
||||
return row === undefined ? undefined : fromRow(row)
|
||||
})
|
||||
|
||||
export const admit = Effect.fn("SessionInput.admit")(function* (
|
||||
db: DatabaseService,
|
||||
input: {
|
||||
readonly id: SessionMessage.ID
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly prompt: Prompt
|
||||
readonly delivery: Delivery
|
||||
},
|
||||
) {
|
||||
return yield* db
|
||||
.transaction(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const existing = yield* find(db, input.id)
|
||||
if (existing !== undefined) return existing
|
||||
const event = yield* db
|
||||
.select({ id: EventTable.id })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.id, input.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const message = yield* db
|
||||
.select({ id: SessionMessageTable.id })
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, input.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (event !== undefined || message !== undefined) return undefined
|
||||
const row = yield* db
|
||||
.insert(SessionInputTable)
|
||||
.values({
|
||||
id: input.id,
|
||||
session_id: input.sessionID,
|
||||
prompt: encodePrompt(input.prompt),
|
||||
delivery: input.delivery,
|
||||
})
|
||||
.returning()
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return fromRow(row)
|
||||
}),
|
||||
{ behavior: "immediate" },
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
deliveries: ReadonlyArray<Delivery> = ["steer", "queue"],
|
||||
) {
|
||||
if (deliveries.length === 0) return false
|
||||
const row = yield* db
|
||||
.select({ id: SessionInputTable.id })
|
||||
.from(SessionInputTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionInputTable.session_id, sessionID),
|
||||
isNull(SessionInputTable.promoted_seq),
|
||||
inArray(SessionInputTable.delivery, deliveries),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row !== undefined
|
||||
})
|
||||
|
||||
export const equivalent = (
|
||||
input: Admitted,
|
||||
expected: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly prompt: Prompt
|
||||
readonly delivery: Delivery
|
||||
},
|
||||
) => input.delivery === expected.delivery && matchesPrompt(input, expected)
|
||||
|
||||
const matchesPrompt = (input: Admitted, expected: { readonly sessionID: SessionSchema.ID; readonly prompt: Prompt }) =>
|
||||
input.sessionID === expected.sessionID && JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt))
|
||||
|
||||
export const guardReservedID = Effect.fn("SessionInput.guardReservedID")(function* (
|
||||
db: DatabaseService,
|
||||
event: EventV2.Payload,
|
||||
) {
|
||||
const admitted = yield* find(db, event.id)
|
||||
if (admitted === undefined) return
|
||||
if (!Schema.is(SessionEvent.Prompted)(event))
|
||||
return yield* Effect.die("Durable event conflicts with admitted prompt input")
|
||||
if (!equivalent(admitted, event.data)) return yield* Effect.die("Prompt projection conflicts with admitted input")
|
||||
})
|
||||
|
||||
export const project = Effect.fn("SessionInput.project")(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
|
||||
},
|
||||
) {
|
||||
yield* db
|
||||
.insert(SessionInputTable)
|
||||
.values({
|
||||
id: input.id,
|
||||
session_id: input.sessionID,
|
||||
prompt: encodePrompt(input.prompt),
|
||||
delivery: input.delivery,
|
||||
promoted_seq: input.promotedSeq,
|
||||
time_created: DateTime.toEpochMillis(input.timeCreated),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const admitted = yield* find(db, input.id)
|
||||
if (admitted === undefined || admitted.delivery !== input.delivery || !matchesPrompt(admitted, input))
|
||||
return yield* Effect.die("Prompt projection conflicts with admitted input")
|
||||
yield* db
|
||||
.update(SessionInputTable)
|
||||
.set({ promoted_seq: input.promotedSeq })
|
||||
.where(
|
||||
and(
|
||||
eq(SessionInputTable.id, input.id),
|
||||
eq(SessionInputTable.session_id, input.sessionID),
|
||||
isNull(SessionInputTable.promoted_seq),
|
||||
),
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return yield* find(db, input.id)
|
||||
})
|
||||
|
||||
export const reconcileProjected = Effect.fn("SessionInput.reconcileProjected")(function* (
|
||||
db: DatabaseService,
|
||||
expected: {
|
||||
readonly id: SessionMessage.ID
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly prompt: Prompt
|
||||
readonly delivery: Delivery
|
||||
},
|
||||
) {
|
||||
if (expected.delivery !== "steer") return undefined
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, expected.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (row === undefined || row.session_id !== expected.sessionID || row.type !== "user") return undefined
|
||||
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||
if (message.type !== "user" || !Prompt.equivalence(Prompt.fromUserMessage(message), expected.prompt)) return undefined
|
||||
return yield* project(db, {
|
||||
id: expected.id,
|
||||
sessionID: expected.sessionID,
|
||||
prompt: expected.prompt,
|
||||
delivery: expected.delivery,
|
||||
timeCreated: message.time.created,
|
||||
promotedSeq: row.seq,
|
||||
})
|
||||
})
|
||||
|
||||
const publish = Effect.fn("SessionInput.publish")(function* (
|
||||
events: EventV2.Interface,
|
||||
sessionID: SessionSchema.ID,
|
||||
rows: ReadonlyArray<typeof SessionInputTable.$inferSelect>,
|
||||
) {
|
||||
for (const row of rows) {
|
||||
yield* events.publish(
|
||||
SessionEvent.Prompted,
|
||||
{
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(row.time_created),
|
||||
prompt: decodePrompt(row.prompt),
|
||||
delivery: row.delivery,
|
||||
},
|
||||
{ id: SessionMessage.ID.make(row.id) },
|
||||
)
|
||||
}
|
||||
return rows.length
|
||||
})
|
||||
|
||||
export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionInputTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionInputTable.session_id, sessionID),
|
||||
isNull(SessionInputTable.promoted_seq),
|
||||
eq(SessionInputTable.delivery, "steer"),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionInputTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return yield* publish(events, sessionID, rows)
|
||||
})
|
||||
|
||||
export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(function* (
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionInputTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionInputTable.session_id, sessionID),
|
||||
isNull(SessionInputTable.promoted_seq),
|
||||
eq(SessionInputTable.delivery, "queue"),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionInputTable.seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row === undefined ? false : yield* publish(events, sessionID, [row]).pipe(Effect.as(true))
|
||||
})
|
||||
|
||||
export const toMessage = (input: Admitted) =>
|
||||
new SessionMessage.User({
|
||||
id: input.id,
|
||||
type: "user",
|
||||
text: input.prompt.text,
|
||||
files: input.prompt.files,
|
||||
agents: input.prompt.agents,
|
||||
references: input.prompt.references,
|
||||
time: { created: input.timeCreated },
|
||||
})
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { produce, type WritableDraft } from "immer"
|
||||
import { castDraft, produce, type WritableDraft } from "immer"
|
||||
import { Effect } from "effect"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
|
|
@ -9,6 +9,7 @@ export type MemoryState = {
|
|||
|
||||
export interface Adapter {
|
||||
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined>
|
||||
readonly getAssistant: (messageID: SessionMessage.ID) => Effect.Effect<SessionMessage.Assistant | undefined>
|
||||
readonly getCurrentCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined>
|
||||
readonly getCurrentShell: (callID: string) => Effect.Effect<SessionMessage.Shell | undefined>
|
||||
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void>
|
||||
|
|
@ -18,8 +19,10 @@ export interface Adapter {
|
|||
}
|
||||
|
||||
export function memory(state: MemoryState): Adapter {
|
||||
const activeAssistantIndex = () =>
|
||||
state.messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed)
|
||||
const assistantIndex = (messageID: SessionMessage.ID) =>
|
||||
state.messages.findLastIndex((message) => message.id === messageID)
|
||||
// A newer turn supersedes stale incomplete rows; never resume an older assistant projection.
|
||||
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
|
||||
const activeCompactionIndex = () => state.messages.findLastIndex((message) => message.type === "compaction")
|
||||
const activeShellIndex = (callID: string) =>
|
||||
state.messages.findLastIndex((message) => message.type === "shell" && message.callID === callID)
|
||||
|
|
@ -27,7 +30,15 @@ export function memory(state: MemoryState): Adapter {
|
|||
return {
|
||||
getCurrentAssistant() {
|
||||
return Effect.sync(() => {
|
||||
const index = activeAssistantIndex()
|
||||
const index = latestAssistantIndex()
|
||||
if (index < 0) return
|
||||
const assistant = state.messages[index]
|
||||
return assistant?.type === "assistant" && !assistant.time.completed ? assistant : undefined
|
||||
})
|
||||
},
|
||||
getAssistant(messageID) {
|
||||
return Effect.sync(() => {
|
||||
const index = assistantIndex(messageID)
|
||||
if (index < 0) return
|
||||
const assistant = state.messages[index]
|
||||
return assistant?.type === "assistant" ? assistant : undefined
|
||||
|
|
@ -51,7 +62,7 @@ export function memory(state: MemoryState): Adapter {
|
|||
},
|
||||
updateAssistant(assistant) {
|
||||
return Effect.sync(() => {
|
||||
const index = activeAssistantIndex()
|
||||
const index = assistantIndex(assistant.id)
|
||||
if (index < 0) return
|
||||
const current = state.messages[index]
|
||||
if (current?.type !== "assistant") return
|
||||
|
|
@ -95,12 +106,18 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
(item): item is DraftTool => item.type === "tool" && (callID === undefined || item.id === callID),
|
||||
)
|
||||
|
||||
const latestText = (assistant: DraftAssistant | undefined) =>
|
||||
assistant?.content.findLast((item): item is DraftText => item.type === "text")
|
||||
const latestText = (assistant: DraftAssistant | undefined, textID: string) =>
|
||||
assistant?.content.findLast((item): item is DraftText => item.type === "text" && item.id === textID)
|
||||
|
||||
const latestReasoning = (assistant: DraftAssistant | undefined, reasoningID: string) =>
|
||||
assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && item.id === reasoningID)
|
||||
|
||||
const updateOwnedAssistant = (messageID: SessionMessage.ID, recipe: (draft: DraftAssistant) => void) =>
|
||||
Effect.gen(function* () {
|
||||
const assistant = yield* adapter.getAssistant(messageID)
|
||||
if (assistant) yield* adapter.updateAssistant(produce(assistant, recipe))
|
||||
})
|
||||
|
||||
return Effect.gen(function* () {
|
||||
yield* SessionEvent.All.match(event, {
|
||||
"session.next.agent.switched": (event) => {
|
||||
|
|
@ -200,42 +217,30 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
})
|
||||
},
|
||||
"session.next.step.ended": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const currentAssistant = yield* adapter.getCurrentAssistant()
|
||||
if (currentAssistant) {
|
||||
yield* adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
draft.time.completed = event.data.timestamp
|
||||
draft.finish = event.data.finish
|
||||
draft.cost = event.data.cost
|
||||
draft.tokens = event.data.tokens
|
||||
if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, end: event.data.snapshot }
|
||||
}),
|
||||
)
|
||||
}
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
draft.time.completed = event.data.timestamp
|
||||
draft.finish = event.data.finish
|
||||
draft.cost = event.data.cost
|
||||
draft.tokens = event.data.tokens
|
||||
if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, end: event.data.snapshot }
|
||||
})
|
||||
},
|
||||
"session.next.step.failed": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const currentAssistant = yield* adapter.getCurrentAssistant()
|
||||
if (currentAssistant) {
|
||||
yield* adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
draft.time.completed = event.data.timestamp
|
||||
draft.finish = "error"
|
||||
draft.error = event.data.error
|
||||
}),
|
||||
)
|
||||
}
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
draft.time.completed = event.data.timestamp
|
||||
draft.finish = "error"
|
||||
draft.error = event.data.error
|
||||
})
|
||||
},
|
||||
"session.next.text.started": () => {
|
||||
"session.next.text.started": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const currentAssistant = yield* adapter.getCurrentAssistant()
|
||||
if (currentAssistant) {
|
||||
yield* adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
draft.content.push(new SessionMessage.AssistantText({ type: "text", text: "" }) as DraftText)
|
||||
draft.content.push(
|
||||
castDraft(new SessionMessage.AssistantText({ type: "text", id: event.data.textID, text: "" })),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -247,7 +252,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
if (currentAssistant) {
|
||||
yield* adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestText(draft)
|
||||
const match = latestText(draft, event.data.textID)
|
||||
if (match) match.text += event.data.delta
|
||||
}),
|
||||
)
|
||||
|
|
@ -260,7 +265,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
if (currentAssistant) {
|
||||
yield* adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestText(draft)
|
||||
const match = latestText(draft, event.data.textID)
|
||||
if (match) match.text = event.data.text
|
||||
}),
|
||||
)
|
||||
|
|
@ -268,118 +273,93 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
})
|
||||
},
|
||||
"session.next.tool.input.started": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const currentAssistant = yield* adapter.getCurrentAssistant()
|
||||
if (currentAssistant) {
|
||||
yield* adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
draft.content.push(
|
||||
new SessionMessage.AssistantTool({
|
||||
type: "tool",
|
||||
id: event.data.callID,
|
||||
name: event.data.name,
|
||||
time: { created: event.data.timestamp },
|
||||
state: new SessionMessage.ToolStatePending({ status: "pending", input: "" }),
|
||||
}) as DraftTool,
|
||||
)
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
draft.content.push(
|
||||
castDraft(
|
||||
new SessionMessage.AssistantTool({
|
||||
type: "tool",
|
||||
id: event.data.callID,
|
||||
name: event.data.name,
|
||||
time: { created: event.data.timestamp },
|
||||
state: new SessionMessage.ToolStatePending({ status: "pending", input: "" }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
})
|
||||
},
|
||||
"session.next.tool.input.delta": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const currentAssistant = yield* adapter.getCurrentAssistant()
|
||||
if (currentAssistant) {
|
||||
yield* adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
// oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string)
|
||||
if (match && match.state.status === "pending") match.state.input += event.data.delta
|
||||
}),
|
||||
)
|
||||
}
|
||||
"session.next.tool.input.delta": () => Effect.void,
|
||||
"session.next.tool.input.ended": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match && match.state.status === "pending") match.state.input = event.data.text
|
||||
})
|
||||
},
|
||||
"session.next.tool.input.ended": () => Effect.void,
|
||||
"session.next.tool.called": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const currentAssistant = yield* adapter.getCurrentAssistant()
|
||||
if (currentAssistant) {
|
||||
yield* adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match) {
|
||||
match.provider = event.data.provider
|
||||
match.time.ran = event.data.timestamp
|
||||
match.state = {
|
||||
status: "running",
|
||||
input: event.data.input,
|
||||
structured: {},
|
||||
content: [],
|
||||
}
|
||||
}
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match) {
|
||||
match.provider = event.data.provider
|
||||
match.time.ran = event.data.timestamp
|
||||
match.state = castDraft(
|
||||
new SessionMessage.ToolStateRunning({
|
||||
status: "running",
|
||||
input: event.data.input,
|
||||
structured: {},
|
||||
content: [],
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
"session.next.tool.progress": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const currentAssistant = yield* adapter.getCurrentAssistant()
|
||||
if (currentAssistant) {
|
||||
yield* adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match && match.state.status === "running") {
|
||||
match.state.structured = event.data.structured
|
||||
match.state.content = [...event.data.content]
|
||||
}
|
||||
}),
|
||||
)
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match && match.state.status === "running") {
|
||||
match.state.structured = event.data.structured
|
||||
match.state.content = [...event.data.content]
|
||||
}
|
||||
})
|
||||
},
|
||||
"session.next.tool.success": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const currentAssistant = yield* adapter.getCurrentAssistant()
|
||||
if (currentAssistant) {
|
||||
yield* adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match && match.state.status === "running") {
|
||||
match.provider = event.data.provider
|
||||
match.time.completed = event.data.timestamp
|
||||
match.state = {
|
||||
status: "completed",
|
||||
input: match.state.input,
|
||||
structured: event.data.structured,
|
||||
content: [...event.data.content],
|
||||
}
|
||||
}
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match && match.state.status === "running") {
|
||||
match.provider = {
|
||||
executed: event.data.provider.executed || match.provider?.executed === true,
|
||||
metadata: match.provider?.metadata,
|
||||
resultMetadata: event.data.provider.metadata,
|
||||
}
|
||||
match.time.completed = event.data.timestamp
|
||||
match.state = castDraft(
|
||||
new SessionMessage.ToolStateCompleted({
|
||||
status: "completed",
|
||||
input: match.state.input,
|
||||
structured: event.data.structured,
|
||||
content: [...event.data.content],
|
||||
result: event.data.result,
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
"session.next.tool.failed": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const currentAssistant = yield* adapter.getCurrentAssistant()
|
||||
if (currentAssistant) {
|
||||
yield* adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match && match.state.status === "running") {
|
||||
match.provider = event.data.provider
|
||||
match.time.completed = event.data.timestamp
|
||||
match.state = {
|
||||
status: "error",
|
||||
error: event.data.error,
|
||||
input: match.state.input,
|
||||
structured: match.state.structured,
|
||||
content: match.state.content,
|
||||
}
|
||||
}
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match && (match.state.status === "pending" || match.state.status === "running")) {
|
||||
match.provider = {
|
||||
executed: event.data.provider.executed || match.provider?.executed === true,
|
||||
metadata: match.provider?.metadata,
|
||||
resultMetadata: event.data.provider.metadata,
|
||||
}
|
||||
match.time.completed = event.data.timestamp
|
||||
match.state = castDraft(
|
||||
new SessionMessage.ToolStateError({
|
||||
status: "error",
|
||||
error: event.data.error,
|
||||
input: typeof match.state.input === "string" ? {} : match.state.input,
|
||||
structured: match.state.status === "running" ? match.state.structured : {},
|
||||
content: match.state.status === "running" ? match.state.content : [],
|
||||
result: event.data.result,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -392,11 +372,14 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
yield* adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
draft.content.push(
|
||||
new SessionMessage.AssistantReasoning({
|
||||
type: "reasoning",
|
||||
id: event.data.reasoningID,
|
||||
text: "",
|
||||
}) as DraftReasoning,
|
||||
castDraft(
|
||||
new SessionMessage.AssistantReasoning({
|
||||
type: "reasoning",
|
||||
id: event.data.reasoningID,
|
||||
text: "",
|
||||
providerMetadata: event.data.providerMetadata,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
@ -423,7 +406,10 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
yield* adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestReasoning(draft, event.data.reasoningID)
|
||||
if (match) match.text = event.data.text
|
||||
if (match) {
|
||||
match.text = event.data.text
|
||||
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export * as SessionMessage from "./message"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { ProviderMetadata } from "@opencode-ai/llm"
|
||||
import { EventV2 } from "../event"
|
||||
import { ModelV2 } from "../model"
|
||||
import { ToolOutput } from "../tool-output"
|
||||
|
|
@ -80,6 +81,7 @@ export class ToolStateCompleted extends Schema.Class<ToolStateCompleted>("Sessio
|
|||
attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional),
|
||||
content: ToolOutput.Content.pipe(Schema.Array),
|
||||
structured: ToolOutput.Structured,
|
||||
result: SessionEvent.Tool.Success.data.fields.result,
|
||||
}) {}
|
||||
|
||||
export class ToolStateError extends Schema.Class<ToolStateError>("Session.Message.ToolState.Error")({
|
||||
|
|
@ -88,6 +90,7 @@ export class ToolStateError extends Schema.Class<ToolStateError>("Session.Messag
|
|||
content: ToolOutput.Content.pipe(Schema.Array),
|
||||
structured: ToolOutput.Structured,
|
||||
error: SessionEvent.UnknownError,
|
||||
result: SessionEvent.Tool.Failed.data.fields.result,
|
||||
}) {}
|
||||
|
||||
export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe(
|
||||
|
|
@ -101,7 +104,8 @@ export class AssistantTool extends Schema.Class<AssistantTool>("Session.Message.
|
|||
name: Schema.String,
|
||||
provider: Schema.Struct({
|
||||
executed: Schema.Boolean,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
metadata: ProviderMetadata.pipe(Schema.optional),
|
||||
resultMetadata: ProviderMetadata.pipe(Schema.optional),
|
||||
}).pipe(Schema.optional),
|
||||
state: ToolState,
|
||||
time: Schema.Struct({
|
||||
|
|
@ -114,6 +118,7 @@ export class AssistantTool extends Schema.Class<AssistantTool>("Session.Message.
|
|||
|
||||
export class AssistantText extends Schema.Class<AssistantText>("Session.Message.Assistant.Text")({
|
||||
type: Schema.Literal("text"),
|
||||
id: Schema.String,
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
|
|
@ -121,6 +126,7 @@ export class AssistantReasoning extends Schema.Class<AssistantReasoning>("Sessio
|
|||
type: Schema.Literal("reasoning"),
|
||||
id: Schema.String,
|
||||
text: Schema.String,
|
||||
providerMetadata: ProviderMetadata.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export * as SessionProjector from "./projector"
|
||||
|
||||
import { and, eq, sql } from "drizzle-orm"
|
||||
import { and, desc, eq, sql } from "drizzle-orm"
|
||||
import { DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
|
|
@ -9,6 +9,7 @@ import { SessionV1 } from "../v1/session"
|
|||
import { WorkspaceTable } from "../control-plane/workspace.sql"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionMessageUpdater } from "./message-updater"
|
||||
import { SessionInput } from "./input"
|
||||
import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql"
|
||||
import type { DeepMutable } from "../schema"
|
||||
|
||||
|
|
@ -17,6 +18,9 @@ type DatabaseService = Database.Interface["db"]
|
|||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
|
||||
|
||||
export class PromptAlreadyProjected extends Error {}
|
||||
export class SessionAlreadyProjected extends Error {}
|
||||
|
||||
type Usage = {
|
||||
cost: number
|
||||
tokens: {
|
||||
|
|
@ -105,37 +109,84 @@ function applyUsage(
|
|||
|
||||
function run(db: DatabaseService, event: SessionEvent.Event) {
|
||||
return Effect.gen(function* () {
|
||||
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||
const writeMessage = (message: SessionMessage.Message) => {
|
||||
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
|
||||
const encoded = encodeMessage(message)
|
||||
const { id, type, ...data } = encoded
|
||||
return db
|
||||
.insert(SessionMessageTable)
|
||||
.values([
|
||||
{
|
||||
id: SessionMessage.ID.make(id),
|
||||
session_id: event.data.sessionID,
|
||||
type,
|
||||
seq: event.seq,
|
||||
time_created: DateTime.toEpochMillis(message.time.created),
|
||||
data,
|
||||
},
|
||||
])
|
||||
.onConflictDoUpdate({
|
||||
target: SessionMessageTable.id,
|
||||
set: { type, time_created: DateTime.toEpochMillis(message.time.created), data },
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
const adapter: SessionMessageUpdater.Adapter = {
|
||||
getCurrentAssistant() {
|
||||
return Effect.gen(function* () {
|
||||
const rows = yield* db
|
||||
// A newer turn supersedes stale incomplete rows; never resume an older assistant projection.
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "assistant")),
|
||||
)
|
||||
.all()
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return rows
|
||||
.map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type }))
|
||||
.find(
|
||||
(message): message is SessionMessage.Assistant => message.type === "assistant" && !message.time.completed,
|
||||
if (!row) return
|
||||
const message = decodeRow(row)
|
||||
return message.type === "assistant" && !message.time.completed ? message : undefined
|
||||
})
|
||||
},
|
||||
getAssistant(messageID) {
|
||||
return Effect.gen(function* () {
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.id, messageID),
|
||||
eq(SessionMessageTable.session_id, event.data.sessionID),
|
||||
eq(SessionMessageTable.type, "assistant"),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return
|
||||
const message = decodeRow(row)
|
||||
return message.type === "assistant" ? message : undefined
|
||||
})
|
||||
},
|
||||
getCurrentCompaction() {
|
||||
return Effect.gen(function* () {
|
||||
const rows = yield* db
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "compaction")),
|
||||
)
|
||||
.all()
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return rows
|
||||
.map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type }))
|
||||
.find((message): message is SessionMessage.Compaction => message.type === "compaction")
|
||||
if (!row) return
|
||||
const message = decodeRow(row)
|
||||
return message.type === "compaction" ? message : undefined
|
||||
})
|
||||
},
|
||||
getCurrentShell(callID) {
|
||||
|
|
@ -144,121 +195,18 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
|
|||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "shell")))
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return rows
|
||||
.map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type }))
|
||||
.map(decodeRow)
|
||||
.find((message): message is SessionMessage.Shell => message.type === "shell" && message.callID === callID)
|
||||
})
|
||||
},
|
||||
updateAssistant(message) {
|
||||
return Effect.gen(function* () {
|
||||
const encoded = encodeMessage(message)
|
||||
const { id, type, ...data } = encoded
|
||||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values([
|
||||
{
|
||||
id: SessionMessage.ID.make(id),
|
||||
session_id: event.data.sessionID,
|
||||
type,
|
||||
time_created: DateTime.toEpochMillis(message.time.created),
|
||||
data,
|
||||
},
|
||||
])
|
||||
.onConflictDoUpdate({
|
||||
target: SessionMessageTable.id,
|
||||
set: {
|
||||
type,
|
||||
time_created: DateTime.toEpochMillis(message.time.created),
|
||||
data,
|
||||
},
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
},
|
||||
updateCompaction(message) {
|
||||
return Effect.gen(function* () {
|
||||
const encoded = encodeMessage(message)
|
||||
const { id, type, ...data } = encoded
|
||||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values([
|
||||
{
|
||||
id: SessionMessage.ID.make(id),
|
||||
session_id: event.data.sessionID,
|
||||
type,
|
||||
time_created: DateTime.toEpochMillis(message.time.created),
|
||||
data,
|
||||
},
|
||||
])
|
||||
.onConflictDoUpdate({
|
||||
target: SessionMessageTable.id,
|
||||
set: {
|
||||
type,
|
||||
time_created: DateTime.toEpochMillis(message.time.created),
|
||||
data,
|
||||
},
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
},
|
||||
updateShell(message) {
|
||||
return Effect.gen(function* () {
|
||||
const encoded = encodeMessage(message)
|
||||
const { id, type, ...data } = encoded
|
||||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values([
|
||||
{
|
||||
id: SessionMessage.ID.make(id),
|
||||
session_id: event.data.sessionID,
|
||||
type,
|
||||
time_created: DateTime.toEpochMillis(message.time.created),
|
||||
data,
|
||||
},
|
||||
])
|
||||
.onConflictDoUpdate({
|
||||
target: SessionMessageTable.id,
|
||||
set: {
|
||||
type,
|
||||
time_created: DateTime.toEpochMillis(message.time.created),
|
||||
data,
|
||||
},
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
},
|
||||
appendMessage(message) {
|
||||
return Effect.gen(function* () {
|
||||
const encoded = encodeMessage(message)
|
||||
const { id, type, ...data } = encoded
|
||||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values([
|
||||
{
|
||||
id: SessionMessage.ID.make(id),
|
||||
session_id: event.data.sessionID,
|
||||
type,
|
||||
time_created: DateTime.toEpochMillis(message.time.created),
|
||||
data,
|
||||
},
|
||||
])
|
||||
.onConflictDoUpdate({
|
||||
target: SessionMessageTable.id,
|
||||
set: {
|
||||
type,
|
||||
time_created: DateTime.toEpochMillis(message.time.created),
|
||||
data,
|
||||
},
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
},
|
||||
updateAssistant: writeMessage,
|
||||
updateCompaction: writeMessage,
|
||||
updateShell: writeMessage,
|
||||
appendMessage: writeMessage,
|
||||
}
|
||||
yield* SessionMessageUpdater.update(adapter, event)
|
||||
})
|
||||
|
|
@ -268,9 +216,17 @@ export const layer = Layer.effectDiscard(
|
|||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* events.beforeCommit((event) => SessionInput.guardReservedID(db, event))
|
||||
yield* events.project(SessionV1.Event.Created, (event) =>
|
||||
Effect.gen(function* () {
|
||||
yield* db.insert(SessionTable).values(sessionRow(event.data.info)).run().pipe(Effect.orDie)
|
||||
const stored = yield* db
|
||||
.insert(SessionTable)
|
||||
.values(sessionRow(event.data.info))
|
||||
.onConflictDoNothing()
|
||||
.returning({ sessionID: SessionTable.id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!stored) return yield* Effect.die(new SessionAlreadyProjected())
|
||||
if (event.data.info.workspaceID) {
|
||||
yield* db
|
||||
.update(WorkspaceTable)
|
||||
|
|
@ -361,93 +317,71 @@ export const layer = Layer.effectDiscard(
|
|||
if (next) yield* applyUsage(db, sessionID, next)
|
||||
}),
|
||||
)
|
||||
// session.next.* projectors are disabled while the v2 message projection is stabilized.
|
||||
// The events still publish through EventV2 and fan out through the opencode bridge.
|
||||
// yield* events.project(SessionEvent.AgentSwitched, (event) =>
|
||||
// Effect.gen(function* () {
|
||||
// const message = Schema.encodeSync(SessionMessage.AgentSwitched)(
|
||||
// new SessionMessage.AgentSwitched({
|
||||
// id: event.id,
|
||||
// type: "agent-switched",
|
||||
// metadata: event.metadata,
|
||||
// agent: event.data.agent,
|
||||
// time: { created: event.data.timestamp },
|
||||
// }),
|
||||
// )
|
||||
// const data = { metadata: message.metadata, agent: message.agent, time: message.time }
|
||||
// yield* 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)
|
||||
// yield* db
|
||||
// .insert(SessionMessageTable)
|
||||
// .values([
|
||||
// {
|
||||
// id: SessionMessage.ID.make(event.id),
|
||||
// session_id: event.data.sessionID,
|
||||
// type: "agent-switched",
|
||||
// time_created: DateTime.toEpochMillis(event.data.timestamp),
|
||||
// data,
|
||||
// },
|
||||
// ])
|
||||
// .run()
|
||||
// .pipe(Effect.orDie)
|
||||
// }),
|
||||
// )
|
||||
// yield* events.project(SessionEvent.ModelSwitched, (event) =>
|
||||
// Effect.gen(function* () {
|
||||
// const message = Schema.encodeSync(SessionMessage.ModelSwitched)(
|
||||
// new SessionMessage.ModelSwitched({
|
||||
// id: event.id,
|
||||
// type: "model-switched",
|
||||
// metadata: event.metadata,
|
||||
// model: event.data.model,
|
||||
// time: { created: event.data.timestamp },
|
||||
// }),
|
||||
// )
|
||||
// const data = { metadata: message.metadata, model: message.model, time: message.time }
|
||||
// yield* db
|
||||
// .update(SessionTable)
|
||||
// .set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
|
||||
// .where(eq(SessionTable.id, event.data.sessionID))
|
||||
// .run()
|
||||
// .pipe(Effect.orDie)
|
||||
// yield* db
|
||||
// .insert(SessionMessageTable)
|
||||
// .values([
|
||||
// {
|
||||
// id: SessionMessage.ID.make(event.id),
|
||||
// session_id: event.data.sessionID,
|
||||
// type: "model-switched",
|
||||
// time_created: DateTime.toEpochMillis(event.data.timestamp),
|
||||
// data,
|
||||
// },
|
||||
// ])
|
||||
// .run()
|
||||
// .pipe(Effect.orDie)
|
||||
// }),
|
||||
// )
|
||||
// yield* events.project(SessionEvent.Prompted, (event) => run(db, event))
|
||||
// yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
|
||||
// yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
|
||||
// yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
|
||||
// yield* events.project(SessionEvent.Step.Started, (event) => run(db, event))
|
||||
// yield* events.project(SessionEvent.Step.Ended, (event) => run(db, event))
|
||||
// yield* events.project(SessionEvent.Step.Failed, (event) => run(db, event))
|
||||
// yield* events.project(SessionEvent.Text.Started, (event) => run(db, event))
|
||||
// yield* events.project(SessionEvent.Text.Ended, (event) => run(db, event))
|
||||
// yield* events.project(SessionEvent.Tool.Input.Started, (event) => run(db, event))
|
||||
// yield* events.project(SessionEvent.Tool.Input.Ended, (event) => run(db, event))
|
||||
// yield* events.project(SessionEvent.Tool.Called, (event) => run(db, event))
|
||||
// yield* events.project(SessionEvent.Tool.Success, (event) => run(db, event))
|
||||
// yield* events.project(SessionEvent.Tool.Failed, (event) => run(db, event))
|
||||
// yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
|
||||
// yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
|
||||
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))),
|
||||
)
|
||||
yield* events.project(SessionEvent.ModelSwitched, (event) =>
|
||||
db.update(SessionTable)
|
||||
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
|
||||
)
|
||||
yield* events.project(SessionEvent.Prompted, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const existing = yield* db
|
||||
.select({ id: SessionMessageTable.id })
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, event.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (existing) return yield* Effect.die(new PromptAlreadyProjected())
|
||||
yield* run(db, event)
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, event.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return yield* Effect.die("Prompt projection was not stored")
|
||||
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||
if (message.type !== "user") return yield* Effect.die("Prompt projection did not produce a user message")
|
||||
if (event.seq === undefined)
|
||||
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
|
||||
yield* SessionInput.project(db, {
|
||||
id: SessionMessage.ID.make(event.id),
|
||||
sessionID: event.data.sessionID,
|
||||
prompt: event.data.prompt,
|
||||
delivery: event.data.delivery,
|
||||
timeCreated: event.data.timestamp,
|
||||
promotedSeq: event.seq,
|
||||
})
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Step.Started, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Step.Ended, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Step.Failed, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Text.Started, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Text.Ended, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Tool.Input.Started, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Tool.Input.Ended, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Tool.Called, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Tool.Progress, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Tool.Success, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Tool.Failed, (event) => run(db, event))
|
||||
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.Started, (event) => run(db, event))
|
||||
// yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Compaction.Started, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Compaction.Delta, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event))
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -46,4 +46,15 @@ export class Prompt extends Schema.Class<Prompt>("Prompt")({
|
|||
files: Schema.Array(FileAttachment).pipe(Schema.optional),
|
||||
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
|
||||
references: Schema.Array(ReferenceAttachment).pipe(Schema.optional),
|
||||
}) {}
|
||||
}) {
|
||||
static readonly equivalence = Schema.toEquivalence(Prompt)
|
||||
|
||||
static fromUserMessage(input: Pick<Prompt, "text" | "files" | "agents" | "references">) {
|
||||
return new Prompt({
|
||||
text: input.text,
|
||||
...(input.files === undefined ? {} : { files: input.files }),
|
||||
...(input.agents === undefined ? {} : { agents: input.agents }),
|
||||
...(input.references === undefined ? {} : { references: input.references }),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
183
packages/core/src/session/run-coordinator.ts
Normal file
183
packages/core/src/session/run-coordinator.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
export * as SessionRunCoordinator from "./run-coordinator"
|
||||
|
||||
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Scope } from "effect"
|
||||
import { SessionRunner } from "./runner"
|
||||
import { SessionSchema } from "./schema"
|
||||
|
||||
export type Mode = "run" | "wake"
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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) => Effect.Effect<void>
|
||||
/** Waits until the current ownership chain settles. */
|
||||
readonly awaitIdle: (key: Key) => Effect.Effect<void, E>
|
||||
}
|
||||
|
||||
type Entry<A, E> = {
|
||||
readonly done: Deferred.Deferred<A, E>
|
||||
mode: Mode
|
||||
rerun?: Mode
|
||||
explicit?: Deferred.Deferred<A, E>
|
||||
}
|
||||
|
||||
const strongest = (left: Mode | undefined, right: Mode): Mode => (left === "run" || right === "run" ? "run" : "wake")
|
||||
|
||||
/** 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> =>
|
||||
Effect.gen(function* () {
|
||||
const active = new Map<Key, Entry<A, E>>()
|
||||
const scope = yield* Effect.scope
|
||||
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()
|
||||
}),
|
||||
)
|
||||
|
||||
const makeEntry = (mode: Mode, explicit?: Deferred.Deferred<A, E>): Entry<A, E> => ({
|
||||
done: Deferred.makeUnsafe<A, E>(),
|
||||
mode,
|
||||
explicit,
|
||||
})
|
||||
|
||||
const start = (key: Key, entry: Entry<A, E>, mode: Mode) => {
|
||||
fork(own(key, entry, mode))
|
||||
}
|
||||
|
||||
const own = (key: Key, entry: Entry<A, E>, mode: Mode): Effect.Effect<void> =>
|
||||
Effect.suspend(() => options.drain(key, mode)).pipe(
|
||||
Effect.exit,
|
||||
Effect.flatMap((exit) => {
|
||||
if (closed) return Deferred.done(entry.done, exit).pipe(Effect.asVoid)
|
||||
if (mode === "run" && entry.explicit !== undefined) {
|
||||
Deferred.doneUnsafe(entry.explicit, exit)
|
||||
entry.explicit = undefined
|
||||
}
|
||||
if (exit._tag === "Success") {
|
||||
if (active.get(key) !== entry) return Deferred.done(entry.done, exit).pipe(Effect.asVoid)
|
||||
if (entry.rerun !== undefined) {
|
||||
const mode = entry.rerun
|
||||
entry.rerun = undefined
|
||||
entry.mode = mode
|
||||
return own(key, entry, mode)
|
||||
}
|
||||
active.delete(key)
|
||||
return Deferred.done(entry.done, exit).pipe(Effect.asVoid)
|
||||
}
|
||||
|
||||
const successor =
|
||||
active.get(key) === entry && entry.rerun !== undefined ? makeEntry(entry.rerun, entry.explicit) : undefined
|
||||
if (successor === undefined) active.delete(key)
|
||||
else {
|
||||
active.set(key, successor)
|
||||
}
|
||||
if (successor !== undefined) start(key, successor, successor.mode)
|
||||
const report =
|
||||
mode === "wake" && options.onFailure !== undefined
|
||||
? options.onFailure(key, exit.cause).pipe(Effect.forkIn(scope), Effect.asVoid)
|
||||
: Effect.void
|
||||
return Deferred.done(entry.done, exit).pipe(Effect.andThen(report), Effect.asVoid)
|
||||
}),
|
||||
)
|
||||
|
||||
const wake = (key: Key) =>
|
||||
Effect.sync(() => {
|
||||
if (closed) return
|
||||
const entry = active.get(key)
|
||||
if (entry !== undefined) {
|
||||
entry.rerun = strongest(entry.rerun, "wake")
|
||||
return
|
||||
}
|
||||
|
||||
const next = makeEntry("wake")
|
||||
active.set(key, next)
|
||||
start(key, next, "wake")
|
||||
})
|
||||
|
||||
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.done).pipe(Effect.exit),
|
||||
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)
|
||||
})
|
||||
|
||||
return { run, wake, awaitIdle }
|
||||
|
||||
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.mode === "wake") {
|
||||
entry.rerun = "run"
|
||||
entry.explicit ??= Deferred.makeUnsafe<A, E>()
|
||||
return restore(awaitRun(entry.explicit))
|
||||
}
|
||||
return restore(awaitRun(entry.done))
|
||||
}
|
||||
|
||||
const next = makeEntry("run")
|
||||
active.set(key, next)
|
||||
start(key, next, "run")
|
||||
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)))
|
||||
}
|
||||
})
|
||||
|
||||
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,
|
||||
Effect.gen(function* () {
|
||||
const runner = yield* SessionRunner.Service
|
||||
return Service.of(
|
||||
yield* make<SessionSchema.ID, void, SessionRunner.RunError>({
|
||||
drain: (sessionID, mode) => runner.run({ sessionID, force: mode === "run" }),
|
||||
onFailure: (sessionID, cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.void
|
||||
: Effect.logError("Failed to drain Session").pipe(
|
||||
Effect.annotateLogs("sessionID", sessionID),
|
||||
Effect.annotateLogs("cause", cause),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
28
packages/core/src/session/runner/index.ts
Normal file
28
packages/core/src/session/runner/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export * as SessionRunner from "./index"
|
||||
|
||||
import type { LLMError } from "@opencode-ai/llm"
|
||||
import { Context, Effect, Schema } from "effect"
|
||||
import { SessionSchema } from "../schema"
|
||||
import type { MessageDecodeError } from "../error"
|
||||
import { SessionRunnerModel } from "./model"
|
||||
|
||||
export class StepLimitExceededError extends Schema.TaggedErrorClass<StepLimitExceededError>()(
|
||||
"SessionRunner.StepLimitExceededError",
|
||||
{
|
||||
sessionID: SessionSchema.ID,
|
||||
limit: Schema.Int,
|
||||
},
|
||||
) {}
|
||||
|
||||
export type RunError = LLMError | SessionRunnerModel.Error | MessageDecodeError | StepLimitExceededError
|
||||
|
||||
/** Runs one local continuation from already-recorded Session history. */
|
||||
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
|
||||
}) => Effect.Effect<void, RunError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRunner") {}
|
||||
262
packages/core/src/session/runner/llm.ts
Normal file
262
packages/core/src/session/runner/llm.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
import { LLM, LLMClient, LLMError, LLMEvent } from "@opencode-ai/llm"
|
||||
import { Cause, DateTime, Effect, FiberSet, Layer, Semaphore, Stream } from "effect"
|
||||
import { EventV2 } from "../../event"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import { SessionSchema } from "../schema"
|
||||
import { SessionEvent } from "../event"
|
||||
import { SessionStore } from "../store"
|
||||
import { Service, StepLimitExceededError } from "./index"
|
||||
import { createLLMEventPublisher } from "./publish-llm-event"
|
||||
import { toLLMMessages } from "./to-llm-message"
|
||||
import { ToolRegistry } from "../../tool-registry"
|
||||
import { SessionRunnerModel } from "./model"
|
||||
import { Database } from "../../database/database"
|
||||
import { SessionInput } from "../input"
|
||||
import { QuestionV2 } from "../../question"
|
||||
|
||||
/**
|
||||
* Runs one durable coding-agent Session until it settles.
|
||||
*
|
||||
* Keep this as orchestration over smaller collaborators rather than rebuilding the legacy
|
||||
* `SessionPrompt` monolith. Implement the unchecked items in small reviewed slices:
|
||||
*
|
||||
* - Session ownership and controls
|
||||
* - [x] Coordinate one local active drain per Session; explicit resumes join and prompt wakeups coalesce.
|
||||
* - [ ] Replace local ownership with durable multi-node ownership when clustered.
|
||||
* - [ ] Mark busy, retrying, idle, interrupted, or terminal-failure status durably.
|
||||
* - [ ] Honor interruption and reject stale work after runtime attachment replacement.
|
||||
* - [x] Bound model steps.
|
||||
* - [ ] Bound provider retries and repeated identical tool calls.
|
||||
*
|
||||
* - Runtime context assembly
|
||||
* - [x] Load Session placement and chronological projected V2 history.
|
||||
* - [x] Resolve the selected model through the location-scoped runner environment.
|
||||
* - [ ] Load the selected agent and effective permissions.
|
||||
* - [ ] Build provider/model-specific base instructions and environment facts.
|
||||
* - [ ] Load configured project instructions such as `AGENTS.md`, remote instructions, and
|
||||
* nearby nested instructions discovered while files are read.
|
||||
* - [ ] List available skills in the system prompt and expose a tool for loading skill bodies.
|
||||
* - [ ] Resolve referenced files, directories, agents, repositories, MCP resources, and media.
|
||||
* - [ ] Apply steering reminders, plugin transforms, and structured-output policy.
|
||||
* - [ ] Compact or summarize history when context pressure requires it.
|
||||
*
|
||||
* - One provider turn
|
||||
* - [x] Translate every projected V2 Session message variant into canonical
|
||||
* `@opencode-ai/llm` messages.
|
||||
* - [ ] Resolve policy-filtered built-in, MCP, plugin, and structured-output tool definitions.
|
||||
* - [x] Stream exactly one `llm.stream(request)` provider turn.
|
||||
* - [x] Persist assistant text and usage events incrementally as they arrive.
|
||||
* - [ ] Persist snapshots, patches, and retry notices incrementally as they arrive.
|
||||
* - [x] Persist reasoning, provider errors, and tool-call events incrementally as they arrive.
|
||||
*
|
||||
* - Tool settlement and continuation
|
||||
* - [x] Durably record each tool call before side effects begin.
|
||||
* - [x] Authorize and execute recorded local calls through a core-owned registry hook.
|
||||
* - [x] Persist typed success, failure, and provider-executed tool outcomes.
|
||||
* - [x] Start each recorded local call eagerly and await all settlements before continuation.
|
||||
* - [ ] Add scoped runtime context, progress updates, output truncation, attachment normalization,
|
||||
* plugins, and cancellation settlement.
|
||||
* - [x] Reload projected history and start the next explicit provider turn after local tool results.
|
||||
* - [x] Continue for durable user steering accepted during an active provider turn.
|
||||
* - [ ] Continue for compaction or another continuation condition when required.
|
||||
*
|
||||
* - Post-run maintenance
|
||||
* - [ ] Settle final status and expose durable output events to replayable consumers.
|
||||
* - [ ] Coalesce streamed deltas and add covering projected-history indexes.
|
||||
* - [ ] 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.
|
||||
*
|
||||
* 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 a
|
||||
* bounded explicit loop starts the next provider turn after local settlement.
|
||||
*/
|
||||
|
||||
// QUESTION: Did this exist previously, or did we add this limit? Does it make sense?
|
||||
const MAX_STEPS = 25
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
const tools = yield* ToolRegistry.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
|
||||
return session
|
||||
})
|
||||
|
||||
const getContext = Effect.fn("SessionRunner.getContext")(function* (sessionID: SessionSchema.ID) {
|
||||
return yield* store.context(sessionID)
|
||||
})
|
||||
|
||||
const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
for (const message of yield* getContext(sessionID)) {
|
||||
if (message.type !== "assistant") continue
|
||||
for (const tool of message.content) {
|
||||
if (tool.type !== "tool" || (tool.state.status !== "pending" && tool.state.status !== "running")) continue
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
assistantMessageID: message.id,
|
||||
callID: tool.id,
|
||||
error: { type: "unknown", message: "Tool execution interrupted" },
|
||||
provider: {
|
||||
executed: tool.provider?.executed === true,
|
||||
...(tool.provider?.metadata === undefined ? {} : { metadata: tool.provider.metadata }),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, never>) =>
|
||||
Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))
|
||||
|
||||
// Match V1: dismissing a question halts the loop instead of becoming model-facing tool output.
|
||||
const isQuestionRejected = (cause: Cause.Cause<unknown>) =>
|
||||
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
|
||||
|
||||
const runTurn = Effect.fn("SessionRunner.runTurn")(function* (
|
||||
session: SessionSchema.Info,
|
||||
promotion: "steer" | "queue" | undefined,
|
||||
) {
|
||||
const model = yield* models.resolve(session)
|
||||
const toolFibers = yield* FiberSet.make<void, never>()
|
||||
let needsContinuation = false
|
||||
if (promotion === "steer") yield* SessionInput.promoteSteers(db, events, session.id)
|
||||
if (promotion === "queue") {
|
||||
yield* SessionInput.promoteNextQueued(db, events, session.id)
|
||||
yield* SessionInput.promoteSteers(db, events, session.id)
|
||||
}
|
||||
yield* failInterruptedTools(session.id)
|
||||
const context = yield* getContext(session.id)
|
||||
const request = LLM.request({ model, messages: toLLMMessages(context, model), tools: yield* tools.definitions() })
|
||||
const publisher = createLLMEventPublisher(events, {
|
||||
sessionID: session.id,
|
||||
agent: session.agent ?? "build",
|
||||
model: {
|
||||
id: ModelV2.ID.make(model.id),
|
||||
providerID: ProviderV2.ID.make(model.provider),
|
||||
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
|
||||
},
|
||||
})
|
||||
const withPublication = Semaphore.makeUnsafe(1).withPermit
|
||||
const publish = (event: LLMEvent) => withPublication(publisher.publish(event))
|
||||
const providerStream = llm.stream(request).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
yield* publish(event)
|
||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||
needsContinuation = true
|
||||
yield* tools.settle({ sessionID: session.id, call: event }).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
if (isQuestionRejected(cause)) return Effect.failCause(cause)
|
||||
return Effect.succeed({
|
||||
result: { type: "error" as const, value: String(Cause.squash(cause)) },
|
||||
output: undefined,
|
||||
})
|
||||
}),
|
||||
Effect.flatMap((settlement) =>
|
||||
publish(
|
||||
LLMEvent.toolResult({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
result: settlement.result,
|
||||
output: settlement.output,
|
||||
}),
|
||||
),
|
||||
),
|
||||
FiberSet.run(toolFibers),
|
||||
)
|
||||
}),
|
||||
),
|
||||
Effect.ensuring(withPublication(publisher.flush())),
|
||||
)
|
||||
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const stream = yield* restore(providerStream).pipe(Effect.exit)
|
||||
let llmFailure: LLMError | undefined
|
||||
if (stream._tag === "Failure") {
|
||||
for (const reason of stream.cause.reasons) {
|
||||
if (!Cause.isFailReason(reason)) continue
|
||||
if (reason.error instanceof LLMError) llmFailure = reason.error
|
||||
}
|
||||
}
|
||||
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 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
|
||||
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
|
||||
if (settled._tag === "Failure" && isQuestionRejected(settled.cause)) {
|
||||
yield* FiberSet.clear(toolFibers)
|
||||
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
return yield* Effect.interrupt
|
||||
}
|
||||
if (
|
||||
(stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) ||
|
||||
(settled._tag === "Failure" && Cause.hasInterrupts(settled.cause))
|
||||
) {
|
||||
yield* FiberSet.clear(toolFibers)
|
||||
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
}
|
||||
if (publisher.hasProviderError())
|
||||
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
if (stream._tag === "Success" && !publisher.hasProviderError())
|
||||
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
||||
const attempt = stream._tag === "Failure" ? stream : settled
|
||||
if (attempt._tag === "Failure") return yield* Effect.failCause(attempt.cause)
|
||||
return !publisher.hasProviderError() && needsContinuation
|
||||
}),
|
||||
)
|
||||
}, Effect.scoped)
|
||||
|
||||
const run = Effect.fn("SessionRunner.run")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force?: boolean
|
||||
}) {
|
||||
const session = yield* getSession(input.sessionID)
|
||||
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, ["steer"])
|
||||
const hasQueue = yield* SessionInput.hasPending(db, input.sessionID, ["queue"])
|
||||
if (input.force !== true && !hasSteer && !hasQueue) return
|
||||
let promotion: "steer" | "queue" | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
|
||||
let openActivity = input.force === true || hasSteer || hasQueue
|
||||
while (openActivity) {
|
||||
let needsContinuation = true
|
||||
for (let step = 0; step < MAX_STEPS; step++) {
|
||||
needsContinuation = yield* runTurn(session, promotion)
|
||||
promotion = "steer"
|
||||
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, ["steer"])
|
||||
if (!needsContinuation) break
|
||||
}
|
||||
if (needsContinuation)
|
||||
return yield* new StepLimitExceededError({ sessionID: input.sessionID, limit: MAX_STEPS })
|
||||
openActivity = yield* SessionInput.hasPending(db, input.sessionID, ["queue"])
|
||||
promotion = openActivity ? "queue" : undefined
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
run,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
141
packages/core/src/session/runner/model.ts
Normal file
141
packages/core/src/session/runner/model.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
export * as SessionRunnerModel from "./model"
|
||||
|
||||
import { type Model } from "@opencode-ai/llm"
|
||||
import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages"
|
||||
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 { produce } from "immer"
|
||||
import { Catalog } from "../../catalog"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { PluginBoot } from "../../plugin/boot"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import { SessionSchema } from "../schema"
|
||||
|
||||
export class ModelNotSelectedError extends Schema.TaggedErrorClass<ModelNotSelectedError>()(
|
||||
"SessionRunnerModel.ModelNotSelectedError",
|
||||
{
|
||||
sessionID: SessionSchema.ID,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiError>()(
|
||||
"SessionRunnerModel.UnsupportedApiError",
|
||||
{
|
||||
providerID: ProviderV2.ID,
|
||||
modelID: ModelV2.ID,
|
||||
api: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export type Error =
|
||||
| Catalog.ProviderNotFoundError
|
||||
| Catalog.ModelNotFoundError
|
||||
| ModelNotSelectedError
|
||||
| UnsupportedApiError
|
||||
|
||||
export interface Interface {
|
||||
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Model, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRunnerModel") {}
|
||||
|
||||
/** 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, provider?: ProviderV2.Info) => {
|
||||
const value = model.request.body.apiKey ?? model.api.settings?.apiKey
|
||||
if (typeof value === "string") return Auth.value(value)
|
||||
return provider?.enabled !== false && provider?.enabled.via === "env" ? Auth.config(provider.enabled.name) : undefined
|
||||
}
|
||||
|
||||
const withDefaults = (model: ModelV2.Info, route: AnyRoute) =>
|
||||
route.with({
|
||||
provider: model.providerID,
|
||||
endpoint: model.api.url === undefined ? undefined : { baseURL: model.api.url },
|
||||
headers: model.request.headers,
|
||||
http: {
|
||||
body: Object.fromEntries(Object.entries(model.request.body).filter(([key]) => key !== "apiKey")),
|
||||
},
|
||||
limits: { context: model.limit.context, output: model.limit.output },
|
||||
})
|
||||
|
||||
const withVariant = (model: ModelV2.Info, variantID: ModelV2.VariantID | undefined) => {
|
||||
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) => {
|
||||
Object.assign(draft.request.headers, variant.headers)
|
||||
Object.assign(draft.request.body, variant.body)
|
||||
})
|
||||
}
|
||||
|
||||
const apiName = (model: ModelV2.Info) =>
|
||||
model.api.type === "aisdk" ? `${model.api.type}:${model.api.package}` : model.api.type
|
||||
|
||||
export const fromCatalogModel = (
|
||||
model: ModelV2.Info,
|
||||
provider?: ProviderV2.Info,
|
||||
): Effect.Effect<Model, UnsupportedApiError> => {
|
||||
const key = apiKey(model, provider)
|
||||
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai") {
|
||||
return Effect.succeed(
|
||||
withDefaults(model, OpenAIResponses.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: model.api.id }),
|
||||
)
|
||||
}
|
||||
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/anthropic") {
|
||||
return Effect.succeed(
|
||||
withDefaults(model, AnthropicMessages.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
|
||||
.model({ id: model.api.id }),
|
||||
)
|
||||
}
|
||||
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai-compatible" && model.api.url) {
|
||||
return Effect.succeed(
|
||||
withDefaults(model, OpenAICompatibleChat.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: model.api.id }),
|
||||
)
|
||||
}
|
||||
return Effect.fail(
|
||||
new UnsupportedApiError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
api: apiName(model),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, provider?: ProviderV2.Info) =>
|
||||
fromCatalogModel(withVariant(model, session.model?.variant), provider)
|
||||
|
||||
export const supported = (model: ModelV2.Info) =>
|
||||
model.api.type === "aisdk" &&
|
||||
(model.api.package === "@ai-sdk/openai" ||
|
||||
model.api.package === "@ai-sdk/anthropic" ||
|
||||
(model.api.package === "@ai-sdk/openai-compatible" && model.api.url !== undefined))
|
||||
|
||||
/** Resolves models from the catalog belonging to the current Location runtime. */
|
||||
export const locationLayer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.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 preferred = yield* catalog.model.default()
|
||||
const selected = session.model
|
||||
? yield* catalog.model.get(session.model.providerID, session.model.id)
|
||||
: (Option.getOrUndefined(preferred.pipe(Option.filter(supported))) ??
|
||||
(yield* catalog.model.available()).find(supported))
|
||||
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
|
||||
return yield* resolve(session, selected, yield* catalog.provider.get(selected.providerID))
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
355
packages/core/src/session/runner/publish-llm-event.ts
Normal file
355
packages/core/src/session/runner/publish-llm-event.ts
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
import { ToolOutput as LLMToolOutput, type LLMEvent, type ProviderMetadata, type ToolOutput as LLMToolOutputType, type ToolResultValue, type Usage } from "@opencode-ai/llm"
|
||||
import { DateTime, Effect } from "effect"
|
||||
import { EventV2 } from "../../event"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { SessionEvent } from "../event"
|
||||
import { SessionSchema } from "../schema"
|
||||
|
||||
type Input = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: string
|
||||
readonly model: ModelV2.Ref
|
||||
}
|
||||
|
||||
const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
|
||||
|
||||
const tokens = (usage: Usage | undefined) => {
|
||||
const reasoning = safe(usage?.reasoningTokens)
|
||||
const read = safe(usage?.cacheReadInputTokens)
|
||||
const write = safe(usage?.cacheWriteInputTokens)
|
||||
return {
|
||||
input: safe(usage?.nonCachedInputTokens),
|
||||
output: safe(usage?.visibleOutputTokens),
|
||||
reasoning,
|
||||
cache: { read, write },
|
||||
}
|
||||
}
|
||||
|
||||
const record = (value: unknown): Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : { value }
|
||||
|
||||
const message = (value: unknown) => {
|
||||
if (typeof value === "string") return value
|
||||
try {
|
||||
return JSON.stringify(value) ?? String(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
type ToolOutput =
|
||||
| { readonly structured: Record<string, unknown>; readonly content: LLMToolOutputType["content"] }
|
||||
| { readonly error: { readonly type: "unknown"; readonly message: string } }
|
||||
|
||||
const settledOutput = (value: LLMToolOutputType | undefined, result: ToolResultValue): ToolOutput => {
|
||||
if (result.type === "error") return { error: { type: "unknown", message: message(result.value) } }
|
||||
const settled = value ?? LLMToolOutput.fromResultValue(result)
|
||||
if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`)
|
||||
return { structured: record(settled.structured), content: settled.content }
|
||||
}
|
||||
|
||||
/** Persist one provider turn without executing tools or starting a continuation turn. */
|
||||
export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) => {
|
||||
const tools = new Map<
|
||||
string,
|
||||
{ readonly assistantMessageID: EventV2.ID; readonly name: string; inputEnded: boolean; called: boolean; settled: boolean; providerExecuted: boolean; providerMetadata?: ProviderMetadata }
|
||||
>()
|
||||
const timestamp = DateTime.now
|
||||
let assistantMessageID: EventV2.ID | undefined
|
||||
let providerFailed = false
|
||||
|
||||
const startAssistant = Effect.fnUntraced(function* () {
|
||||
if (assistantMessageID !== undefined) return assistantMessageID
|
||||
assistantMessageID = (yield* events.publish(SessionEvent.Step.Started, { ...input, timestamp: yield* timestamp })).id
|
||||
return assistantMessageID
|
||||
})
|
||||
const currentAssistantMessageID = () => assistantMessageID === undefined
|
||||
? Effect.die("Tool event before assistant step start")
|
||||
: Effect.succeed(assistantMessageID)
|
||||
|
||||
const fragments = (name: string, ended: (id: string, value: string, providerMetadata?: ProviderMetadata) => Effect.Effect<void>) => {
|
||||
const chunks = new Map<string, string[]>()
|
||||
const start = (id: string) =>
|
||||
Effect.suspend(() => {
|
||||
if (chunks.has(id)) return Effect.die(`Duplicate ${name} start: ${id}`)
|
||||
chunks.set(id, [])
|
||||
return Effect.void
|
||||
})
|
||||
const append = (id: string, value: string) =>
|
||||
Effect.suspend(() => {
|
||||
const current = chunks.get(id)
|
||||
if (!current) return Effect.die(`${name} delta before start: ${id}`)
|
||||
current.push(value)
|
||||
return Effect.void
|
||||
})
|
||||
const end = Effect.fnUntraced(function* (id: string, providerMetadata?: ProviderMetadata) {
|
||||
const current = chunks.get(id)
|
||||
if (!current) return yield* Effect.die(`${name} end before start: ${id}`)
|
||||
yield* ended(id, current.join(""), providerMetadata)
|
||||
chunks.delete(id)
|
||||
})
|
||||
const flush = Effect.fnUntraced(function* () {
|
||||
for (const id of chunks.keys()) yield* end(id)
|
||||
})
|
||||
return { start, append, end, flush }
|
||||
}
|
||||
|
||||
const text = fragments("text", (textID, value) =>
|
||||
Effect.gen(function* () {
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* timestamp,
|
||||
textID,
|
||||
text: value,
|
||||
})
|
||||
}),
|
||||
)
|
||||
const reasoning = fragments("reasoning", (reasoningID, value, providerMetadata) =>
|
||||
Effect.gen(function* () {
|
||||
yield* events.publish(SessionEvent.Reasoning.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* timestamp,
|
||||
reasoningID,
|
||||
text: value,
|
||||
providerMetadata,
|
||||
})
|
||||
}),
|
||||
)
|
||||
const toolInput = fragments("tool input", (callID, value) =>
|
||||
Effect.gen(function* () {
|
||||
const tool = tools.get(callID)
|
||||
if (!tool) return yield* Effect.die(`Tool input end before start: ${callID}`)
|
||||
yield* events.publish(SessionEvent.Tool.Input.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* timestamp,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID,
|
||||
text: value,
|
||||
})
|
||||
tool.inputEnded = true
|
||||
}),
|
||||
)
|
||||
|
||||
const flushFragments = Effect.fnUntraced(function* () {
|
||||
yield* text.flush()
|
||||
yield* reasoning.flush()
|
||||
yield* toolInput.flush()
|
||||
})
|
||||
|
||||
const startToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) {
|
||||
if (tools.has(event.id)) return yield* Effect.die(`Duplicate tool input start: ${event.id}`)
|
||||
const assistantMessageID = yield* currentAssistantMessageID()
|
||||
tools.set(event.id, { assistantMessageID, name: event.name, inputEnded: false, called: false, settled: false, providerExecuted: false })
|
||||
yield* toolInput.start(event.id)
|
||||
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* timestamp,
|
||||
assistantMessageID,
|
||||
callID: event.id,
|
||||
name: event.name,
|
||||
})
|
||||
})
|
||||
|
||||
const endToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) {
|
||||
const tool = tools.get(event.id)
|
||||
if (!tool) return yield* Effect.die(`Tool input end before start: ${event.id}`)
|
||||
if (tool.name !== event.name) return yield* Effect.die(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`)
|
||||
if (tool.inputEnded) return yield* Effect.die(`Duplicate tool input end: ${event.id}`)
|
||||
yield* toolInput.end(event.id)
|
||||
})
|
||||
|
||||
const flush = Effect.fn("SessionRunner.flush")(function* () {
|
||||
yield* flushFragments()
|
||||
})
|
||||
|
||||
const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
|
||||
message: string,
|
||||
hostedOnly = false,
|
||||
) {
|
||||
for (const [callID, tool] of tools) {
|
||||
if (tool.settled || (hostedOnly && !tool.providerExecuted)) continue
|
||||
tool.settled = true
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* timestamp,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID,
|
||||
error: { type: "unknown", message },
|
||||
provider: {
|
||||
executed: tool.providerExecuted,
|
||||
...(tool.providerMetadata === undefined ? {} : { metadata: tool.providerMetadata }),
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent) {
|
||||
switch (event.type) {
|
||||
case "step-start":
|
||||
yield* startAssistant()
|
||||
return
|
||||
case "text-start":
|
||||
yield* text.start(event.id)
|
||||
yield* events.publish(SessionEvent.Text.Started, { sessionID: input.sessionID, timestamp: yield* timestamp, textID: event.id })
|
||||
return
|
||||
case "text-delta":
|
||||
yield* text.append(event.id, event.text)
|
||||
yield* events.publish(SessionEvent.Text.Delta, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* timestamp,
|
||||
textID: event.id,
|
||||
delta: event.text,
|
||||
})
|
||||
return
|
||||
case "text-end":
|
||||
yield* text.end(event.id)
|
||||
return
|
||||
case "reasoning-start":
|
||||
yield* reasoning.start(event.id)
|
||||
yield* events.publish(SessionEvent.Reasoning.Started, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* timestamp,
|
||||
reasoningID: event.id,
|
||||
providerMetadata: event.providerMetadata,
|
||||
})
|
||||
return
|
||||
case "reasoning-delta":
|
||||
yield* reasoning.append(event.id, event.text)
|
||||
yield* events.publish(SessionEvent.Reasoning.Delta, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* timestamp,
|
||||
reasoningID: event.id,
|
||||
delta: event.text,
|
||||
})
|
||||
return
|
||||
case "reasoning-end":
|
||||
yield* reasoning.end(event.id, event.providerMetadata)
|
||||
return
|
||||
case "tool-input-start":
|
||||
yield* startToolInput(event)
|
||||
return
|
||||
case "tool-input-delta": {
|
||||
const tool = tools.get(event.id)
|
||||
if (!tool) return yield* Effect.die(`Tool input delta before start: ${event.id}`)
|
||||
if (tool.name !== event.name) return yield* Effect.die(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`)
|
||||
if (tool.inputEnded) return yield* Effect.die(`Tool input delta after end: ${event.id}`)
|
||||
yield* toolInput.append(event.id, event.text)
|
||||
yield* events.publish(SessionEvent.Tool.Input.Delta, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* timestamp,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID: event.id,
|
||||
delta: event.text,
|
||||
})
|
||||
return
|
||||
}
|
||||
case "tool-input-end":
|
||||
yield* endToolInput(event)
|
||||
return
|
||||
case "tool-call": {
|
||||
if (!tools.has(event.id)) yield* startToolInput(event)
|
||||
const tool = tools.get(event.id)!
|
||||
if (!tool.inputEnded) yield* endToolInput(event)
|
||||
if (tool.name !== event.name) return yield* Effect.die(`Tool call name changed for ${event.id}: ${tool.name} -> ${event.name}`)
|
||||
if (tool.called) return yield* Effect.die(`Duplicate tool call: ${event.id}`)
|
||||
tool.called = true
|
||||
tool.providerExecuted = event.providerExecuted === true
|
||||
tool.providerMetadata = event.providerMetadata
|
||||
yield* events.publish(SessionEvent.Tool.Called, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* timestamp,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID: event.id,
|
||||
tool: event.name,
|
||||
input: record(event.input),
|
||||
provider: {
|
||||
executed: tool.providerExecuted,
|
||||
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
case "tool-result": {
|
||||
const tool = tools.get(event.id)
|
||||
if (!tool?.called) return yield* Effect.die(`Tool result before call: ${event.id}`)
|
||||
if (tool.name !== event.name) return yield* Effect.die(`Tool result name changed for ${event.id}: ${tool.name} -> ${event.name}`)
|
||||
if (tool.settled) {
|
||||
if (event.result.type === "error") return
|
||||
return yield* Effect.die(`Duplicate tool result: ${event.id}`)
|
||||
}
|
||||
tool.settled = true
|
||||
const result = settledOutput(event.output, event.result)
|
||||
const provider = {
|
||||
executed: event.providerExecuted === true || tool.providerExecuted,
|
||||
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
|
||||
}
|
||||
if ("error" in result) {
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* timestamp,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID: event.id,
|
||||
error: result.error,
|
||||
result: event.result,
|
||||
provider,
|
||||
})
|
||||
return
|
||||
}
|
||||
yield* events.publish(SessionEvent.Tool.Success, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* timestamp,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID: event.id,
|
||||
...result,
|
||||
result: event.result,
|
||||
provider,
|
||||
})
|
||||
return
|
||||
}
|
||||
case "tool-error": {
|
||||
const tool = tools.get(event.id)
|
||||
if (!tool?.called) return yield* Effect.die(`Tool error before call: ${event.id}`)
|
||||
if (tool.name !== event.name) return yield* Effect.die(`Tool error name changed for ${event.id}: ${tool.name} -> ${event.name}`)
|
||||
if (tool.settled) return yield* Effect.die(`Duplicate tool error: ${event.id}`)
|
||||
tool.settled = true
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* timestamp,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID: event.id,
|
||||
error: { type: "unknown", message: event.message },
|
||||
provider: {
|
||||
executed: tool.providerExecuted,
|
||||
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
case "step-finish":
|
||||
yield* flush()
|
||||
yield* events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* timestamp,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
finish: event.reason,
|
||||
cost: 0,
|
||||
tokens: tokens(event.usage),
|
||||
})
|
||||
return
|
||||
case "finish":
|
||||
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 },
|
||||
})
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
return { publish, flush, failUnsettledTools, hasProviderError: () => providerFailed, startAssistant }
|
||||
}
|
||||
130
packages/core/src/session/runner/to-llm-message.ts
Normal file
130
packages/core/src/session/runner/to-llm-message.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { Message, ToolCallPart, ToolOutput, ToolResultPart, type ContentPart, type Model, type ProviderMetadata } from "@opencode-ai/llm"
|
||||
import { SessionMessage } from "../message"
|
||||
import type { FileAttachment } from "../prompt"
|
||||
|
||||
const media = (file: FileAttachment): ContentPart => ({
|
||||
type: "media",
|
||||
mediaType: file.mime,
|
||||
data: file.uri,
|
||||
filename: file.name,
|
||||
metadata: file.description === undefined ? undefined : { description: file.description },
|
||||
})
|
||||
|
||||
const toolInput = (tool: SessionMessage.AssistantTool) => {
|
||||
if (tool.state.status !== "pending") return tool.state.input
|
||||
try {
|
||||
return JSON.parse(tool.state.input) as unknown
|
||||
} catch {
|
||||
return tool.state.input
|
||||
}
|
||||
}
|
||||
|
||||
const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: ProviderMetadata | undefined): ContentPart =>
|
||||
ToolCallPart.make({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
input: toolInput(tool),
|
||||
providerExecuted: tool.provider?.executed,
|
||||
providerMetadata,
|
||||
})
|
||||
|
||||
const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: ProviderMetadata | undefined) => {
|
||||
if (tool.state.status === "completed") {
|
||||
// TODO: Materialize remote URL and managed file sources before provider-history lowering.
|
||||
// ToolOutput.toResultValue intentionally rejects unmaterialized sources rather than
|
||||
// guessing whether a provider can fetch them or leaking host-local resource paths.
|
||||
const result =
|
||||
tool.provider?.executed === true && tool.state.result !== undefined
|
||||
? tool.state.result
|
||||
: ToolOutput.toResultValue({ structured: tool.state.structured, content: tool.state.content })
|
||||
return ToolResultPart.make({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
result,
|
||||
providerExecuted: tool.provider?.executed,
|
||||
providerMetadata,
|
||||
})
|
||||
}
|
||||
if (tool.state.status === "error") {
|
||||
return ToolResultPart.make({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
result:
|
||||
tool.provider?.executed === true && tool.state.result !== undefined
|
||||
? tool.state.result
|
||||
: { error: tool.state.error, content: tool.state.content, structured: tool.state.structured },
|
||||
resultType: "error",
|
||||
providerExecuted: tool.provider?.executed,
|
||||
providerMetadata,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const assistant = (message: SessionMessage.Assistant, model: Model) => {
|
||||
const sameModel = String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id)
|
||||
const content = message.content.flatMap((item): ContentPart[] => {
|
||||
if (item.type === "text") return [{ type: "text", text: item.text }]
|
||||
if (item.type === "reasoning")
|
||||
return sameModel
|
||||
? [{ type: "reasoning", text: item.text, providerMetadata: item.providerMetadata }]
|
||||
: item.text.length > 0
|
||||
? [{ type: "text", text: item.text }]
|
||||
: []
|
||||
const call = toolCall(item, sameModel ? item.provider?.metadata : undefined)
|
||||
const result = toolResult(item, sameModel ? item.provider?.resultMetadata ?? item.provider?.metadata : undefined)
|
||||
return item.provider?.executed === true && result ? [call, result] : [call]
|
||||
})
|
||||
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]
|
||||
}
|
||||
|
||||
function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] {
|
||||
switch (message.type) {
|
||||
case "agent-switched":
|
||||
case "model-switched":
|
||||
return []
|
||||
case "user":
|
||||
return [
|
||||
Message.make({
|
||||
id: message.id,
|
||||
role: "user",
|
||||
content: [{ type: "text", text: message.text }, ...(message.files ?? []).map(media)],
|
||||
metadata: {
|
||||
...message.metadata,
|
||||
...(message.agents?.length ? { agents: message.agents } : {}),
|
||||
...(message.references?.length ? { references: message.references } : {}),
|
||||
},
|
||||
}),
|
||||
]
|
||||
case "synthetic":
|
||||
return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })]
|
||||
case "shell":
|
||||
return [
|
||||
Message.make({
|
||||
id: message.id,
|
||||
role: "user",
|
||||
content: `Shell command: ${message.command}\n\n${message.output}`,
|
||||
metadata: message.metadata,
|
||||
}),
|
||||
]
|
||||
case "assistant":
|
||||
return assistant(message, model)
|
||||
case "compaction":
|
||||
return [
|
||||
Message.make({
|
||||
id: message.id,
|
||||
role: "user",
|
||||
content: `Summary of earlier conversation:\n${message.summary}`,
|
||||
metadata: message.metadata,
|
||||
}),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/** Translate projected V2 Session history into canonical @opencode-ai/llm context. */
|
||||
export const toLLMMessages = (messages: readonly SessionMessage.Message[], model: Model) =>
|
||||
messages.flatMap((message) => toLLMMessage(message, model))
|
||||
|
|
@ -4,23 +4,21 @@ import { Schema } from "effect"
|
|||
import { Location } from "../location"
|
||||
import { ModelV2 } from "../model"
|
||||
import { ProjectV2 } from "../project"
|
||||
import { RelativePath, optionalOmitUndefined, withStatics } from "../schema"
|
||||
import { externalID, type ExternalID, RelativePath, optionalOmitUndefined, withStatics } from "../schema"
|
||||
import { Identifier } from "../util/identifier"
|
||||
import { V2Schema } from "../v2-schema"
|
||||
import { AgentV2 } from "../agent"
|
||||
|
||||
export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({
|
||||
identifier: "Session.Delivery",
|
||||
})
|
||||
export type Delivery = Schema.Schema.Type<typeof Delivery>
|
||||
|
||||
export const DefaultDelivery = "immediate" satisfies Delivery
|
||||
|
||||
export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
|
||||
Schema.brand("SessionID"),
|
||||
withStatics((schema) => ({
|
||||
descending: (id?: string) => schema.make(id ?? "ses_" + Identifier.descending()),
|
||||
})),
|
||||
withStatics((schema) => {
|
||||
const create = () => schema.make("ses_" + Identifier.descending())
|
||||
return {
|
||||
create,
|
||||
descending: (id?: string) => id === undefined ? create() : schema.make(id),
|
||||
fromExternal: (input: ExternalID) => schema.make(externalID("ses", input)),
|
||||
}
|
||||
}),
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { sqliteTable, text, integer, index, primaryKey, real } from "drizzle-orm
|
|||
import * as DatabasePath from "../database/path"
|
||||
import { ProjectTable } from "../project/sql"
|
||||
import type { SessionMessage } from "./message"
|
||||
import type { Prompt } from "./prompt"
|
||||
import type { SessionInput } from "./input"
|
||||
import type { Snapshot } from "../snapshot"
|
||||
import { PermissionV1 } from "../v1/permission"
|
||||
import { ProjectV2 } from "../project"
|
||||
|
|
@ -120,12 +122,40 @@ export const SessionMessageTable = sqliteTable(
|
|||
.notNull()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
type: text().$type<SessionMessage.Type>().notNull(),
|
||||
seq: integer().notNull(),
|
||||
...Timestamps,
|
||||
data: text({ mode: "json" }).notNull().$type<SessionMessageData>(),
|
||||
},
|
||||
(table) => [
|
||||
index("session_message_session_idx").on(table.session_id),
|
||||
index("session_message_session_type_idx").on(table.session_id, table.type),
|
||||
index("session_message_session_seq_idx").on(table.session_id, table.seq),
|
||||
index("session_message_session_type_seq_idx").on(table.session_id, table.type, table.seq),
|
||||
index("session_message_session_time_created_id_idx").on(table.session_id, table.time_created, table.id),
|
||||
index("session_message_time_created_idx").on(table.time_created),
|
||||
],
|
||||
)
|
||||
|
||||
export const SessionInputTable = sqliteTable(
|
||||
"session_input",
|
||||
{
|
||||
seq: integer().primaryKey({ autoIncrement: true }),
|
||||
id: text().$type<SessionMessage.ID>().notNull().unique(),
|
||||
session_id: text()
|
||||
.$type<SessionSchema.ID>()
|
||||
.notNull()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
prompt: text({ mode: "json" }).notNull().$type<Prompt>(),
|
||||
delivery: text().$type<SessionInput.Delivery>().notNull(),
|
||||
promoted_seq: integer(),
|
||||
time_created: integer()
|
||||
.notNull()
|
||||
.$default(() => Date.now()),
|
||||
},
|
||||
(table) => [
|
||||
index("session_input_session_pending_delivery_seq_idx").on(
|
||||
table.session_id,
|
||||
table.promoted_seq,
|
||||
table.delivery,
|
||||
table.seq,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
|
|
|||
53
packages/core/src/session/store.ts
Normal file
53
packages/core/src/session/store.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
export * as SessionStore from "./store"
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { SessionContext } from "./context"
|
||||
import { MessageDecodeError } from "./error"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionMessageTable, SessionTable } from "./sql"
|
||||
import { fromRow } from "./info"
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info | undefined>
|
||||
readonly context: (sessionID: SessionSchema.ID) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
|
||||
readonly message: (
|
||||
messageID: SessionMessage.ID,
|
||||
) => Effect.Effect<{ readonly sessionID: SessionSchema.ID; readonly message: SessionMessage.Message } | undefined>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionStore") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
|
||||
return Service.of({
|
||||
get: Effect.fn("SessionStore.get")(function* (sessionID) {
|
||||
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
|
||||
return row ? fromRow(row) : undefined
|
||||
}),
|
||||
context: Effect.fn("SessionStore.context")(function* (sessionID) {
|
||||
return yield* SessionContext.load(db, sessionID)
|
||||
}),
|
||||
message: Effect.fn("SessionStore.message")(function* (messageID) {
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, messageID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row
|
||||
? {
|
||||
sessionID: SessionSchema.ID.make(row.session_id),
|
||||
message: yield* decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie),
|
||||
}
|
||||
: undefined
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
91
packages/core/src/session/todo.ts
Normal file
91
packages/core/src/session/todo.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
export * as SessionTodo from "./todo"
|
||||
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { TodoTable } from "./sql"
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
content: Schema.String.annotate({ description: "Brief description of the task" }),
|
||||
status: Schema.String.annotate({
|
||||
description: "Current status of the task: pending, in_progress, completed, cancelled",
|
||||
}),
|
||||
priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }),
|
||||
}).annotate({ identifier: "SessionTodo.Info" })
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
export const Event = {
|
||||
Updated: EventV2.define({
|
||||
type: "todo.updated",
|
||||
schema: {
|
||||
sessionID: SessionSchema.ID,
|
||||
todos: Schema.Array(Info),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly update: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly todos: ReadonlyArray<Info>
|
||||
}) => Effect.Effect<void>
|
||||
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<ReadonlyArray<Info>>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionTodo") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const events = yield* EventV2.Service
|
||||
|
||||
const update = Effect.fn("SessionTodo.update")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly todos: ReadonlyArray<Info>
|
||||
}) {
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tx.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run()
|
||||
if (input.todos.length === 0) return
|
||||
yield* tx
|
||||
.insert(TodoTable)
|
||||
.values(
|
||||
input.todos.map((todo, position) => ({
|
||||
session_id: input.sessionID,
|
||||
content: todo.content,
|
||||
status: todo.status,
|
||||
priority: todo.priority,
|
||||
position,
|
||||
})),
|
||||
)
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
yield* events.publish(Event.Updated, input)
|
||||
})
|
||||
|
||||
const get = Effect.fn("SessionTodo.get")(function* (sessionID: SessionSchema.ID) {
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(TodoTable)
|
||||
.where(eq(TodoTable.session_id, sessionID))
|
||||
.orderBy(asc(TodoTable.position))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return rows.map((row) => ({
|
||||
content: row.content,
|
||||
status: row.status,
|
||||
priority: row.priority,
|
||||
}))
|
||||
})
|
||||
|
||||
return Service.of({ update, get })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer))
|
||||
|
|
@ -122,6 +122,8 @@ export const layer = Layer.effect(
|
|||
return skills
|
||||
})
|
||||
|
||||
// QUESTION(Dax): Should local skill sources invalidate on filesystem watch
|
||||
// events, following the reload policy chosen for other context sources?
|
||||
const cache = new Map<string, Info[]>()
|
||||
const list = Effect.fn("SkillV2.list")(function* () {
|
||||
const skills = new Map<string, Info>()
|
||||
|
|
|
|||
|
|
@ -11,6 +11,46 @@ import * as Log from "../util/log"
|
|||
const skillConcurrency = 4
|
||||
const fileConcurrency = 8
|
||||
|
||||
function isSafeSegment(value: string) {
|
||||
return (
|
||||
value.length > 0 &&
|
||||
value !== "." &&
|
||||
value !== ".." &&
|
||||
!value.includes("/") &&
|
||||
!value.includes("\\") &&
|
||||
!value.includes("\0")
|
||||
)
|
||||
}
|
||||
|
||||
function isSafeRelativePath(value: string) {
|
||||
const segments = value.split("/")
|
||||
return (
|
||||
value.length > 0 &&
|
||||
!value.includes("\\") &&
|
||||
!value.includes("\0") &&
|
||||
!value.includes("?") &&
|
||||
!value.includes("#") &&
|
||||
!URL.canParse(value) &&
|
||||
!path.posix.isAbsolute(value) &&
|
||||
!path.win32.isAbsolute(value) &&
|
||||
segments.every((segment) => {
|
||||
try {
|
||||
const decoded = decodeURIComponent(segment)
|
||||
return (
|
||||
decoded.length > 0 &&
|
||||
decoded !== "." &&
|
||||
decoded !== ".." &&
|
||||
!decoded.includes("/") &&
|
||||
!decoded.includes("\\") &&
|
||||
!decoded.includes("\0")
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
class IndexSkill extends Schema.Class<IndexSkill>("SkillDiscovery.IndexSkill")({
|
||||
name: Schema.String,
|
||||
files: Schema.Array(Schema.String),
|
||||
|
|
@ -54,7 +94,8 @@ export const layer = Layer.effect(
|
|||
return Service.of({
|
||||
pull: Effect.fn("SkillDiscovery.pull")(function* (url) {
|
||||
const base = url.endsWith("/") ? url : `${url}/`
|
||||
const index = new URL("index.json", base).href
|
||||
const source = new URL(base)
|
||||
const index = new URL("index.json", source).href
|
||||
const data = yield* HttpClientRequest.get(index).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
http.execute,
|
||||
|
|
@ -66,18 +107,53 @@ export const layer = Layer.effect(
|
|||
)
|
||||
if (!data) return []
|
||||
|
||||
const sourceRoot = path.resolve(global.cache, "skills", Bun.hash(base).toString(16))
|
||||
return yield* Effect.forEach(
|
||||
data.skills.filter((skill) => {
|
||||
if (skill.files.includes("SKILL.md") || skill.files.includes(`${skill.name}.md`)) return true
|
||||
log.warn("skill entry missing Markdown definition", { url: index, skill: skill.name })
|
||||
return false
|
||||
data.skills.flatMap((skill) => {
|
||||
if (!isSafeSegment(skill.name)) {
|
||||
log.warn("skill entry has unsafe name", { url: index, skill: skill.name })
|
||||
return []
|
||||
}
|
||||
if (!skill.files.includes("SKILL.md") && !skill.files.includes(`${skill.name}.md`)) {
|
||||
log.warn("skill entry missing Markdown definition", { url: index, skill: skill.name })
|
||||
return []
|
||||
}
|
||||
|
||||
const root = path.resolve(sourceRoot, skill.name)
|
||||
if (!FSUtil.contains(sourceRoot, root) || root === sourceRoot) {
|
||||
log.warn("skill entry escapes cache root", { url: index, skill: skill.name })
|
||||
return []
|
||||
}
|
||||
|
||||
const skillUrl = new URL(`${encodeURIComponent(skill.name)}/`, source)
|
||||
const files = skill.files.map((file) => {
|
||||
if (!isSafeRelativePath(file)) return undefined
|
||||
let resource: URL
|
||||
try {
|
||||
resource = new URL(file, skillUrl)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
if (resource.origin !== source.origin) return undefined
|
||||
|
||||
const destination = path.resolve(root, file)
|
||||
if (!FSUtil.contains(root, destination) || destination === root) return undefined
|
||||
return {
|
||||
url: resource.href,
|
||||
destination,
|
||||
}
|
||||
})
|
||||
if (files.some((file) => file === undefined)) {
|
||||
log.warn("skill entry has unsafe file", { url: index, skill: skill.name })
|
||||
return []
|
||||
}
|
||||
return [{ skill, root, files: files as { url: string; destination: string }[] }]
|
||||
}),
|
||||
(skill) =>
|
||||
({ skill, root, files }) =>
|
||||
Effect.gen(function* () {
|
||||
const root = path.join(global.cache, "skills", Bun.hash(base).toString(16), skill.name)
|
||||
yield* Effect.forEach(
|
||||
skill.files,
|
||||
(file) => download(new URL(file, `${base}${skill.name}/`).href, path.join(root, file)),
|
||||
files,
|
||||
(file) => download(file.url, file.destination),
|
||||
{ concurrency: fileConcurrency, discard: true },
|
||||
)
|
||||
return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ||
|
||||
|
|
|
|||
|
|
@ -3,19 +3,50 @@ export * as State from "./state"
|
|||
import { Effect, Scope, Semaphore } from "effect"
|
||||
import type { Draft, Objectish } from "immer"
|
||||
|
||||
/**
|
||||
* A replayable contribution applied to an editor during rebuild.
|
||||
*
|
||||
* Transforms are intentionally synchronous and mutation-shaped: domain editors
|
||||
* hide the draft representation while preserving concise plugin/config code.
|
||||
*/
|
||||
export type Transform<Editor> = (editor: Editor) => void
|
||||
export type MakeEditor<State extends Objectish, Editor> = (draft: Draft<State>) => Editor
|
||||
|
||||
export interface Options<State extends Objectish, Editor> {
|
||||
/** Creates the base value for initial state and every scoped-transform rebuild. */
|
||||
readonly initial: () => State
|
||||
/** Wraps the mutable draft in a domain-specific editor. */
|
||||
readonly editor: MakeEditor<State, Editor>
|
||||
/** Completes every committed edit; reason identifies exceptional update origins. */
|
||||
/**
|
||||
* Completes every committed edit.
|
||||
*
|
||||
* For rebuilds, this runs after all active transforms have been replayed and
|
||||
* before the rebuilt state becomes visible. For direct updates, this runs
|
||||
* after the current state has already been edited. The optional reason is
|
||||
* caller-defined metadata for exceptional update origins.
|
||||
*/
|
||||
readonly finalize?: (editor: Editor, reason?: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface Interface<State extends Objectish, Editor> {
|
||||
readonly get: () => State
|
||||
/**
|
||||
* Registers a scoped transform slot and returns the slot updater.
|
||||
*
|
||||
* Acquiring the slot has no visible effect until the returned updater is
|
||||
* called. Each updater call replaces that slot's transform, then rebuilds the
|
||||
* materialized state from `initial()` by replaying all active transforms in
|
||||
* registration order. Closing the owning Scope removes the slot and rebuilds.
|
||||
*/
|
||||
readonly transform: () => Effect.Effect<(transform: Transform<Editor>) => Effect.Effect<void>, never, Scope.Scope>
|
||||
/**
|
||||
* Mutates the current materialized state directly.
|
||||
*
|
||||
* This is not replayable contribution state: a later rebuild starts again
|
||||
* from `initial()` plus active transforms, so direct edits must be reserved
|
||||
* for current-state adjustments that are intentionally outside the transform
|
||||
* fold.
|
||||
*/
|
||||
readonly update: (update: (editor: Editor) => Effect.Effect<void>, reason?: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
|
|
|
|||
141
packages/core/src/system-context.ts
Normal file
141
packages/core/src/system-context.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
export * as SystemContext from "./system-context"
|
||||
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Hash } from "./util/hash"
|
||||
|
||||
export const Key = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._/-]*$/)).pipe(
|
||||
Schema.brand("SystemContext.Key"),
|
||||
)
|
||||
export type Key = typeof Key.Type
|
||||
|
||||
export const unavailable = Symbol.for("@opencode/SystemContext.Unavailable")
|
||||
export type Unavailable = typeof unavailable
|
||||
|
||||
export interface Value {
|
||||
/** Full component text rendered into a new epoch baseline. */
|
||||
readonly baseline: string
|
||||
/** Absolute current-state text emitted when this component changes. */
|
||||
readonly update: string
|
||||
}
|
||||
|
||||
export interface Component<out E = never, out R = never> {
|
||||
readonly key: Key
|
||||
readonly load: Effect.Effect<Value | Unavailable, E, R>
|
||||
}
|
||||
|
||||
export interface SystemContext<out E = never, out R = never> {
|
||||
readonly components: ReadonlyArray<Component<E, R>>
|
||||
}
|
||||
|
||||
export interface AvailableEntry extends Value {
|
||||
readonly _tag: "Available"
|
||||
readonly key: Key
|
||||
readonly hash: string
|
||||
}
|
||||
|
||||
export interface UnavailableEntry {
|
||||
readonly _tag: "Unavailable"
|
||||
readonly key: Key
|
||||
}
|
||||
|
||||
export type Entry = AvailableEntry | UnavailableEntry
|
||||
|
||||
export interface Snapshot {
|
||||
readonly entries: ReadonlyArray<Entry>
|
||||
}
|
||||
|
||||
export interface Part {
|
||||
readonly key: Key
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
export type Checkpoint = Readonly<Record<string, string>>
|
||||
|
||||
export interface Initialized {
|
||||
readonly baseline: ReadonlyArray<Part>
|
||||
readonly checkpoint: Checkpoint
|
||||
}
|
||||
|
||||
export interface Refreshed {
|
||||
readonly changes: ReadonlyArray<Part>
|
||||
readonly checkpoint: Checkpoint
|
||||
}
|
||||
|
||||
export class DuplicateKeyError extends Schema.TaggedErrorClass<DuplicateKeyError>()("SystemContext.DuplicateKeyError", {
|
||||
key: Key,
|
||||
}) {
|
||||
override get message() {
|
||||
return `Duplicate system context key: ${this.key}`
|
||||
}
|
||||
}
|
||||
|
||||
export const value = <E, R>(component: Component<E, R>): Component<E, R> => component
|
||||
|
||||
export function struct<E, R>(components: Readonly<Record<string, Component<E, R>>>): SystemContext<E, R> {
|
||||
const values = Object.values(components)
|
||||
assertUniqueKeys(values)
|
||||
return { components: values }
|
||||
}
|
||||
|
||||
export const load = <E, R>(context: SystemContext<E, R>) =>
|
||||
Effect.sync(() => assertUniqueKeys(context.components)).pipe(
|
||||
Effect.andThen(
|
||||
Effect.forEach(context.components, (component) =>
|
||||
component.load.pipe(
|
||||
Effect.map(
|
||||
(result): Entry =>
|
||||
result === unavailable
|
||||
? { _tag: "Unavailable", key: component.key }
|
||||
: { _tag: "Available", key: component.key, ...result, hash: Hash.sha256(result.update) },
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.map((entries): Snapshot => ({ entries })),
|
||||
)
|
||||
|
||||
export function initialize(snapshot: Snapshot): Initialized {
|
||||
return {
|
||||
baseline: snapshot.entries.flatMap((entry) =>
|
||||
entry._tag === "Available" ? [{ key: entry.key, text: entry.baseline }] : [],
|
||||
),
|
||||
checkpoint: nextCheckpoint(snapshot, {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function refresh(snapshot: Snapshot, previous: Checkpoint): Refreshed {
|
||||
return {
|
||||
changes: snapshot.entries.flatMap((entry) =>
|
||||
entry._tag === "Available" && getCheckpoint(previous, entry.key) !== entry.hash
|
||||
? [{ key: entry.key, text: entry.update }]
|
||||
: [],
|
||||
),
|
||||
checkpoint: nextCheckpoint(snapshot, previous),
|
||||
}
|
||||
}
|
||||
|
||||
export function render(parts: ReadonlyArray<Part>) {
|
||||
return parts.map((part) => part.text).join("\n\n")
|
||||
}
|
||||
|
||||
function nextCheckpoint(snapshot: Snapshot, previous: Checkpoint) {
|
||||
return Object.fromEntries(
|
||||
snapshot.entries.flatMap((entry) => {
|
||||
if (entry._tag === "Available") return [[entry.key, entry.hash]]
|
||||
const hash = getCheckpoint(previous, entry.key)
|
||||
return hash === undefined ? [] : [[entry.key, hash]]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function getCheckpoint(checkpoint: Checkpoint, key: Key) {
|
||||
return Object.hasOwn(checkpoint, key) ? checkpoint[key] : undefined
|
||||
}
|
||||
|
||||
function assertUniqueKeys(components: ReadonlyArray<Component<unknown, unknown>>) {
|
||||
const keys = new Set<Key>()
|
||||
for (const component of components) {
|
||||
if (keys.has(component.key)) throw new DuplicateKeyError({ key: component.key })
|
||||
keys.add(component.key)
|
||||
}
|
||||
}
|
||||
335
packages/core/src/tool-output-store.ts
Normal file
335
packages/core/src/tool-output-store.ts
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
export * as ToolOutputStore from "./tool-output-store"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
|
||||
import { Config } from "./config"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Global } from "./global"
|
||||
import { NonNegativeInt, PositiveInt } from "./schema"
|
||||
import { SessionSchema } from "./session/schema"
|
||||
import { Identifier } from "./util/identifier"
|
||||
|
||||
export const MAX_LINES = 2_000
|
||||
export const MAX_BYTES = 50 * 1024
|
||||
export const MAX_READ_BYTES = 50 * 1024
|
||||
export const RETENTION = Duration.days(7)
|
||||
|
||||
const URI_PREFIX = "tool-output://"
|
||||
const MANAGED_DIRECTORY = path.join("tool-output", "managed")
|
||||
const ID_PATTERN = /^[0-9a-f]{12}[0-9A-Za-z]{14}$/
|
||||
|
||||
export class Resource extends Schema.Class<Resource>("ToolOutputStore.Resource")({
|
||||
uri: Schema.String,
|
||||
mime: Schema.String,
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
size: NonNegativeInt,
|
||||
}) {}
|
||||
|
||||
export class Page extends Schema.Class<Page>("ToolOutputStore.Page")({
|
||||
resource: Resource,
|
||||
content: Schema.String,
|
||||
offset: NonNegativeInt,
|
||||
truncated: Schema.Boolean,
|
||||
next: NonNegativeInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class AccessDeniedError extends Schema.TaggedErrorClass<AccessDeniedError>()("ToolOutputStore.AccessDeniedError", {
|
||||
uri: Schema.String,
|
||||
sessionID: SessionSchema.ID,
|
||||
}) {}
|
||||
|
||||
export class InvalidResourceError extends Schema.TaggedErrorClass<InvalidResourceError>()("ToolOutputStore.InvalidResourceError", {
|
||||
uri: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class ResourceNotFoundError extends Schema.TaggedErrorClass<ResourceNotFoundError>()(
|
||||
"ToolOutputStore.ResourceNotFoundError",
|
||||
{ uri: Schema.String },
|
||||
) {}
|
||||
|
||||
export interface WriteInput {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly toolCallID: string
|
||||
readonly content: string
|
||||
readonly mime?: string
|
||||
readonly name?: string
|
||||
}
|
||||
|
||||
export interface TruncateInput extends WriteInput {
|
||||
readonly maxLines?: number
|
||||
readonly maxBytes?: number
|
||||
}
|
||||
|
||||
export interface ReadInput {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly uri: string
|
||||
/** Zero-based byte offset. Returned `next` values preserve UTF-8 boundaries. */
|
||||
readonly offset?: number
|
||||
readonly limit?: number
|
||||
}
|
||||
|
||||
export type TruncateResult =
|
||||
| { readonly content: string; readonly truncated: false }
|
||||
| { readonly content: string; readonly truncated: true; readonly resource: Resource }
|
||||
|
||||
interface Record {
|
||||
readonly version: 1
|
||||
readonly id: string
|
||||
readonly uri: string
|
||||
readonly sessionID: string
|
||||
readonly toolCallID: string
|
||||
readonly mime: string
|
||||
readonly name?: string
|
||||
readonly size: number
|
||||
readonly created: number
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }>
|
||||
readonly write: (input: WriteInput) => Effect.Effect<Resource>
|
||||
readonly truncate: (input: TruncateInput) => Effect.Effect<TruncateResult>
|
||||
readonly read: (input: ReadInput) => Effect.Effect<Page, AccessDeniedError | InvalidResourceError | ResourceNotFoundError>
|
||||
readonly cleanup: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolOutputStore") {}
|
||||
|
||||
const uri = (id: string) => URI_PREFIX + id
|
||||
|
||||
const idFromUri = (input: string) => {
|
||||
if (!input.startsWith(URI_PREFIX)) return
|
||||
const id = input.slice(URI_PREFIX.length)
|
||||
if (!ID_PATTERN.test(id)) return
|
||||
return id
|
||||
}
|
||||
|
||||
const validRecord = (input: unknown, id: string): input is Record => {
|
||||
if (!input || typeof input !== "object") return false
|
||||
const record = input as Partial<Record>
|
||||
return (
|
||||
record.version === 1 &&
|
||||
record.id === id &&
|
||||
record.uri === uri(id) &&
|
||||
typeof record.sessionID === "string" &&
|
||||
typeof record.toolCallID === "string" &&
|
||||
typeof record.mime === "string" &&
|
||||
(record.name === undefined || typeof record.name === "string") &&
|
||||
typeof record.size === "number" &&
|
||||
Number.isSafeInteger(record.size) &&
|
||||
record.size >= 0 &&
|
||||
typeof record.created === "number" &&
|
||||
Number.isFinite(record.created)
|
||||
)
|
||||
}
|
||||
|
||||
const takePrefix = (input: string, maximumBytes: number) => {
|
||||
let bytes = 0
|
||||
let content = ""
|
||||
for (const char of input) {
|
||||
const size = Buffer.byteLength(char, "utf-8")
|
||||
if (bytes + size > maximumBytes) break
|
||||
content += char
|
||||
bytes += size
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
const takeSuffix = (input: string, maximumBytes: number) => {
|
||||
let bytes = 0
|
||||
const content: string[] = []
|
||||
for (const char of Array.from(input).toReversed()) {
|
||||
const size = Buffer.byteLength(char, "utf-8")
|
||||
if (bytes + size > maximumBytes) break
|
||||
content.unshift(char)
|
||||
bytes += size
|
||||
}
|
||||
return content.join("")
|
||||
}
|
||||
|
||||
const preview = (text: string, maxLines: number, maxBytes: number) => {
|
||||
const lines = text.split("\n")
|
||||
const headLines = Math.ceil(maxLines / 2)
|
||||
const tailLines = Math.floor(maxLines / 2)
|
||||
const sampled =
|
||||
lines.length <= maxLines
|
||||
? text
|
||||
: [lines.slice(0, headLines).join("\n"), ...(tailLines > 0 ? [lines.slice(lines.length - tailLines).join("\n")] : [])].join("\n")
|
||||
if (Buffer.byteLength(sampled, "utf-8") <= maxBytes) {
|
||||
return lines.length <= maxLines
|
||||
? { head: sampled, tail: "" }
|
||||
: { head: lines.slice(0, headLines).join("\n"), tail: tailLines > 0 ? lines.slice(lines.length - tailLines).join("\n") : "" }
|
||||
}
|
||||
const headBytes = Math.ceil(maxBytes / 2)
|
||||
const tailBytes = Math.floor(maxBytes / 2)
|
||||
return { head: takePrefix(sampled, headBytes), tail: takeSuffix(sampled, tailBytes) }
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const config = yield* Effect.serviceOption(Config.Service)
|
||||
const directory = path.join(global.data, MANAGED_DIRECTORY)
|
||||
const metadataPath = (id: string) => path.join(directory, `${id}.json`)
|
||||
const contentPath = (id: string) => path.join(directory, `${id}.txt`)
|
||||
|
||||
const load = Effect.fn("ToolOutputStore.load")(function* (resourceUri: string) {
|
||||
const id = idFromUri(resourceUri)
|
||||
if (!id) return yield* Effect.fail(new InvalidResourceError({ uri: resourceUri }))
|
||||
const text = yield* fs.readFileStringSafe(metadataPath(id)).pipe(Effect.orDie)
|
||||
if (!text) return yield* Effect.fail(new ResourceNotFoundError({ uri: resourceUri }))
|
||||
const record = yield* Effect.sync(() => JSON.parse(text)).pipe(Effect.catch(() => Effect.void))
|
||||
if (!validRecord(record, id)) return yield* Effect.fail(new ResourceNotFoundError({ uri: resourceUri }))
|
||||
const info = yield* fs.stat(contentPath(id)).pipe(Effect.catch(() => Effect.void))
|
||||
if (!info || info.type !== "File" || Number(info.size) !== record.size)
|
||||
return yield* Effect.fail(new ResourceNotFoundError({ uri: resourceUri }))
|
||||
return record
|
||||
})
|
||||
|
||||
const limits = Effect.fn("ToolOutputStore.limits")(function* () {
|
||||
if (Option.isNone(config)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES }
|
||||
const entries = yield* config.value.entries().pipe(Effect.catch(() => Effect.succeed([] as Config.Entry[])))
|
||||
const configured = Object.assign(
|
||||
{},
|
||||
...entries.flatMap((entry) => entry.type === "document" ? [entry.info.tool_output ?? {}] : []),
|
||||
)
|
||||
return { maxLines: configured.max_lines ?? MAX_LINES, maxBytes: configured.max_bytes ?? MAX_BYTES }
|
||||
})
|
||||
|
||||
const write = Effect.fn("ToolOutputStore.write")(function* (input: WriteInput) {
|
||||
const id = Identifier.ascending()
|
||||
const resourceUri = uri(id)
|
||||
const size = Buffer.byteLength(input.content, "utf-8")
|
||||
const record: Record = {
|
||||
version: 1,
|
||||
id,
|
||||
uri: resourceUri,
|
||||
sessionID: input.sessionID,
|
||||
toolCallID: input.toolCallID,
|
||||
mime: input.mime ?? "text/plain",
|
||||
...(input.name === undefined ? {} : { name: input.name }),
|
||||
size,
|
||||
created: Date.now(),
|
||||
}
|
||||
yield* fs.ensureDir(directory).pipe(Effect.orDie)
|
||||
yield* fs.writeFileString(contentPath(id), input.content, { flag: "wx" }).pipe(Effect.orDie)
|
||||
yield* fs.writeFileString(metadataPath(id), JSON.stringify(record), { flag: "wx" }).pipe(
|
||||
Effect.onError(() => fs.remove(contentPath(id)).pipe(Effect.catch(() => Effect.void))),
|
||||
Effect.orDie,
|
||||
)
|
||||
return new Resource({ uri: resourceUri, mime: record.mime, ...(record.name === undefined ? {} : { name: record.name }), size })
|
||||
})
|
||||
|
||||
const truncate = Effect.fn("ToolOutputStore.truncate")(function* (input: TruncateInput) {
|
||||
const configured = yield* limits()
|
||||
const maxLines = input.maxLines ?? configured.maxLines
|
||||
const maxBytes = input.maxBytes ?? configured.maxBytes
|
||||
if (input.content.split("\n").length <= maxLines && Buffer.byteLength(input.content, "utf-8") <= maxBytes) {
|
||||
return { content: input.content, truncated: false } as const
|
||||
}
|
||||
const resource = yield* write(input)
|
||||
const bounded = preview(input.content, maxLines, maxBytes)
|
||||
const marker = `... output truncated; full content available as ${resource.uri} ...`
|
||||
return {
|
||||
content: bounded.tail ? `${bounded.head}\n\n${marker}\n\n${bounded.tail}` : `${bounded.head}\n\n${marker}`,
|
||||
truncated: true,
|
||||
resource,
|
||||
} as const
|
||||
})
|
||||
|
||||
const read = Effect.fn("ToolOutputStore.read")(function* (input: ReadInput) {
|
||||
const record = yield* load(input.uri)
|
||||
if (record.sessionID !== input.sessionID) {
|
||||
return yield* Effect.fail(new AccessDeniedError({ uri: input.uri, sessionID: input.sessionID }))
|
||||
}
|
||||
const offset = Math.max(0, Math.min(input.offset ?? 0, record.size))
|
||||
const limit = Math.max(1, Math.min(input.limit ?? MAX_READ_BYTES, MAX_READ_BYTES))
|
||||
const bytes = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const file = yield* fs.open(contentPath(record.id), { flag: "r" }).pipe(Effect.orDie)
|
||||
yield* file.seek(offset, "start")
|
||||
const chunk = yield* file.readAlloc(Math.min(limit + 3, record.size - offset)).pipe(Effect.orDie)
|
||||
return Option.getOrElse(chunk, () => new Uint8Array())
|
||||
}),
|
||||
)
|
||||
let start = 0
|
||||
while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++
|
||||
let end = Math.min(start + limit, bytes.length)
|
||||
while (end > start && end < bytes.length && (bytes[end] & 0xc0) === 0x80) end--
|
||||
if (end === start && end < bytes.length) {
|
||||
end = Math.min(start + limit, bytes.length)
|
||||
while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) end++
|
||||
}
|
||||
const absoluteStart = offset + start
|
||||
const absoluteEnd = offset + end
|
||||
const truncated = absoluteEnd < record.size
|
||||
return new Page({
|
||||
resource: new Resource({
|
||||
uri: record.uri,
|
||||
mime: record.mime,
|
||||
...(record.name === undefined ? {} : { name: record.name }),
|
||||
size: record.size,
|
||||
}),
|
||||
content: Buffer.from(bytes.subarray(start, end)).toString("utf-8"),
|
||||
offset: absoluteStart,
|
||||
truncated,
|
||||
...(truncated ? { next: absoluteEnd } : {}),
|
||||
})
|
||||
})
|
||||
|
||||
const cleanup = Effect.fn("ToolOutputStore.cleanup")(function* () {
|
||||
const entries = yield* fs.readDirectory(directory).pipe(Effect.catch(() => Effect.succeed([])))
|
||||
const cutoff = Date.now() - Duration.toMillis(RETENTION)
|
||||
const ids = new Set(entries.flatMap((entry) => {
|
||||
const match = entry.match(/^([0-9a-f]{12}[0-9A-Za-z]{14})\.(?:json|txt)$/)
|
||||
return match ? [match[1]] : []
|
||||
}))
|
||||
const removeIfPresent = (target: string) =>
|
||||
fs.existsSafe(target).pipe(Effect.flatMap((exists) => exists ? fs.remove(target) : Effect.void))
|
||||
const removePair = (id: string) =>
|
||||
Effect.gen(function* () {
|
||||
yield* removeIfPresent(contentPath(id))
|
||||
yield* removeIfPresent(metadataPath(id))
|
||||
}).pipe(Effect.catch(() => Effect.void))
|
||||
for (const id of ids) {
|
||||
const text = yield* fs.readFileStringSafe(metadataPath(id)).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const contentExists = yield* fs.existsSafe(contentPath(id))
|
||||
if (!text) {
|
||||
if (!contentExists) continue
|
||||
const info = yield* fs.stat(contentPath(id)).pipe(Effect.catch(() => Effect.void))
|
||||
const modified = info ? info.mtime.pipe(Option.map((date) => date.getTime()), Option.getOrElse(() => 0)) : 0
|
||||
if (modified < cutoff) yield* removePair(id)
|
||||
continue
|
||||
}
|
||||
const record = yield* Effect.try({ try: () => JSON.parse(text), catch: () => new globalThis.Error("Invalid metadata") }).pipe(
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
const info = contentExists ? yield* fs.stat(contentPath(id)).pipe(Effect.catch(() => Effect.void)) : undefined
|
||||
if (
|
||||
!contentExists ||
|
||||
!validRecord(record, id) ||
|
||||
!info ||
|
||||
info.type !== "File" ||
|
||||
Number(info.size) !== record.size ||
|
||||
record.created < cutoff
|
||||
)
|
||||
yield* removePair(id)
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ limits, write, truncate, read, cleanup })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.defaultLayer))
|
||||
|
||||
/** Runs retention scanning once globally rather than once per active Location. */
|
||||
export const cleanupLayer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const store = yield* Service
|
||||
yield* store.cleanup().pipe(Effect.repeat(Schedule.spaced(Duration.hours(1))), Effect.forkScoped)
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultCleanupLayer = Layer.merge(defaultLayer, cleanupLayer.pipe(Layer.provide(defaultLayer)))
|
||||
|
|
@ -1,18 +1,11 @@
|
|||
export * as ToolOutput from "./tool-output"
|
||||
export {
|
||||
ToolContent as Content,
|
||||
ToolFileContent as FileContent,
|
||||
ToolTextContent as TextContent,
|
||||
toolFile as file,
|
||||
toolText as text,
|
||||
} from "@opencode-ai/llm"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export class TextContent extends Schema.Class<TextContent>("Tool.TextContent")({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class FileContent extends Schema.Class<FileContent>("Tool.FileContent")({
|
||||
type: Schema.Literal("file"),
|
||||
uri: Schema.String,
|
||||
mime: Schema.String,
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const Content = Schema.Union([TextContent, FileContent]).pipe(Schema.toTaggedUnion("type"))
|
||||
|
||||
export const Structured = Schema.Record(Schema.String, Schema.Any)
|
||||
|
|
|
|||
148
packages/core/src/tool-registry.ts
Normal file
148
packages/core/src/tool-registry.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
export * as ToolRegistry from "./tool-registry"
|
||||
|
||||
import { Tool, ToolFailure, ToolOutput, ToolResultValue as ToolResult, type Tool as TypedTool, type ToolCall, type ToolResultValue, type ToolSchema, type ToolSettlement } from "@opencode-ai/llm"
|
||||
import { Context, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { castDraft, enableMapSet } from "immer"
|
||||
import { PermissionV2 } from "./permission"
|
||||
import { State } from "./state"
|
||||
import { SessionSchema } from "./session/schema"
|
||||
import type { SessionV2 } from "./session"
|
||||
|
||||
export type ExecuteInput = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly call: ToolCall
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow cross-cutting context for one registry invocation. Leaf tools retain
|
||||
* ownership of sequence-sensitive policy decisions; the registry only binds
|
||||
* identity and shared helper behavior consistently.
|
||||
*
|
||||
* TODO: Add `source` when the runner can pass the durable owning assistant
|
||||
* message ID alongside the call ID. Do not infer it from the tool call alone.
|
||||
* TODO: Add cancellation and progress only when the runner exposes a real
|
||||
* signal and durable/live progress sink.
|
||||
*/
|
||||
export type Invocation = ExecuteInput & {
|
||||
readonly source?: PermissionV2.Source
|
||||
readonly assertPermission: (
|
||||
input: Omit<PermissionV2.AssertInput, "sessionID" | "source">,
|
||||
) => Effect.Effect<void, PermissionV2.Error | SessionV2.NotFoundError>
|
||||
}
|
||||
|
||||
/** Kept as the leaf entry input name for backwards-compatible execute usage. */
|
||||
export type AuthorizeInput<Parameters = unknown> = Invocation & {
|
||||
readonly parameters: Parameters
|
||||
}
|
||||
|
||||
export type Entry<Parameters extends ToolSchema<any> = ToolSchema<any>, Success extends ToolSchema<any> = ToolSchema<any>> = {
|
||||
readonly tool: TypedTool<Parameters, Success>
|
||||
readonly authorize?: (input: AuthorizeInput<Schema.Schema.Type<Parameters>>) => Effect.Effect<void, ToolFailure>
|
||||
readonly execute?: (input: AuthorizeInput<Schema.Schema.Type<Parameters>>) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
|
||||
}
|
||||
|
||||
type Data = {
|
||||
readonly entries: Map<string, Entry>
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
readonly list: () => ReadonlyArray<readonly [string, Entry]>
|
||||
readonly get: (name: string) => Entry | undefined
|
||||
readonly set: <Parameters extends ToolSchema<any>, Success extends ToolSchema<any>>(name: string, entry: Entry<Parameters, Success>) => void
|
||||
readonly remove: (name: string) => void
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
readonly contribute: (update: State.Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly definitions: () => Effect.Effect<ReadonlyArray<ReturnType<typeof Tool.toDefinitions>[number]>>
|
||||
readonly execute: (input: ExecuteInput) => Effect.Effect<ToolResultValue>
|
||||
readonly settle: (input: ExecuteInput) => Effect.Effect<ToolSettlement>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
|
||||
|
||||
enableMapSet()
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const permission = yield* PermissionV2.Service
|
||||
const state = State.create<Data, Editor>({
|
||||
initial: () => ({ entries: new Map() }),
|
||||
editor: (draft) => ({
|
||||
list: () => Array.from(draft.entries.entries()) as Array<[string, Entry]>,
|
||||
get: (name) => draft.entries.get(name) as Entry | undefined,
|
||||
set: (name, entry) => {
|
||||
draft.entries.set(name, castDraft(entry) as typeof draft.entries extends Map<string, infer Value> ? Value : never)
|
||||
},
|
||||
remove: (name) => {
|
||||
draft.entries.delete(name)
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const definitions = Effect.fn("ToolRegistry.definitions")(function* () {
|
||||
return Tool.toDefinitions(Object.fromEntries(Array.from(state.get().entries, ([name, entry]) => [name, entry.tool])))
|
||||
})
|
||||
|
||||
const invocation = (input: ExecuteInput): Invocation => ({
|
||||
...input,
|
||||
// Source needs the durable owning assistant message ID, which the registry does not receive yet.
|
||||
assertPermission: (request) => permission.assert({ ...request, sessionID: input.sessionID }),
|
||||
})
|
||||
|
||||
const settle = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput) {
|
||||
const entry = state.get().entries.get(input.call.name)
|
||||
if (!entry) return { result: { type: "error" as const, value: `Unknown tool: ${input.call.name}` } }
|
||||
if (!entry.execute && !entry.tool.execute)
|
||||
return { result: { type: "error" as const, value: `Tool has no execute handler: ${input.call.name}` } }
|
||||
|
||||
return yield* entry.tool._decode(input.call.input).pipe(
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
|
||||
Effect.flatMap((parameters) => {
|
||||
const context = { ...invocation(input), parameters }
|
||||
const execute = entry.execute?.(context) ??
|
||||
entry.tool.execute!(parameters, { id: input.call.id, name: input.call.name })
|
||||
return (entry.authorize === undefined ? execute : entry.authorize(context).pipe(Effect.andThen(execute))).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
entry.tool._encode(value).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `Tool returned an invalid value for its success schema: ${error.message}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.map((value): ToolSettlement => {
|
||||
if (entry.tool._legacyResult && ToolResult.is(value))
|
||||
return { result: value, output: ToolOutput.fromResultValue(value) }
|
||||
const output = entry.tool._project(parameters, input.call.id, value)
|
||||
const result = ToolOutput.toResultValue(output)
|
||||
return result.type === "error" ? { result } : { result, output }
|
||||
}),
|
||||
)
|
||||
}),
|
||||
Effect.catchTag("LLM.ToolFailure", (failure) =>
|
||||
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const execute = Effect.fn("ToolRegistry.execute")(function* (input: ExecuteInput) {
|
||||
return (yield* settle(input)).result
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
contribute: Effect.fn("ToolRegistry.contribute")(function* (update) {
|
||||
const transform = yield* state.transform()
|
||||
yield* transform(update)
|
||||
}),
|
||||
definitions,
|
||||
execute,
|
||||
settle,
|
||||
})
|
||||
}),
|
||||
)
|
||||
131
packages/core/src/tool/apply-patch.ts
Normal file
131
packages/core/src/tool/apply-patch.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
export * as ApplyPatchTool from "./apply-patch"
|
||||
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { Patch } from "../patch"
|
||||
import { ToolRegistry } from "../tool-registry"
|
||||
|
||||
export const name = "apply_patch"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
patchText: Schema.String.annotate({ description: "The full patch text describing add, update, and delete operations" }),
|
||||
})
|
||||
|
||||
export const Applied = Schema.Struct({
|
||||
type: Schema.Literals(["add", "update", "delete"]),
|
||||
resource: Schema.String,
|
||||
target: Schema.String,
|
||||
})
|
||||
|
||||
export const Success = Schema.Struct({ applied: Schema.Array(Applied) })
|
||||
export type Success = typeof Success.Type
|
||||
|
||||
export const toModelOutput = (output: Success) =>
|
||||
["Applied patch sequentially:", ...output.applied.map((item) => `${item.type === "add" ? "A" : item.type === "delete" ? "D" : "M"} ${item.resource}`)].join("\n")
|
||||
|
||||
const definition = Tool.make({
|
||||
description:
|
||||
"Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.",
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
})
|
||||
|
||||
type Planned = { readonly hunk: Patch.Hunk; readonly plan: LocationMutation.Plan }
|
||||
type Prepared =
|
||||
| { readonly type: "add"; readonly hunk: Extract<Patch.Hunk, { readonly type: "add" }>; readonly plan: LocationMutation.Plan }
|
||||
| { readonly type: "delete"; readonly hunk: Extract<Patch.Hunk, { readonly type: "delete" }>; readonly plan: LocationMutation.Plan }
|
||||
| { readonly type: "update"; readonly hunk: Extract<Patch.Hunk, { readonly type: "update" }>; readonly plan: LocationMutation.Plan; readonly source: Uint8Array; readonly content: string }
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, assertPermission }) => {
|
||||
const applied: Array<typeof Applied.Type> = []
|
||||
const fail = (path: string, cause: unknown) => {
|
||||
const prefix = applied.length === 0
|
||||
? `Unable to apply patch at ${path}`
|
||||
: `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}`
|
||||
return new ToolFailure({ message: prefix, error: cause })
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
if (!parameters.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" })
|
||||
const hunks = yield* Effect.try({
|
||||
try: () => Patch.parse(parameters.patchText),
|
||||
catch: (cause) => new ToolFailure({ message: `apply_patch verification failed: ${String(cause)}` }),
|
||||
})
|
||||
if (hunks.length === 0) return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
||||
const move = hunks.find((hunk) => hunk.type === "update" && hunk.movePath !== undefined)
|
||||
if (move) return yield* new ToolFailure({ message: "apply_patch moves are not supported yet" })
|
||||
|
||||
const planned: Planned[] = []
|
||||
for (const hunk of hunks) planned.push({ hunk, plan: yield* mutation.resolve({ path: hunk.path, kind: "file" }) })
|
||||
const externalDirectories = new Map<string, LocationMutation.ExternalDirectoryAuthorization>()
|
||||
for (const { plan } of planned) {
|
||||
const external = plan.target.externalDirectory
|
||||
if (external) externalDirectories.set(external.resource, external)
|
||||
}
|
||||
for (const external of externalDirectories.values()) {
|
||||
yield* assertPermission(LocationMutation.externalDirectoryPermission(external))
|
||||
}
|
||||
yield* assertPermission({ action: "edit", resources: [...new Set(planned.map(({ plan }) => plan.target.resource))], save: ["*"] })
|
||||
|
||||
const prepared: Prepared[] = []
|
||||
for (const { hunk, plan } of planned) {
|
||||
if (hunk.type === "add") {
|
||||
const target = yield* mutation.revalidate(plan)
|
||||
if (target.exists) return yield* fail(hunk.path, new Error("Target file already exists"))
|
||||
prepared.push({ type: hunk.type, hunk, plan })
|
||||
continue
|
||||
}
|
||||
const target = yield* mutation.revalidate(plan)
|
||||
if (!target.exists || target.type !== "File") return yield* fail(hunk.path, new Error("Target file does not exist"))
|
||||
if (hunk.type === "delete") {
|
||||
prepared.push({ type: hunk.type, hunk, plan })
|
||||
continue
|
||||
}
|
||||
const source = yield* fs.readFile(target.canonical)
|
||||
const update = Patch.derive(hunk.path, hunk.chunks, new TextDecoder("utf-8", { ignoreBOM: true }).decode(source))
|
||||
prepared.push({ type: hunk.type, hunk, plan, source, content: Patch.joinBom(update.content, update.bom) })
|
||||
}
|
||||
|
||||
yield* Effect.uninterruptible(
|
||||
Effect.forEach(prepared, (change) =>
|
||||
Effect.gen(function* () {
|
||||
if (change.type === "add") {
|
||||
const result = yield* files.create({ plan: change.plan, content: change.hunk.contents.endsWith("\n") || change.hunk.contents === "" ? change.hunk.contents : `${change.hunk.contents}\n` })
|
||||
applied.push({ type: change.type, resource: result.resource, target: result.target })
|
||||
return
|
||||
}
|
||||
if (change.type === "delete") {
|
||||
const result = yield* files.remove({ plan: change.plan })
|
||||
applied.push({ type: change.type, resource: result.resource, target: result.target })
|
||||
return
|
||||
}
|
||||
const result = yield* files.writeIfUnchanged({ plan: change.plan, expected: change.source, content: change.content })
|
||||
applied.push({ type: change.type, resource: result.resource, target: result.target })
|
||||
}).pipe(Effect.catchCause((cause) => Effect.fail(fail(change.hunk.path, Cause.squash(cause))))),
|
||||
{ discard: true }),
|
||||
)
|
||||
return { applied }
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
const error = Cause.squash(cause)
|
||||
return Effect.fail(error instanceof ToolFailure ? error : fail("patch", error))
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
206
packages/core/src/tool/bash.ts
Normal file
206
packages/core/src/tool/bash.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
export * as BashTool from "./bash"
|
||||
|
||||
import path from "path"
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Duration, Effect, Layer, Schema } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Config } from "../config"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { AppProcess } from "../process"
|
||||
import { PositiveInt } from "../schema"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { ToolRegistry } from "../tool-registry"
|
||||
|
||||
export const name = "bash"
|
||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
|
||||
export const MAX_CAPTURE_BYTES = 1024 * 1024
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
command: Schema.String.annotate({ description: "Shell command string to execute" }),
|
||||
workdir: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Working directory. Defaults to the active Location; relative paths resolve from that Location.",
|
||||
}),
|
||||
timeout: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_TIMEOUT_MS))
|
||||
.pipe(Schema.optional)
|
||||
.annotate({
|
||||
description: `Timeout in milliseconds. Defaults to ${DEFAULT_TIMEOUT_MS} and may not exceed ${MAX_TIMEOUT_MS}.`,
|
||||
}),
|
||||
description: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Concise description of the command's purpose",
|
||||
}),
|
||||
})
|
||||
|
||||
const Success = Schema.Struct({
|
||||
command: Schema.String,
|
||||
cwd: Schema.String,
|
||||
exitCode: Schema.Number.pipe(Schema.optional),
|
||||
/** Bounded compact equivalent of stdout/stderr: stderr is labeled when present. */
|
||||
output: Schema.String,
|
||||
truncated: Schema.Boolean,
|
||||
stdoutTruncated: Schema.Boolean.pipe(Schema.optional),
|
||||
stderrTruncated: Schema.Boolean.pipe(Schema.optional),
|
||||
resource: ToolOutputStore.Resource.pipe(Schema.optional),
|
||||
timedOut: Schema.Boolean.pipe(Schema.optional),
|
||||
warnings: Schema.Array(Schema.String).pipe(Schema.optional),
|
||||
})
|
||||
|
||||
type Success = typeof Success.Type
|
||||
|
||||
const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh")
|
||||
|
||||
const compactOutput = (stdout: string, stderr: string) => {
|
||||
const output = stdout && stderr ? `${stdout}\n\nstderr:\n${stderr}` : stderr ? `stderr:\n${stderr}` : stdout
|
||||
return output || "(no output)"
|
||||
}
|
||||
|
||||
const captureNotice = (stdoutTruncated: boolean, stderrTruncated: boolean) => {
|
||||
if (stdoutTruncated && stderrTruncated) return "[stdout and stderr capture truncated at the in-memory safety limit]"
|
||||
if (stdoutTruncated) return "[stdout capture truncated at the in-memory safety limit]"
|
||||
if (stderrTruncated) return "[stderr capture truncated at the in-memory safety limit]"
|
||||
}
|
||||
|
||||
const modelOutput = (output: Success) => {
|
||||
const warnings = output.warnings?.length
|
||||
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
|
||||
: ""
|
||||
if (output.timedOut) return `${output.output}${warnings}\n\nCommand timed out before completion.`
|
||||
return `${output.output}${warnings}\n\nCommand exited with code ${output.exitCode}.`
|
||||
}
|
||||
|
||||
const isTimeout = (error: AppProcess.AppProcessError) =>
|
||||
error.cause instanceof Error && error.cause.message === "Timed out"
|
||||
|
||||
const definition = Tool.make({
|
||||
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`,
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: modelOutput(output) })],
|
||||
})
|
||||
|
||||
/**
|
||||
* Minimal V2 core shell boundary. Keep parity debt visible without pulling the
|
||||
* legacy shell runtime into core.
|
||||
*/
|
||||
// TODO: Port tree-sitter bash / PowerShell parser-based approval reduction.
|
||||
// TODO: Port BashArity reusable command-prefix approvals.
|
||||
// TODO: Replace token-based command-argument external-directory advisories with parser-based detection.
|
||||
// TODO: Restore PowerShell and cmd-specific invocation/path handling on Windows.
|
||||
// TODO: Add plugin shell.env environment augmentation once V2 plugin hooks exist.
|
||||
// TODO: Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.
|
||||
// TODO: Persist background job status and define restart recovery before exposing remote observation.
|
||||
// TODO: Re-add model-facing background launch only with owner-bound get/wait/cancel tools and completion delivery.
|
||||
// TODO: Add HTTP background-job observation only after durable status, restart recovery, and authorization are defined.
|
||||
// TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.
|
||||
// TODO: Revisit binary output handling if stdout/stderr decoding is text-only.
|
||||
// TODO: Stream full shell output into managed storage while retaining only a bounded in-memory preview.
|
||||
|
||||
const shellTokens = (command: string) => command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []
|
||||
const unquote = (value: string) => value.replace(/^(['"])(.*)\1$/, "$2")
|
||||
const externalCommandDirectories = (command: string, cwd: string) => {
|
||||
const directories = new Set<string>()
|
||||
for (const token of shellTokens(command)) {
|
||||
const value = unquote(token).replace(/[;,|&]+$/, "")
|
||||
if (!path.isAbsolute(value)) continue
|
||||
const resolved = FSUtil.resolve(value)
|
||||
if (FSUtil.contains(cwd, resolved)) continue
|
||||
directories.add(FSUtil.resolve(path.dirname(resolved)))
|
||||
}
|
||||
return [...directories]
|
||||
}
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
const config = yield* Config.Service
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, sessionID, call, assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
const plan = yield* mutation.resolve({ path: parameters.workdir ?? ".", kind: "directory" })
|
||||
const external = plan.target.externalDirectory
|
||||
if (external) yield* assertPermission(LocationMutation.externalDirectoryPermission(external))
|
||||
const warnings = externalCommandDirectories(parameters.command, plan.target.canonical).map(
|
||||
(directory) =>
|
||||
`Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Bash runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
|
||||
)
|
||||
yield* assertPermission({ action: name, resources: [parameters.command], save: [parameters.command] })
|
||||
|
||||
const target = yield* mutation.revalidate(plan)
|
||||
if (!target.exists || target.type !== "Directory")
|
||||
throw new Error(`Working directory is not a directory: ${target.canonical}`)
|
||||
|
||||
const entries = yield* config.entries()
|
||||
const shell =
|
||||
Object.assign({}, ...entries.flatMap((entry) => (entry.type === "document" ? [entry.info] : []))).shell ??
|
||||
defaultShell()
|
||||
const command = ChildProcess.make(parameters.command, [], {
|
||||
cwd: target.canonical,
|
||||
shell,
|
||||
stdin: "ignore",
|
||||
detached: process.platform !== "win32",
|
||||
forceKillAfter: Duration.seconds(3),
|
||||
})
|
||||
const timeout = parameters.timeout ?? DEFAULT_TIMEOUT_MS
|
||||
const result = yield* appProcess
|
||||
.run(command, {
|
||||
timeout: Duration.millis(timeout),
|
||||
maxOutputBytes: MAX_CAPTURE_BYTES,
|
||||
maxErrorBytes: MAX_CAPTURE_BYTES,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("AppProcessError", (error) =>
|
||||
isTimeout(error) ? Effect.succeed(undefined) : Effect.fail(error),
|
||||
),
|
||||
)
|
||||
if (!result) {
|
||||
return {
|
||||
command: parameters.command,
|
||||
cwd: target.canonical,
|
||||
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
||||
truncated: false,
|
||||
timedOut: true,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const compact = compactOutput(result.stdout.toString("utf8"), result.stderr.toString("utf8"))
|
||||
const notice = captureNotice(result.stdoutTruncated, result.stderrTruncated)
|
||||
const truncated = yield* resources.truncate({
|
||||
sessionID,
|
||||
toolCallID: call.id,
|
||||
content: notice ? `${compact}\n\n${notice}` : compact,
|
||||
})
|
||||
return {
|
||||
command: parameters.command,
|
||||
cwd: target.canonical,
|
||||
exitCode: result.exitCode,
|
||||
output: truncated.content,
|
||||
truncated: truncated.truncated || result.stdoutTruncated || result.stderrTruncated,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
...(result.stdoutTruncated ? { stdoutTruncated: true } : {}),
|
||||
...(result.stderrTruncated ? { stderrTruncated: true } : {}),
|
||||
...(truncated.truncated && !result.stdoutTruncated && !result.stderrTruncated
|
||||
? { resource: truncated.resource }
|
||||
: {}),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(
|
||||
new ToolFailure({
|
||||
message: `Unable to execute command: ${parameters.command}`,
|
||||
error: Cause.squash(cause),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
43
packages/core/src/tool/builtins.ts
Normal file
43
packages/core/src/tool/builtins.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
export * as BuiltInTools from "./builtins"
|
||||
|
||||
import { Layer } from "effect"
|
||||
import { BashTool } from "./bash"
|
||||
import { ApplyPatchTool } from "./apply-patch"
|
||||
import { EditTool } from "./edit"
|
||||
import { GlobTool } from "./glob"
|
||||
import { GrepTool } from "./grep"
|
||||
import { QuestionTool } from "./question"
|
||||
import { ReadTool } from "./read"
|
||||
import { SkillTool } from "./skill"
|
||||
import { TodoWriteTool } from "./todowrite"
|
||||
import { WebFetchTool } from "./webfetch"
|
||||
import { WebSearchTool } from "./websearch"
|
||||
import { WriteTool } from "./write"
|
||||
|
||||
/**
|
||||
* Composes only the shipped Location-scoped built-in tool contributions.
|
||||
* Each tool retains its implementation and focused tests independently. Dynamic
|
||||
* MCP and plugin tools later use separate scoped ToolRegistry transforms, while
|
||||
* provider/model filtering belongs to a future materialization phase rather
|
||||
* than this static list. The caller intentionally supplies shared Location
|
||||
* services once to this merged set.
|
||||
*
|
||||
* TODO: Port the remaining launch-follow-up leaves deliberately: edit fuzzy
|
||||
* parity, task, LSP,
|
||||
* repo_clone, repo_overview, plan_exit, and Rune/code mode. Keep MCP and plugin
|
||||
* contributions separate from this static built-in list.
|
||||
*/
|
||||
export const locationLayer = Layer.mergeAll(
|
||||
ApplyPatchTool.layer,
|
||||
BashTool.layer,
|
||||
EditTool.layer,
|
||||
GlobTool.layer,
|
||||
GrepTool.layer,
|
||||
QuestionTool.layer,
|
||||
ReadTool.layer,
|
||||
SkillTool.layer,
|
||||
TodoWriteTool.layer,
|
||||
WebFetchTool.layer,
|
||||
WebSearchTool.layer.pipe(Layer.provide(WebSearchTool.defaultConfigLayer)),
|
||||
WriteTool.layer,
|
||||
)
|
||||
169
packages/core/src/tool/edit.ts
Normal file
169
packages/core/src/tool/edit.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
/**
|
||||
* Model-facing V2 exact-edit leaf. Relative paths resolve within the active
|
||||
* Location. Absolute paths inside that Location are accepted, while explicit
|
||||
* absolute external paths retain mutation capability through a separate
|
||||
* external_directory approval before edit approval. Named project references
|
||||
* are read-oriented and deliberately are not accepted by mutation tools.
|
||||
*/
|
||||
export * as EditTool from "./edit"
|
||||
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { ToolRegistry } from "../tool-registry"
|
||||
|
||||
export const name = "edit"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
path: Schema.String.annotate({
|
||||
description:
|
||||
"File path to edit. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.",
|
||||
}),
|
||||
oldString: Schema.String.annotate({ description: "Exact text to replace" }),
|
||||
newString: Schema.String.annotate({ description: "Replacement text, which must differ from oldString" }),
|
||||
replaceAll: Schema.Boolean.pipe(Schema.optional).annotate({
|
||||
description: "Replace all exact occurrences of oldString (default false)",
|
||||
}),
|
||||
})
|
||||
|
||||
export const Success = Schema.Struct({
|
||||
operation: Schema.Literal("write"),
|
||||
target: Schema.String,
|
||||
resource: Schema.String,
|
||||
existed: Schema.Boolean,
|
||||
replacements: Schema.Number,
|
||||
})
|
||||
export type Success = typeof Success.Type
|
||||
|
||||
const normalizeLineEndings = (text: string) => text.replaceAll("\r\n", "\n")
|
||||
const detectLineEnding = (text: string): "\n" | "\r\n" => (text.includes("\r\n") ? "\r\n" : "\n")
|
||||
const convertToLineEnding = (text: string, ending: "\n" | "\r\n") =>
|
||||
ending === "\n" ? normalizeLineEndings(text) : normalizeLineEndings(text).replaceAll("\n", "\r\n")
|
||||
|
||||
const splitBom = (text: string) =>
|
||||
text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text }
|
||||
const joinBom = (text: string, bom: boolean) => (bom ? `\uFEFF${text}` : text)
|
||||
const decodeUtf8 = (content: Uint8Array) => {
|
||||
const bom = content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
|
||||
return { bom, content, text: new TextDecoder().decode(bom ? content.slice(3) : content) }
|
||||
}
|
||||
|
||||
const countOccurrences = (content: string, search: string) => {
|
||||
if (search === "") return content.length + 1
|
||||
let count = 0
|
||||
let offset = 0
|
||||
while ((offset = content.indexOf(search, offset)) !== -1) {
|
||||
count++
|
||||
offset += search.length
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
const previewLines = (value: string, prefix: "+" | "-") => {
|
||||
const lines = normalizeLineEndings(value).split("\n")
|
||||
const shown = lines.slice(0, 6).map((line) => `${prefix}${line.length > 240 ? `${line.slice(0, 240)}...` : line}`)
|
||||
if (lines.length > shown.length) shown.push(`${prefix}...`)
|
||||
return shown
|
||||
}
|
||||
|
||||
export const toModelOutput = (output: Success, oldString: string, newString: string) =>
|
||||
[
|
||||
`Edited file successfully: ${output.resource}`,
|
||||
`Replacements: ${output.replacements}`,
|
||||
"```diff",
|
||||
...previewLines(oldString, "-"),
|
||||
...previewLines(newString, "+"),
|
||||
"```",
|
||||
].join("\n")
|
||||
|
||||
const definition = Tool.make({
|
||||
description:
|
||||
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ parameters, output }) => [
|
||||
toolText({ type: "text", text: toModelOutput(output, parameters.oldString, parameters.newString) }),
|
||||
],
|
||||
})
|
||||
|
||||
/** Deferred V2 edit behavior and UX integrations remain visible at the model-facing seam. */
|
||||
// TODO: Port V1 fuzzy correction strategies only after exact-edit behavior is established: line-trimmed matching, block-anchor fallback, indentation correction, and similarity-threshold review.
|
||||
// TODO: Add formatter integration after V2 formatter runtime exists.
|
||||
// TODO: Publish watcher/file-edit events after V2 watcher integration exists.
|
||||
// TODO: Add snapshots / undo after design exists.
|
||||
// TODO: Add LSP notification and diagnostics after V2 LSP runtime exists.
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, assertPermission }) => {
|
||||
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
const error = Cause.squash(cause)
|
||||
return Effect.fail(
|
||||
error instanceof FileMutation.StaleContentError
|
||||
? new ToolFailure({ message: "File changed after permission approval. Read it again before editing." })
|
||||
: new ToolFailure({ message: `Unable to edit ${parameters.path}`, error }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
return Effect.gen(function* () {
|
||||
if (parameters.oldString === parameters.newString) {
|
||||
return yield* new ToolFailure({ message: "No changes to apply: oldString and newString are identical." })
|
||||
}
|
||||
if (parameters.oldString === "") {
|
||||
return yield* new ToolFailure({ message: "oldString must not be empty. Use write to create or overwrite a file." })
|
||||
}
|
||||
|
||||
const plan = yield* unableToEdit(mutation.resolve({ path: parameters.path, kind: "file" }))
|
||||
const external = plan.target.externalDirectory
|
||||
if (external) {
|
||||
yield* unableToEdit(assertPermission(LocationMutation.externalDirectoryPermission(external)))
|
||||
}
|
||||
|
||||
yield* unableToEdit(assertPermission({ action: "edit", resources: [plan.target.resource], save: ["*"] }))
|
||||
const readable = yield* unableToEdit(mutation.revalidate(plan))
|
||||
const source = decodeUtf8(yield* unableToEdit(fs.readFile(readable.canonical)))
|
||||
const ending = detectLineEnding(source.text)
|
||||
const oldString = convertToLineEnding(parameters.oldString, ending)
|
||||
const newString = convertToLineEnding(parameters.newString, ending)
|
||||
const replacements = countOccurrences(source.text, oldString)
|
||||
if (replacements === 0) {
|
||||
return yield* new ToolFailure({
|
||||
message:
|
||||
"Could not find oldString in the file. It must match exactly, including whitespace and indentation.",
|
||||
})
|
||||
}
|
||||
if (replacements > 1 && parameters.replaceAll !== true) {
|
||||
return yield* new ToolFailure({
|
||||
message:
|
||||
"Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.",
|
||||
})
|
||||
}
|
||||
|
||||
const replaced =
|
||||
parameters.replaceAll === true
|
||||
? source.text.replaceAll(oldString, newString)
|
||||
: source.text.replace(oldString, newString)
|
||||
const next = splitBom(replaced)
|
||||
const result = yield* unableToEdit(
|
||||
files.writeIfUnchanged({ plan, expected: source.content, content: joinBom(next.text, source.bom || next.bom) }),
|
||||
)
|
||||
return { ...result, replacements } satisfies Success
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
75
packages/core/src/tool/glob.ts
Normal file
75
packages/core/src/tool/glob.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
export * as GlobTool from "./glob"
|
||||
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { LocationSearch } from "../location-search"
|
||||
import { ToolRegistry } from "../tool-registry"
|
||||
|
||||
export const name = "glob"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
pattern: LocationSearch.FilesInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }),
|
||||
path: LocationSearch.FilesInput.fields.path.annotate({ description: "Relative directory to search. Defaults to the active Location." }),
|
||||
reference: LocationSearch.FilesInput.fields.reference.annotate({ description: "Named project reference to search instead of the active Location" }),
|
||||
limit: LocationSearch.FilesInput.fields.limit.annotate({ description: `Maximum results to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})` }),
|
||||
})
|
||||
|
||||
type ModelOutput = typeof LocationSearch.FilesResult.Encoded
|
||||
|
||||
/** Format raw Location search results into the concise line-oriented output models expect. */
|
||||
export const toModelOutput = (output: ModelOutput) => {
|
||||
const lines = output.items.length === 0 ? ["No files found"] : output.items.map((item) => item.resource)
|
||||
if (output.truncated) {
|
||||
lines.push("", `(Results are truncated: showing first ${output.items.length} results. Consider using a more specific path or pattern.)`)
|
||||
}
|
||||
if (output.partial) lines.push("", "(Results may be incomplete because some discovered files could not be read.)")
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
const definition = Tool.make({
|
||||
description: "Find files by glob pattern within the active Location or a named project reference. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
|
||||
parameters: Parameters,
|
||||
success: LocationSearch.FilesResult,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
})
|
||||
|
||||
/**
|
||||
* Location-scoped glob leaf. FileSystem selects a canonical root for
|
||||
* permission metadata; LocationSearch owns containment and traversal.
|
||||
*
|
||||
* TODO: Revisit root-specific search permission resources if named-reference policy needs independent allow/deny rules.
|
||||
*/
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const search = yield* LocationSearch.Service
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* filesystem.resolveRoot({ path: parameters.path, reference: parameters.reference })
|
||||
yield* assertPermission({
|
||||
action: name,
|
||||
resources: [parameters.pattern],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
root: root.resource,
|
||||
reference: parameters.reference,
|
||||
path: parameters.path,
|
||||
limit: parameters.limit,
|
||||
},
|
||||
})
|
||||
return yield* search.files(parameters, root)
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(new ToolFailure({ message: `Unable to find files matching ${parameters.pattern}`, error: Cause.squash(cause) })),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
91
packages/core/src/tool/grep.ts
Normal file
91
packages/core/src/tool/grep.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
export * as GrepTool from "./grep"
|
||||
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { LocationSearch } from "../location-search"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { ToolRegistry } from "../tool-registry"
|
||||
|
||||
export const name = "grep"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
pattern: LocationSearch.GrepInput.fields.pattern.annotate({ description: "Regex pattern to search for in file contents" }),
|
||||
path: LocationSearch.GrepInput.fields.path.annotate({ description: "Relative file or directory to search. Defaults to the active Location." }),
|
||||
reference: LocationSearch.GrepInput.fields.reference.annotate({ description: "Named project reference to search instead of the active Location" }),
|
||||
include: LocationSearch.GrepInput.fields.include.annotate({ description: 'File glob to include in the search (for example, "*.js" or "*.{ts,tsx}")' }),
|
||||
limit: LocationSearch.GrepInput.fields.limit.annotate({ description: `Maximum matches to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})` }),
|
||||
})
|
||||
|
||||
type Success = typeof LocationSearch.GrepResult.Encoded
|
||||
|
||||
/** Format raw Location search matches into the familiar concise model output. */
|
||||
export const toModelOutput = (output: Success) => {
|
||||
const lines = output.items.length === 0 ? ["No files found"] : [`Found ${output.items.length} matches`]
|
||||
let current = ""
|
||||
for (const match of output.items) {
|
||||
if (current !== match.resource) {
|
||||
if (current) lines.push("")
|
||||
current = match.resource
|
||||
lines.push(`${match.resource}:`)
|
||||
}
|
||||
lines.push(` Line ${match.line}: ${match.lines}${match.linePreviewTruncated ? "..." : ""}`)
|
||||
}
|
||||
if (output.truncated) {
|
||||
lines.push("", `(Results are truncated: showing first ${output.items.length} matches. Consider using a more specific path or pattern.)`)
|
||||
}
|
||||
if (output.partial) lines.push("", "(Some paths were inaccessible and skipped)")
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
const definition = Tool.make({
|
||||
description: "Search file contents by regular expression within the active Location or a named project reference. Use a relative path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise relative file resources, line numbers, and bounded line previews.",
|
||||
parameters: Parameters,
|
||||
success: LocationSearch.GrepResult,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
})
|
||||
|
||||
/**
|
||||
* Location-scoped grep leaf. FileSystem selects a canonical root for
|
||||
* permission metadata; LocationSearch owns containment and ripgrep execution.
|
||||
*
|
||||
* TODO: Revisit root-specific search permission resources if named-reference policy needs independent allow/deny rules.
|
||||
*/
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const search = yield* LocationSearch.Service
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* filesystem.resolveRoot(parameters)
|
||||
yield* assertPermission({
|
||||
action: name,
|
||||
resources: [parameters.pattern],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
root: root.resource,
|
||||
reference: parameters.reference,
|
||||
path: parameters.path,
|
||||
include: parameters.include,
|
||||
limit: parameters.limit,
|
||||
},
|
||||
})
|
||||
return yield* search.grep(parameters, root)
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
const error = Cause.squash(cause)
|
||||
const message = error instanceof Ripgrep.InvalidPatternError
|
||||
? `Invalid grep pattern ${JSON.stringify(parameters.pattern)}: ${error.message}`
|
||||
: `Unable to grep for ${parameters.pattern}`
|
||||
return Effect.fail(new ToolFailure({ message, error }))
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
76
packages/core/src/tool/question.ts
Normal file
76
packages/core/src/tool/question.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
export * as QuestionTool from "./question"
|
||||
|
||||
import { Tool, toolText } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { QuestionV2 } from "../question"
|
||||
import { ToolRegistry } from "../tool-registry"
|
||||
|
||||
export const name = "question"
|
||||
|
||||
export const description = `Use this tool when you need to ask the user questions during execution. This allows you to:
|
||||
1. Gather user preferences or requirements
|
||||
2. Clarify ambiguous instructions
|
||||
3. Get decisions on implementation choices as you work
|
||||
4. Offer choices to the user about what direction to take.
|
||||
|
||||
Usage notes:
|
||||
- When \`custom\` is enabled (default), a "Type your own answer" option is added automatically; don't include "Other" or catch-all options
|
||||
- Answers are returned as arrays of labels; set \`multiple: true\` to allow selecting more than one
|
||||
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
questions: Schema.Array(QuestionV2.Prompt).annotate({ description: "Questions to ask" }),
|
||||
})
|
||||
|
||||
export const Success = Schema.Struct({
|
||||
answers: Schema.Array(QuestionV2.Answer),
|
||||
})
|
||||
export type Success = typeof Success.Type
|
||||
|
||||
export const toModelOutput = (
|
||||
questions: ReadonlyArray<QuestionV2.Prompt>,
|
||||
answers: ReadonlyArray<QuestionV2.Answer>,
|
||||
) => {
|
||||
const formatted = questions
|
||||
.map(
|
||||
(question, index) =>
|
||||
`"${question.question}"="${answers[index]?.length ? answers[index].join(", ") : "Unanswered"}"`,
|
||||
)
|
||||
.join(", ")
|
||||
return `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`
|
||||
}
|
||||
|
||||
const definition = Tool.make({
|
||||
description,
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ parameters, output }) => [
|
||||
toolText({ type: "text", text: toModelOutput(parameters.questions, output.answers) }),
|
||||
],
|
||||
})
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const question = yield* QuestionV2.Service
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, sessionID, source }) =>
|
||||
question
|
||||
.ask({
|
||||
sessionID,
|
||||
questions: parameters.questions,
|
||||
// The registry intentionally leaves source absent until it owns the durable assistant message ID.
|
||||
tool: source?.type === "tool" ? { messageID: source.messageID, callID: source.callID } : undefined,
|
||||
})
|
||||
.pipe(
|
||||
Effect.map((answers) => ({ answers })),
|
||||
// V1 treats a dismissed question as an interrupted tool invocation rather than model-facing text.
|
||||
Effect.orDie,
|
||||
),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
96
packages/core/src/tool/read.ts
Normal file
96
packages/core/src/tool/read.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
export * as ReadTool from "./read"
|
||||
|
||||
import { Tool, ToolFailure } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { NonNegativeInt, PositiveInt } from "../schema"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { ToolRegistry } from "../tool-registry"
|
||||
|
||||
export const name = "read"
|
||||
const LocationInput = Schema.Struct({
|
||||
...FileSystem.ReadInput.fields,
|
||||
offset: FileSystem.ListPageInput.fields.offset.annotate({
|
||||
description: "The 1-based directory entry or text line offset to start reading from",
|
||||
}),
|
||||
limit: FileSystem.ListPageInput.fields.limit.annotate({
|
||||
description: "The maximum number of directory entries or text lines to read",
|
||||
}),
|
||||
})
|
||||
const ResourceInput = Schema.Struct({
|
||||
resource: Schema.String,
|
||||
offset: NonNegativeInt.pipe(Schema.optional),
|
||||
limit: PositiveInt.check(Schema.isLessThanOrEqualTo(ToolOutputStore.MAX_READ_BYTES)).pipe(Schema.optional),
|
||||
})
|
||||
const Input = Schema.Union([LocationInput, ResourceInput])
|
||||
const Success = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSystem.ListPage, ToolOutputStore.Page])
|
||||
|
||||
const definition = Tool.make({
|
||||
description:
|
||||
"Read a text or binary file, page through a large UTF-8 text file by line offset, list a directory page relative to the current location, or page through a managed tool-output resource by opaque URI.",
|
||||
parameters: Input,
|
||||
success: Success,
|
||||
})
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, sessionID, assertPermission }) => {
|
||||
const input = parameters
|
||||
return Effect.gen(function* () {
|
||||
if ("resource" in input)
|
||||
return yield* resources.read({ sessionID, uri: input.resource, offset: input.offset, limit: input.limit })
|
||||
const resolved = yield* filesystem.resolveReadPath(input)
|
||||
if (resolved.type === "directory") {
|
||||
const { offset, limit } = input
|
||||
const target = resolved.target
|
||||
yield* assertPermission({ action: name, resources: [target.resource], save: ["*"] })
|
||||
const final = yield* filesystem.resolveReadPath(input)
|
||||
if (
|
||||
final.type !== "directory" ||
|
||||
final.target.resource !== target.resource ||
|
||||
final.target.real !== target.real
|
||||
)
|
||||
return yield* Effect.die(new Error("Directory changed after permission approval"))
|
||||
return yield* filesystem.listPageResolved(final.target, { offset, limit })
|
||||
}
|
||||
const target = resolved.target
|
||||
yield* assertPermission({
|
||||
action: name,
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
})
|
||||
const final = yield* filesystem.resolveReadPath(input)
|
||||
if (final.type !== "file" || final.target.resource !== target.resource || final.target.real !== target.real)
|
||||
return yield* Effect.die(new Error("File changed after permission approval"))
|
||||
if (final.target.size > FileSystem.MAX_READ_BYTES || input.offset !== undefined || input.limit !== undefined)
|
||||
return yield* filesystem.readTextPageResolved(final.target, { offset: input.offset, limit: input.limit })
|
||||
return yield* filesystem.readResolved(final.target, FileSystem.MAX_READ_BYTES)
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(
|
||||
new ToolFailure({
|
||||
message: `Unable to read ${"resource" in input ? input.resource : input.path}`,
|
||||
error: Cause.squash(cause),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(ToolRegistry.layer),
|
||||
Layer.provideMerge(FileSystem.locationLayer),
|
||||
Layer.provideMerge(PermissionV2.locationLayer),
|
||||
Layer.provideMerge(ToolOutputStore.defaultLayer),
|
||||
)
|
||||
119
packages/core/src/tool/skill.ts
Normal file
119
packages/core/src/tool/skill.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
export * as SkillTool from "./skill"
|
||||
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { PluginBoot } from "../plugin/boot"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { ToolRegistry } from "../tool-registry"
|
||||
|
||||
export const name = "skill"
|
||||
const FILE_LIMIT = 10
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
name: Schema.String.annotate({ description: "The name of the skill from the available skills list" }),
|
||||
})
|
||||
|
||||
export const Success = Schema.Struct({
|
||||
name: Schema.String,
|
||||
directory: Schema.String,
|
||||
output: Schema.String,
|
||||
truncated: Schema.Boolean,
|
||||
resource: ToolOutputStore.Resource.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
export const description = (skills: ReadonlyArray<SkillV2.Info>) =>
|
||||
[
|
||||
"Load a specialized skill when the task at hand matches one of the available skills listed below.",
|
||||
"",
|
||||
"Use this tool to inject the skill's instructions and resources into the current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc. in the same directory as the skill.",
|
||||
"",
|
||||
"The skill name must match one of the available skills listed below:",
|
||||
"",
|
||||
...(skills.length
|
||||
? skills.map((skill) => `- **${skill.name}**: ${skill.description ?? "No description provided."}`)
|
||||
: ["No skills are currently available."]),
|
||||
].join("\n")
|
||||
|
||||
export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray<string>) => {
|
||||
const directory = path.dirname(skill.location)
|
||||
return [
|
||||
`<skill_content name="${skill.name}">`,
|
||||
`# Skill: ${skill.name}`,
|
||||
"",
|
||||
skill.content.trim(),
|
||||
"",
|
||||
`Base directory for this skill: ${pathToFileURL(directory).href}`,
|
||||
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
|
||||
"Note: file list is sampled.",
|
||||
"",
|
||||
"<skill_files>",
|
||||
...files.map((file) => `<file>${file}</file>`),
|
||||
"</skill_files>",
|
||||
"</skill_content>",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const notFound = (name: string, skills: ReadonlyArray<SkillV2.Info>) =>
|
||||
new ToolFailure({
|
||||
message: `Skill "${name}" not found. Available skills: ${skills.map((skill) => skill.name).join(", ") || "none"}`,
|
||||
})
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const boot = yield* PluginBoot.Service
|
||||
const skills = yield* SkillV2.Service
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
yield* boot.wait()
|
||||
const available = yield* skills.list()
|
||||
const definition = Tool.make({
|
||||
description: description(available),
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
|
||||
})
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, sessionID, call, assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* skills.list()
|
||||
const skill = current.find((skill) => skill.name === parameters.name)
|
||||
if (!skill) return yield* notFound(parameters.name, current)
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* assertPermission({ action: name, resources: [skill.name], save: [skill.name] })
|
||||
const directory = path.dirname(skill.location)
|
||||
const files = (yield* fs.glob("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
|
||||
.filter((file) => path.basename(file) !== "SKILL.md")
|
||||
.toSorted()
|
||||
.slice(0, FILE_LIMIT)
|
||||
const output = yield* resources.truncate({
|
||||
sessionID,
|
||||
toolCallID: call.id,
|
||||
content: toModelOutput(skill, files),
|
||||
})
|
||||
return {
|
||||
name: skill.name,
|
||||
directory,
|
||||
output: output.content,
|
||||
truncated: output.truncated,
|
||||
...(output.truncated ? { resource: output.resource } : {}),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(
|
||||
new ToolFailure({ message: `Unable to load skill ${parameters.name}`, error: Cause.squash(cause) }),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
50
packages/core/src/tool/todowrite.ts
Normal file
50
packages/core/src/tool/todowrite.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
export * as TodoWriteTool from "./todowrite"
|
||||
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { SessionTodo } from "../session/todo"
|
||||
import { ToolRegistry } from "../tool-registry"
|
||||
|
||||
export const name = "todowrite"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
todos: Schema.Array(SessionTodo.Info).annotate({ description: "The updated todo list" }),
|
||||
})
|
||||
|
||||
export const Success = Schema.Struct({
|
||||
todos: Schema.Array(SessionTodo.Info),
|
||||
})
|
||||
export type Success = typeof Success.Type
|
||||
|
||||
export const toModelOutput = (output: Success) => JSON.stringify(output.todos, null, 2)
|
||||
|
||||
const definition = Tool.make({
|
||||
description:
|
||||
"Create and maintain a structured task list for the current coding session. Use it to track progress during multi-step work and keep todo statuses current.",
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
})
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const todos = yield* SessionTodo.Service
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, sessionID, assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* assertPermission({ action: name, resources: ["*"], save: ["*"] })
|
||||
yield* todos.update({ sessionID, todos: parameters.todos })
|
||||
return { todos: parameters.todos }
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(new ToolFailure({ message: "Unable to update todos", error: Cause.squash(cause) })),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
222
packages/core/src/tool/webfetch.ts
Normal file
222
packages/core/src/tool/webfetch.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
export * as WebFetchTool from "./webfetch"
|
||||
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Duration, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Parser } from "htmlparser2"
|
||||
import TurndownService from "turndown"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { ToolRegistry } from "../tool-registry"
|
||||
|
||||
export const name = "webfetch"
|
||||
export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
|
||||
export const DEFAULT_TIMEOUT_SECONDS = 30
|
||||
export const MAX_TIMEOUT_SECONDS = 120
|
||||
|
||||
export const description = `Fetch content from an HTTP or HTTPS URL and return it as text, markdown, or HTML. Markdown is the default.
|
||||
|
||||
Use a more targeted tool when one is available. This tool is read-only. Large text results are truncated with an opaque managed resource URI for paging.`
|
||||
|
||||
const Timeout = Schema.Number.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(MAX_TIMEOUT_SECONDS))
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
url: Schema.String.annotate({ description: "The HTTP or HTTPS URL to fetch content from" }),
|
||||
format: Schema.Literals(["text", "markdown", "html"])
|
||||
.annotate({ description: "The format to return the content in. Defaults to markdown." })
|
||||
.pipe(Schema.withDecodingDefault(Effect.succeed("markdown" as const))),
|
||||
timeout: Timeout.pipe(Schema.optional).annotate({
|
||||
description: `Optional timeout in seconds (maximum: ${MAX_TIMEOUT_SECONDS})`,
|
||||
}),
|
||||
})
|
||||
|
||||
const Success = Schema.Struct({
|
||||
url: Schema.String,
|
||||
contentType: Schema.String,
|
||||
format: Parameters.fields.format,
|
||||
output: Schema.String,
|
||||
truncated: Schema.Boolean,
|
||||
resource: ToolOutputStore.Resource.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
type Format = (typeof Parameters.Type)["format"]
|
||||
|
||||
const acceptHeader = (format: Format) => {
|
||||
switch (format) {
|
||||
case "markdown":
|
||||
return "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1"
|
||||
case "text":
|
||||
return "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1"
|
||||
case "html":
|
||||
return "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1"
|
||||
}
|
||||
}
|
||||
|
||||
const headers = (format: Format, userAgent: string) => ({
|
||||
"User-Agent": userAgent,
|
||||
Accept: acceptHeader(format),
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
})
|
||||
|
||||
const browserUserAgent =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
|
||||
|
||||
const isCloudflareChallenge = (error: unknown) => {
|
||||
if (!error || typeof error !== "object" || !("reason" in error)) return false
|
||||
const reason = error.reason
|
||||
if (
|
||||
!reason ||
|
||||
typeof reason !== "object" ||
|
||||
!("_tag" in reason) ||
|
||||
reason._tag !== "StatusCodeError" ||
|
||||
!("response" in reason)
|
||||
)
|
||||
return false
|
||||
const response = reason.response as HttpClientResponse.HttpClientResponse
|
||||
return response.status === 403 && response.headers["cf-mitigated"] === "challenge"
|
||||
}
|
||||
|
||||
const request = (url: string, format: Format, userAgent = browserUserAgent) =>
|
||||
HttpClientRequest.get(url).pipe(HttpClientRequest.setHeaders(headers(format, userAgent)))
|
||||
|
||||
const assertHttpUrl = (url: URL) => {
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("URL must use http:// or https://")
|
||||
}
|
||||
|
||||
const execute = (http: HttpClient.HttpClient, url: string, format: Format, userAgent = browserUserAgent) =>
|
||||
http.execute(request(url, format, userAgent)).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk))
|
||||
|
||||
const collectBody = (response: HttpClientResponse.HttpClientResponse) =>
|
||||
Effect.gen(function* () {
|
||||
const contentLength = response.headers["content-length"]
|
||||
if (contentLength && Number.parseInt(contentLength, 10) > MAX_RESPONSE_BYTES) {
|
||||
return yield* Effect.die(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`))
|
||||
}
|
||||
const chunks: Uint8Array[] = []
|
||||
let size = 0
|
||||
yield* Stream.runForEach(response.stream, (chunk) =>
|
||||
Effect.sync(() => {
|
||||
size += chunk.byteLength
|
||||
if (size > MAX_RESPONSE_BYTES) throw new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`)
|
||||
chunks.push(chunk)
|
||||
}),
|
||||
)
|
||||
return Buffer.concat(chunks, size)
|
||||
})
|
||||
|
||||
const mimeFrom = (contentType: string) => contentType.split(";", 1)[0]?.trim().toLowerCase() ?? ""
|
||||
const isImageAttachment = (mime: string) =>
|
||||
mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
|
||||
const isTextualMime = (mime: string) =>
|
||||
!mime ||
|
||||
mime.startsWith("text/") ||
|
||||
mime === "application/json" ||
|
||||
mime.endsWith("+json") ||
|
||||
mime === "application/xml" ||
|
||||
mime.endsWith("+xml") ||
|
||||
mime === "application/javascript" ||
|
||||
mime === "application/x-javascript"
|
||||
const outputMime = (format: Format) =>
|
||||
format === "markdown" ? "text/markdown" : format === "html" ? "text/html" : "text/plain"
|
||||
|
||||
const convert = (content: string, contentType: string, format: Format) => {
|
||||
if (!contentType.includes("text/html")) return content
|
||||
if (format === "markdown") return convertHTMLToMarkdown(content)
|
||||
if (format === "text") return extractTextFromHTML(content)
|
||||
return content
|
||||
}
|
||||
|
||||
const definition = Tool.make({
|
||||
description,
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
|
||||
})
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, sessionID, call, assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
const parsed = new URL(parameters.url)
|
||||
assertHttpUrl(parsed)
|
||||
|
||||
yield* assertPermission({ action: name, resources: [parameters.url], save: ["*"], metadata: parameters })
|
||||
|
||||
const { body, contentType } = yield* Effect.gen(function* () {
|
||||
const response = yield* execute(http, parameters.url, parameters.format).pipe(
|
||||
Effect.catchIf(isCloudflareChallenge, () => execute(http, parameters.url, parameters.format, "opencode")),
|
||||
)
|
||||
const contentType = response.headers["content-type"] || ""
|
||||
const mime = mimeFrom(contentType)
|
||||
if (isImageAttachment(mime)) throw new Error(`Unsupported fetched image content type: ${mime}`)
|
||||
if (!isTextualMime(mime)) throw new Error(`Unsupported fetched file content type: ${mime}`)
|
||||
return { body: yield* collectBody(response), contentType }
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(parameters.timeout ?? DEFAULT_TIMEOUT_SECONDS),
|
||||
orElse: () => Effect.die(new Error("Request timed out")),
|
||||
}),
|
||||
)
|
||||
const content = convert(new TextDecoder().decode(body), contentType, parameters.format)
|
||||
const truncated = yield* resources.truncate({
|
||||
sessionID,
|
||||
toolCallID: call.id,
|
||||
content,
|
||||
mime: outputMime(parameters.format),
|
||||
})
|
||||
return {
|
||||
url: parameters.url,
|
||||
contentType,
|
||||
format: parameters.format,
|
||||
output: truncated.content,
|
||||
truncated: truncated.truncated,
|
||||
...(truncated.truncated ? { resource: truncated.resource } : {}),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(
|
||||
new ToolFailure({ message: `Unable to fetch ${parameters.url}`, error: Cause.squash(cause) }),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export function extractTextFromHTML(html: string) {
|
||||
let text = ""
|
||||
let skipDepth = 0
|
||||
const parser = new Parser({
|
||||
onopentag(name) {
|
||||
if (skipDepth > 0 || ["script", "style", "noscript", "iframe", "object", "embed"].includes(name)) skipDepth++
|
||||
},
|
||||
ontext(input) {
|
||||
if (skipDepth === 0) text += input
|
||||
},
|
||||
onclosetag() {
|
||||
if (skipDepth > 0) skipDepth--
|
||||
},
|
||||
})
|
||||
parser.write(html)
|
||||
parser.end()
|
||||
return text.trim()
|
||||
}
|
||||
|
||||
export function convertHTMLToMarkdown(html: string) {
|
||||
const turndown = new TurndownService({
|
||||
headingStyle: "atx",
|
||||
hr: "---",
|
||||
bulletListMarker: "-",
|
||||
codeBlockStyle: "fenced",
|
||||
emDelimiter: "*",
|
||||
})
|
||||
turndown.remove(["script", "style", "meta", "link"])
|
||||
return turndown.turndown(html)
|
||||
}
|
||||
244
packages/core/src/tool/websearch.ts
Normal file
244
packages/core/src/tool/websearch.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
export * as WebSearchTool from "./websearch"
|
||||
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Context, Duration, Effect, Layer, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { truthy } from "../flag/flag"
|
||||
import { InstallationVersion } from "../installation/version"
|
||||
import { PositiveInt } from "../schema"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { ToolRegistry } from "../tool-registry"
|
||||
import { checksum } from "../util/encode"
|
||||
|
||||
export const name = "websearch"
|
||||
export const NO_RESULTS = "No search results found. Please try a different query."
|
||||
export const EXA_URL = "https://mcp.exa.ai/mcp"
|
||||
export const PARALLEL_URL = "https://search.parallel.ai/mcp"
|
||||
export const MAX_NUM_RESULTS = 20
|
||||
export const MAX_CONTEXT_CHARACTERS = 50_000
|
||||
export const MAX_RESPONSE_BYTES = 256 * 1024
|
||||
|
||||
/**
|
||||
* Provider-independent local web search retained in V2 core for launch parity.
|
||||
* This invokes the legacy Exa/Parallel product backends itself. It is distinct
|
||||
* from provider-hosted web search tools, which remain route-owned and execute
|
||||
* at the model provider. Ownership of this compromise can be revisited later.
|
||||
*/
|
||||
export const description = `Search the web using the session's local web search provider. Use this for current information beyond knowledge cutoff.
|
||||
|
||||
This is a provider-independent local tool backed by Exa or Parallel. Provider-hosted web search tools are separate and execute at the model provider.
|
||||
|
||||
Optional controls support result count, live crawling ('fallback' or 'preferred'), search type ('auto', 'fast', or 'deep'), and maximum context characters.
|
||||
|
||||
The current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.`
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
query: Schema.String.annotate({ description: "Websearch query" }),
|
||||
numResults: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_NUM_RESULTS))).annotate({ description: `Number of search results to return (default: 8, maximum: ${MAX_NUM_RESULTS})` }),
|
||||
livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({
|
||||
description:
|
||||
"Live crawl mode - 'fallback': use live crawling as backup if cached unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
|
||||
}),
|
||||
type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({
|
||||
description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
|
||||
}),
|
||||
contextMaxCharacters: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_CONTEXT_CHARACTERS))).annotate({
|
||||
description: `Maximum characters for context string optimized for models (default: 10000, maximum: ${MAX_CONTEXT_CHARACTERS})`,
|
||||
}),
|
||||
})
|
||||
|
||||
export const Provider = Schema.Literals(["exa", "parallel"])
|
||||
export type Provider = typeof Provider.Type
|
||||
|
||||
export interface Config {
|
||||
readonly provider?: Provider
|
||||
readonly enableExa: boolean
|
||||
readonly enableParallel: boolean
|
||||
readonly exaApiKey?: string
|
||||
readonly parallelApiKey?: string
|
||||
}
|
||||
|
||||
export class ConfigService extends Context.Service<ConfigService, Config>()("@opencode/v2/WebSearchConfig") {}
|
||||
|
||||
/** Isolates the retained product environment contract from the generic tool implementation. */
|
||||
export const defaultConfigLayer = Layer.sync(ConfigService, () =>
|
||||
ConfigService.of({
|
||||
provider: process.env.OPENCODE_WEBSEARCH_PROVIDER === "exa" || process.env.OPENCODE_WEBSEARCH_PROVIDER === "parallel"
|
||||
? process.env.OPENCODE_WEBSEARCH_PROVIDER
|
||||
: undefined,
|
||||
enableExa:
|
||||
truthy("OPENCODE_EXPERIMENTAL") ||
|
||||
truthy("OPENCODE_ENABLE_EXA") ||
|
||||
truthy("OPENCODE_EXPERIMENTAL_EXA"),
|
||||
enableParallel: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"),
|
||||
exaApiKey: process.env.EXA_API_KEY,
|
||||
parallelApiKey: process.env.PARALLEL_API_KEY,
|
||||
}),
|
||||
)
|
||||
|
||||
export function selectProvider(
|
||||
sessionID: string,
|
||||
flags: Pick<Config, "enableExa" | "enableParallel"> = { enableExa: false, enableParallel: false },
|
||||
override?: Provider,
|
||||
): Provider {
|
||||
if (override) return override
|
||||
if (flags.enableParallel) return "parallel"
|
||||
if (flags.enableExa) return "exa"
|
||||
return Number.parseInt(checksum(sessionID) ?? "0", 36) % 2 === 0 ? "exa" : "parallel"
|
||||
}
|
||||
|
||||
const McpResult = Schema.Struct({
|
||||
result: Schema.Struct({
|
||||
content: Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.String })),
|
||||
}),
|
||||
})
|
||||
const decodeMcpResult = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult))
|
||||
|
||||
const parsePayload = (payload: string) =>
|
||||
Effect.gen(function* () {
|
||||
const trimmed = payload.trim()
|
||||
if (!trimmed.startsWith("{")) return undefined
|
||||
return (yield* decodeMcpResult(trimmed)).result.content.find((item) => item.text)?.text
|
||||
})
|
||||
|
||||
export const parseResponse = Effect.fn("WebSearchTool.parseResponse")(function* (body: string) {
|
||||
const trimmed = body.trim()
|
||||
const direct = trimmed ? yield* parsePayload(trimmed) : undefined
|
||||
if (direct) return direct
|
||||
for (const line of body.split("\n")) {
|
||||
if (!line.startsWith("data: ")) continue
|
||||
const data = yield* parsePayload(line.substring(6))
|
||||
if (data) return data
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const ExaArgs = Schema.Struct({
|
||||
query: Schema.String,
|
||||
type: Schema.String,
|
||||
numResults: Schema.Number,
|
||||
livecrawl: Schema.String,
|
||||
contextMaxCharacters: Schema.optional(Schema.Number),
|
||||
})
|
||||
const ParallelArgs = Schema.Struct({
|
||||
objective: Schema.String,
|
||||
search_queries: Schema.Array(Schema.String),
|
||||
session_id: Schema.String,
|
||||
})
|
||||
const McpRequest = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
|
||||
Schema.Struct({
|
||||
jsonrpc: Schema.Literal("2.0"),
|
||||
id: Schema.Literal(1),
|
||||
method: Schema.Literal("tools/call"),
|
||||
params: Schema.Struct({ name: Schema.String, arguments: args }),
|
||||
})
|
||||
|
||||
const exaUrl = (apiKey: string | undefined) => {
|
||||
if (!apiKey) return EXA_URL
|
||||
const url = new URL(EXA_URL)
|
||||
url.searchParams.set("exaApiKey", apiKey)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
const callMcp = <F extends Schema.Struct.Fields>(
|
||||
http: HttpClient.HttpClient,
|
||||
url: string,
|
||||
tool: string,
|
||||
args: Schema.Struct<F>,
|
||||
value: Schema.Struct.Type<F>,
|
||||
headers: Record<string, string> = {},
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.accept("application/json, text/event-stream"),
|
||||
HttpClientRequest.setHeaders(headers),
|
||||
HttpClientRequest.schemaBodyJson(McpRequest(args))({
|
||||
jsonrpc: "2.0" as const,
|
||||
id: 1 as const,
|
||||
method: "tools/call" as const,
|
||||
params: { name: tool, arguments: value },
|
||||
}),
|
||||
)
|
||||
return yield* Effect.gen(function* () {
|
||||
const response = yield* HttpClient.filterStatusOk(http).execute(request)
|
||||
const body = yield* response.text
|
||||
if (Buffer.byteLength(body, "utf8") > MAX_RESPONSE_BYTES) return yield* Effect.die(new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`))
|
||||
return yield* parseResponse(body)
|
||||
}).pipe(Effect.timeoutOrElse({ duration: Duration.seconds(25), orElse: () => Effect.die(new Error(`${tool} request timed out`)) }))
|
||||
})
|
||||
|
||||
const Success = Schema.Struct({
|
||||
provider: Provider,
|
||||
text: Schema.String,
|
||||
truncated: Schema.Boolean,
|
||||
resource: ToolOutputStore.Resource.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const definition = Tool.make({
|
||||
description,
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.text })],
|
||||
})
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const config = yield* ConfigService
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, sessionID, call, assertPermission }) => {
|
||||
const provider = selectProvider(sessionID, config, config.provider)
|
||||
return Effect.gen(function* () {
|
||||
yield* assertPermission({
|
||||
action: name,
|
||||
resources: [parameters.query],
|
||||
save: ["*"],
|
||||
metadata: { ...parameters, provider },
|
||||
})
|
||||
|
||||
const text = provider === "exa"
|
||||
? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, {
|
||||
query: parameters.query,
|
||||
type: parameters.type || "auto",
|
||||
numResults: parameters.numResults || 8,
|
||||
livecrawl: parameters.livecrawl || "fallback",
|
||||
contextMaxCharacters: parameters.contextMaxCharacters,
|
||||
})
|
||||
: yield* callMcp(
|
||||
http,
|
||||
PARALLEL_URL,
|
||||
"web_search",
|
||||
ParallelArgs,
|
||||
{
|
||||
objective: parameters.query,
|
||||
search_queries: [parameters.query],
|
||||
session_id: sessionID,
|
||||
// V2 invocation context does not safely expose the model yet.
|
||||
},
|
||||
{
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
|
||||
},
|
||||
)
|
||||
const truncated = yield* resources.truncate({ sessionID, toolCallID: call.id, content: text ?? NO_RESULTS })
|
||||
return {
|
||||
provider,
|
||||
text: truncated.content,
|
||||
truncated: truncated.truncated,
|
||||
...(truncated.truncated ? { resource: truncated.resource } : {}),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(new ToolFailure({ message: `Unable to search the web for ${parameters.query}`, error: Cause.squash(cause) })),
|
||||
),
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
78
packages/core/src/tool/write.ts
Normal file
78
packages/core/src/tool/write.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* Model-facing V2 file-write leaf. Relative paths resolve within the active
|
||||
* Location. Absolute paths inside that Location are accepted, while explicit
|
||||
* absolute external paths retain mutation capability through a separate
|
||||
* external_directory approval before edit approval. Named project references
|
||||
* are read-oriented and deliberately are not accepted by mutation tools.
|
||||
*/
|
||||
export * as WriteTool from "./write"
|
||||
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { ToolRegistry } from "../tool-registry"
|
||||
|
||||
export const name = "write"
|
||||
|
||||
// TODO: Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.
|
||||
export const Parameters = Schema.Struct({
|
||||
path: Schema.String.annotate({
|
||||
description:
|
||||
"File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.",
|
||||
}),
|
||||
content: Schema.String.annotate({ description: "Content to write to the file" }),
|
||||
})
|
||||
|
||||
export const Success = Schema.Struct({
|
||||
operation: Schema.Literal("write"),
|
||||
target: Schema.String,
|
||||
resource: Schema.String,
|
||||
existed: Schema.Boolean,
|
||||
})
|
||||
export type Success = typeof Success.Type
|
||||
|
||||
export const toModelOutput = (output: Success) =>
|
||||
`${output.existed ? "Wrote" : "Created"} file successfully: ${output.resource}`
|
||||
|
||||
const definition = Tool.make({
|
||||
description:
|
||||
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
})
|
||||
|
||||
/** Deferred V2 write UX integrations remain visible at the model-facing seam. */
|
||||
// TODO: Add formatter integration after V2 formatter runtime exists.
|
||||
// TODO: Publish watcher/file-edit events after V2 watcher integration exists.
|
||||
// TODO: Add snapshots / undo after design exists.
|
||||
// TODO: Add LSP notification and diagnostics after V2 LSP runtime exists.
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
const plan = yield* mutation.resolve({ path: parameters.path, kind: "file" })
|
||||
const external = plan.target.externalDirectory
|
||||
if (external) yield* assertPermission(LocationMutation.externalDirectoryPermission(external))
|
||||
yield* assertPermission({ action: "edit", resources: [plan.target.resource], save: ["*"] })
|
||||
return yield* files.writeTextPreservingBom({ plan, content: parameters.content })
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(
|
||||
new ToolFailure({ message: `Unable to write ${parameters.path}`, error: Cause.squash(cause) }),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
@ -4,4 +4,8 @@ export namespace Hash {
|
|||
export function fast(input: string | Buffer): string {
|
||||
return createHash("sha1").update(input).digest("hex")
|
||||
}
|
||||
|
||||
export function sha256(input: string | Buffer): string {
|
||||
return createHash("sha256").update(input).digest("hex")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import { NamedError } from "../util/error"
|
|||
import { SessionSchema } from "../session/schema"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
|
||||
const Timestamp = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0))
|
||||
|
||||
export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe(
|
||||
Schema.brand("MessageID"),
|
||||
withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "msg_" + Identifier.ascending()) })),
|
||||
|
|
@ -329,7 +331,7 @@ export const User = Schema.Struct({
|
|||
...messageBase,
|
||||
role: Schema.Literal("user"),
|
||||
time: Schema.Struct({
|
||||
created: NonNegativeInt,
|
||||
created: Timestamp,
|
||||
}),
|
||||
format: Schema.optional(Format),
|
||||
summary: Schema.optional(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Scope } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AgentV2.locationLayer)
|
||||
|
|
@ -98,4 +102,22 @@ describe("AgentV2", () => {
|
|||
expect(yield* agent.get(id)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not ambiently opt built-in agents into bash", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
yield* AgentPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
|
||||
),
|
||||
)
|
||||
|
||||
const agents = yield* agent.all()
|
||||
expect(agents.map((item) => String(item.id)).sort()).toEqual(["build", "compaction", "explore", "general", "plan", "summary", "title"])
|
||||
for (const item of agents) {
|
||||
expect(item.permissions.some((rule) => rule.action === "bash" && rule.effect !== "deny")).toBe(false)
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
103
packages/core/test/background-job.test.ts
Normal file
103
packages/core/test/background-job.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { BackgroundJob } from "@opencode-ai/core/background-job"
|
||||
import { Deferred, Effect, Exit, Scope } from "effect"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
describe("BackgroundJob", () => {
|
||||
it.live("tracks process-local work through explicit observation", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* BackgroundJob.Service
|
||||
const latch = yield* Deferred.make<void>()
|
||||
const job = yield* jobs.start({
|
||||
type: "test",
|
||||
metadata: { durable: false },
|
||||
run: Deferred.await(latch).pipe(Effect.as("done")),
|
||||
})
|
||||
|
||||
expect(job).toMatchObject({ type: "test", status: "running", metadata: { durable: false } })
|
||||
expect(yield* jobs.wait({ id: job.id, timeout: 0 })).toMatchObject({
|
||||
timedOut: true,
|
||||
info: { status: "running" },
|
||||
})
|
||||
|
||||
yield* Deferred.succeed(latch, undefined)
|
||||
expect(yield* jobs.wait({ id: job.id })).toMatchObject({
|
||||
timedOut: false,
|
||||
info: { status: "completed", output: "done" },
|
||||
})
|
||||
}).pipe(Effect.provide(BackgroundJob.layer)),
|
||||
)
|
||||
|
||||
it.live("publishes jobs before starting immediately settling work", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* BackgroundJob.Service
|
||||
|
||||
yield* Effect.forEach(Array.from({ length: 100 }), (_, index) => {
|
||||
const id = `job_immediate_start_${index}`
|
||||
return Effect.gen(function* () {
|
||||
const job = yield* jobs.start({
|
||||
id,
|
||||
type: "test",
|
||||
run: jobs
|
||||
.get(id)
|
||||
.pipe(
|
||||
Effect.flatMap((info) =>
|
||||
info?.status === "running"
|
||||
? Effect.succeed(`done-${index}`)
|
||||
: Effect.fail("job started before publish"),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
expect(yield* jobs.wait({ id: job.id })).toMatchObject({
|
||||
timedOut: false,
|
||||
info: { status: "completed", output: `done-${index}` },
|
||||
})
|
||||
})
|
||||
})
|
||||
}).pipe(Effect.provide(BackgroundJob.layer)),
|
||||
)
|
||||
|
||||
it.live("increments pending work before starting immediately settling extensions", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* BackgroundJob.Service
|
||||
|
||||
yield* Effect.forEach(Array.from({ length: 100 }), (_, index) =>
|
||||
Effect.gen(function* () {
|
||||
const first = yield* Deferred.make<void>()
|
||||
const job = yield* jobs.start({
|
||||
type: "test",
|
||||
run: Deferred.await(first).pipe(Effect.as(`first-${index}`)),
|
||||
})
|
||||
|
||||
expect(yield* jobs.extend({ id: job.id, run: Effect.succeed(`second-${index}`) })).toBe(true)
|
||||
expect((yield* jobs.get(job.id))?.status).toBe("running")
|
||||
|
||||
yield* Deferred.succeed(first, undefined)
|
||||
expect(yield* jobs.wait({ id: job.id })).toMatchObject({
|
||||
timedOut: false,
|
||||
info: { status: "completed", output: `second-${index}` },
|
||||
})
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.provide(BackgroundJob.layer)),
|
||||
)
|
||||
|
||||
it.live("interrupts live work without promising settlement after the owning process-local scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.make()
|
||||
const interrupted = yield* Deferred.make<void>()
|
||||
const jobs = yield* BackgroundJob.make.pipe(Scope.provide(scope))
|
||||
const job = yield* jobs.start({
|
||||
type: "test",
|
||||
run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))),
|
||||
})
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
|
||||
yield* Deferred.await(interrupted).pipe(Effect.timeout("1 second"))
|
||||
// The abandoned in-memory registry is not a durable observation channel.
|
||||
expect((yield* jobs.get(job.id))?.status).toBe("running")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -33,6 +33,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
|||
Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }),
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
|
|
@ -56,6 +57,14 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
|||
)
|
||||
|
||||
expect(sources).toEqual([
|
||||
new SkillV2.DirectorySource({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join("/repo/.opencode", "skill")),
|
||||
}),
|
||||
new SkillV2.DirectorySource({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join("/repo/.opencode", "skills")),
|
||||
}),
|
||||
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
|
||||
new SkillV2.DirectorySource({
|
||||
type: "directory",
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import { fileURLToPath } from "url"
|
||||
import path from "path"
|
||||
import { SqliteClient } from "@effect/sql-sqlite-bun"
|
||||
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { eq, inArray, sql } from "drizzle-orm"
|
||||
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
|
||||
import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage"
|
||||
import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths"
|
||||
import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/migration/20260603040000_session_message_projection_order"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
|
|
@ -15,6 +17,8 @@ import { SessionSchema } from "@opencode-ai/core/session/schema"
|
|||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata"
|
||||
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
|
||||
Effect.runPromise(
|
||||
|
|
@ -24,6 +28,15 @@ const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
|
|||
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
|
||||
|
||||
describe("DatabaseMigration", () => {
|
||||
test("serializes concurrent embedded initialization for one database path", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filename = path.join(tmp.path, "embedded.sqlite")
|
||||
const layers = [Database.layerFromPath(filename), Database.layerFromPath(filename)]
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.all(layers.map((layer) => Effect.scoped(Layer.build(layer))), { concurrency: "unbounded" }),
|
||||
)
|
||||
})
|
||||
if (process.platform === "linux") {
|
||||
test("declared schema has no ungenerated migrations", async () => {
|
||||
const result = await $`bun ${fileURLToPath(new URL("../script/migration.ts", import.meta.url))} --check`
|
||||
|
|
@ -43,11 +56,74 @@ describe("DatabaseMigration", () => {
|
|||
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
|
||||
name: "session",
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 25 })
|
||||
expect(
|
||||
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`),
|
||||
).toEqual({ name: "session_input" })
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 29 })
|
||||
expect(
|
||||
yield* db.all(
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
|
||||
),
|
||||
).toEqual([
|
||||
{ name: "event_aggregate_seq_idx" },
|
||||
{ name: "event_aggregate_type_seq_idx" },
|
||||
{ name: "session_input_session_pending_delivery_seq_idx" },
|
||||
{ name: "session_message_session_seq_idx" },
|
||||
{ name: "session_message_session_time_created_id_idx" },
|
||||
{ name: "session_message_session_type_seq_idx" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("backfills projected Session message order from durable event sequence", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, seq integer NOT NULL)`)
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, time_created integer NOT NULL, data text NOT NULL)`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`CREATE INDEX session_message_session_time_created_id_idx ON session_message (session_id, time_created, id)`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`CREATE INDEX session_message_session_type_time_created_id_idx ON session_message (session_id, type, time_created, id)`,
|
||||
)
|
||||
yield* db.run(sql`INSERT INTO event (id, seq) VALUES ('evt_z', 0), ('evt_a', 1)`)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session_message (id, session_id, type, time_created, data) VALUES ('evt_z', 'session', 'user', 0, '{}'), ('evt_a', 'session', 'user', 0, '{}')`,
|
||||
)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [sessionMessageProjectionOrderMigration])
|
||||
|
||||
expect(yield* db.all(sql`SELECT id, seq FROM session_message ORDER BY seq`)).toEqual([
|
||||
{ id: "evt_z", seq: 0 },
|
||||
{ id: "evt_a", seq: 1 },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("fails projected Session message order backfill without a durable event", async () => {
|
||||
await expect(
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, seq integer NOT NULL)`)
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, time_created integer NOT NULL, data text NOT NULL)`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session_message (id, session_id, type, time_created, data) VALUES ('evt_missing', 'session', 'user', 0, '{}')`,
|
||||
)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [sessionMessageProjectionOrderMigration])
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow("Cannot migrate session_message projections without matching durable events")
|
||||
})
|
||||
|
||||
test("runs session usage backfill in order with schema changes", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
|
|
|||
69
packages/core/test/effect/keyed-mutex.test.ts
Normal file
69
packages/core/test/effect/keyed-mutex.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber } from "effect"
|
||||
import { KeyedMutex } from "@opencode-ai/core/effect/keyed-mutex"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
describe("KeyedMutex", () => {
|
||||
it.effect("serializes effects with the same key", () =>
|
||||
Effect.gen(function* () {
|
||||
const mutex = yield* KeyedMutex.make<string>()
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
const secondStarted = yield* Deferred.make<void>()
|
||||
|
||||
const first = yield* mutex
|
||||
.withLock("shared")(Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))))
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
const second = yield* mutex.withLock("shared")(Deferred.succeed(secondStarted, undefined)).pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Deferred.isDone(secondStarted)).toBe(false)
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
expect(yield* mutex.size).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("allows different keys to proceed independently", () =>
|
||||
Effect.gen(function* () {
|
||||
const mutex = yield* KeyedMutex.make<string>()
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
const secondFinished = yield* Deferred.make<void>()
|
||||
|
||||
const first = yield* mutex
|
||||
.withLock("first")(Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))))
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
yield* mutex.withLock("second")(Deferred.succeed(secondFinished, undefined))
|
||||
expect(yield* Deferred.isDone(secondFinished)).toBe(true)
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Fiber.join(first)
|
||||
expect(yield* mutex.size).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes an interrupted waiter without dropping the holder lock", () =>
|
||||
Effect.gen(function* () {
|
||||
const mutex = yield* KeyedMutex.make<string>()
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
|
||||
const first = yield* mutex
|
||||
.withLock("shared")(Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))))
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
const interrupted = yield* mutex.withLock("shared")(Effect.void).pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
yield* Fiber.interrupt(interrupted)
|
||||
expect(yield* mutex.size).toBe(1)
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Fiber.join(first)
|
||||
expect(yield* mutex.size).toBe(0)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -1,17 +1,19 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { DateTime, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import { V2Schema } from "@opencode-ai/core/v2-schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("project"), workspaceID: "workspace" })),
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("project"), workspaceID: WorkspaceV2.ID.make("wrk_test") })),
|
||||
)
|
||||
const eventLayer = Layer.mergeAll(EventV2.defaultLayer, Database.defaultLayer)
|
||||
const it = testEffect(eventLayer.pipe(Layer.provideMerge(locationLayer)))
|
||||
|
|
@ -67,7 +69,30 @@ const VersionedMessage = EventV2.define({
|
|||
},
|
||||
})
|
||||
|
||||
const SyncTimestamp = EventV2.define({
|
||||
type: "test.timestamp",
|
||||
sync: {
|
||||
version: 1,
|
||||
aggregate: "id",
|
||||
},
|
||||
schema: {
|
||||
id: Schema.String,
|
||||
timestamp: V2Schema.DateTimeUtcFromMillis,
|
||||
},
|
||||
})
|
||||
|
||||
describe("EventV2", () => {
|
||||
it.effect("derives stable namespaced external IDs", () =>
|
||||
Effect.sync(() => {
|
||||
const input = { namespace: "opencord.agent-input", key: "input-1" }
|
||||
|
||||
expect(EventV2.ID.fromExternal(input)).toBe(EventV2.ID.fromExternal(input))
|
||||
expect(EventV2.ID.fromExternal(input)).toMatch(/^evt_[a-f0-9]{64}$/)
|
||||
expect(EventV2.ID.fromExternal({ ...input, namespace: "another-app" })).not.toBe(EventV2.ID.fromExternal(input))
|
||||
expect(EventV2.ID.fromExternal({ namespace: "a:b", key: "c" })).not.toBe(EventV2.ID.fromExternal({ namespace: "a", key: "b:c" }))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes events with the current location", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
|
|
@ -80,7 +105,7 @@ describe("EventV2", () => {
|
|||
expect(event.type).toBe("test.message")
|
||||
expect(event).not.toHaveProperty("version")
|
||||
expect(event.data).toEqual({ text: "hello" })
|
||||
expect(event.location).toEqual({ directory: AbsolutePath.make("project"), workspaceID: "workspace" })
|
||||
expect(event.location).toEqual({ directory: AbsolutePath.make("project"), workspaceID: WorkspaceV2.ID.make("wrk_test") })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -204,6 +229,24 @@ describe("EventV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("does not synchronize live-only events", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const synchronized = new Array<string>()
|
||||
const unsubscribe = yield* events.sync((event) =>
|
||||
Effect.sync(() => {
|
||||
synchronized.push(event.type)
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
|
||||
yield* events.publish(Message, { text: "live only" })
|
||||
yield* events.publish(SyncMessage, { id: "one", text: "durable" })
|
||||
|
||||
expect(synchronized).toEqual([SyncMessage.type])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("inserts sync event rows on publish", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
|
|
@ -243,6 +286,102 @@ describe("EventV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("replays durable aggregate events after a cursor and tails new events", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const aggregateID = EventV2.ID.create()
|
||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" })
|
||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "one" })
|
||||
const fiber = yield* events.aggregateEvents({ aggregateID, after: EventV2.Cursor.make(0) }).pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "two" })
|
||||
|
||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual([
|
||||
[EventV2.Cursor.make(1), { id: aggregateID, text: "one" }],
|
||||
[EventV2.Cursor.make(2), { id: aggregateID, text: "two" }],
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("catches durable aggregate events published during replay handoff", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const aggregateID = EventV2.ID.create()
|
||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" })
|
||||
const fiber = yield* events.aggregateEvents({ aggregateID }).pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
||||
|
||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "one" })
|
||||
|
||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, (event.event.data as { text: string }).text])).toEqual([
|
||||
[EventV2.Cursor.make(0), "zero"],
|
||||
[EventV2.Cursor.make(1), "one"],
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains a durable wake committed while historical replay is paused", () =>
|
||||
Effect.gen(function* () {
|
||||
const readStarted = yield* Deferred.make<void>()
|
||||
const continueRead = yield* Deferred.make<void>()
|
||||
let pause = true
|
||||
const database = Database.layerFromPath(":memory:")
|
||||
const eventLayer = EventV2.layerWith({
|
||||
beforeAggregateRead: () =>
|
||||
pause
|
||||
? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
|
||||
: Effect.void,
|
||||
}).pipe(Layer.provide(database))
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const aggregateID = EventV2.ID.create()
|
||||
const fiber = yield* events.aggregateEvents({ aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Deferred.await(readStarted)
|
||||
|
||||
pause = false
|
||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "during handoff" })
|
||||
yield* Deferred.succeed(continueRead, undefined)
|
||||
|
||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual([
|
||||
[EventV2.Cursor.make(0), { id: aggregateID, text: "during handoff" }],
|
||||
])
|
||||
}).pipe(Effect.provide(Layer.mergeAll(database, eventLayer)))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("coalesces durable aggregate wakes while draining every committed event", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const aggregateID = EventV2.ID.create()
|
||||
const count = 64
|
||||
const fiber = yield* events.aggregateEvents({ aggregateID }).pipe(Stream.take(count), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
for (let index = 0; index < count; index++) {
|
||||
yield* events.publish(SyncMessage, { id: aggregateID, text: String(index) })
|
||||
}
|
||||
|
||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual(
|
||||
Array.from({ length: count }, (_, index) => [EventV2.Cursor.make(index), { id: aggregateID, text: String(index) }]),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits live-only events from durable aggregate streams", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const aggregateID = EventV2.ID.create()
|
||||
const fiber = yield* events.aggregateEvents({ aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* events.publish(Message, { text: "live only" })
|
||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "durable" })
|
||||
|
||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.event.type)).toEqual([SyncMessage.type])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses custom sync aggregate field", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
|
|
@ -311,6 +450,49 @@ describe("EventV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("replay rejects an envelope aggregate that differs from its payload without mutating the payload aggregate", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
const envelopeAggregateID = EventV2.ID.create()
|
||||
const payloadAggregateID = EventV2.ID.create()
|
||||
const received = new Array<EventV2.Payload>()
|
||||
yield* events.publish(SyncMessage, { id: payloadAggregateID, text: "seed" })
|
||||
yield* events.project(SyncMessage, (event) =>
|
||||
Effect.sync(() => {
|
||||
received.push(event)
|
||||
}),
|
||||
)
|
||||
|
||||
const exit = yield* events
|
||||
.replay({
|
||||
id: EventV2.ID.create(),
|
||||
type: EventV2.versionedType(SyncMessage.type, 1),
|
||||
seq: 1,
|
||||
aggregateID: envelopeAggregateID,
|
||||
data: { id: payloadAggregateID, text: "replayed" },
|
||||
})
|
||||
.pipe(Effect.exit)
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, payloadAggregateID))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const sequence = yield* db
|
||||
.select({ seq: EventSequenceTable.seq })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, payloadAggregateID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
expect(String(exit)).toContain("Aggregate mismatch")
|
||||
expect(received).toHaveLength(0)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(sequence).toEqual({ seq: 0 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replay defects on sequence mismatch", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
|
|
@ -337,6 +519,29 @@ describe("EventV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("replay decodes synchronized transformed values before projection", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const aggregateID = EventV2.ID.create()
|
||||
const received = new Array<typeof SyncTimestamp.Type>()
|
||||
yield* events.project(SyncTimestamp, (event) =>
|
||||
Effect.sync(() => {
|
||||
received.push(event)
|
||||
}),
|
||||
)
|
||||
|
||||
yield* events.replay({
|
||||
id: EventV2.ID.create(),
|
||||
type: EventV2.versionedType(SyncTimestamp.type, 1),
|
||||
seq: 0,
|
||||
aggregateID,
|
||||
data: { id: aggregateID, timestamp: 0 },
|
||||
})
|
||||
|
||||
expect(received[0]?.data.timestamp).toEqual(DateTime.makeUnsafe(0))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replay defects on unknown event type", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
|
|
@ -485,11 +690,109 @@ describe("EventV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("replay claims an existing unowned sequence before fencing a different owner", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
const aggregateID = EventV2.ID.create()
|
||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "local" })
|
||||
|
||||
yield* events.replay(
|
||||
{
|
||||
id: EventV2.ID.create(),
|
||||
type: EventV2.versionedType(SyncMessage.type, 1),
|
||||
seq: 1,
|
||||
aggregateID,
|
||||
data: { id: aggregateID, text: "claimed" },
|
||||
},
|
||||
{ ownerID: "owner-1" },
|
||||
)
|
||||
yield* events.replay(
|
||||
{
|
||||
id: EventV2.ID.create(),
|
||||
type: EventV2.versionedType(SyncMessage.type, 1),
|
||||
seq: 2,
|
||||
aggregateID,
|
||||
data: { id: aggregateID, text: "fenced" },
|
||||
},
|
||||
{ ownerID: "owner-2" },
|
||||
)
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, aggregateID))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const sequence = yield* db
|
||||
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
expect(rows.map((row) => row.seq)).toEqual([0, 1])
|
||||
expect(sequence).toEqual({ seq: 1, ownerID: "owner-1" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("strict replay rejects an owner conflict instead of silently skipping it", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const aggregateID = EventV2.ID.create()
|
||||
yield* events.replay(
|
||||
{
|
||||
id: EventV2.ID.create(),
|
||||
type: EventV2.versionedType(SyncMessage.type, 1),
|
||||
seq: 0,
|
||||
aggregateID,
|
||||
data: { id: aggregateID, text: "claimed" },
|
||||
},
|
||||
{ ownerID: "owner-1" },
|
||||
)
|
||||
|
||||
const exit = yield* events.replay(
|
||||
{
|
||||
id: EventV2.ID.create(),
|
||||
type: EventV2.versionedType(SyncMessage.type, 1),
|
||||
seq: 1,
|
||||
aggregateID,
|
||||
data: { id: aggregateID, text: "conflict" },
|
||||
},
|
||||
{ ownerID: "owner-2", strictOwner: true },
|
||||
).pipe(Effect.exit)
|
||||
|
||||
expect(String(exit)).toContain("Replay owner mismatch")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes accepted replay with its durable sequence and suppresses stale replay", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const received = new Array<EventV2.Payload>()
|
||||
const aggregateID = EventV2.ID.create()
|
||||
yield* events.listen((event) => Effect.sync(() => received.push(event)))
|
||||
const replayed = {
|
||||
id: EventV2.ID.create(),
|
||||
type: EventV2.versionedType(SyncMessage.type, 1),
|
||||
seq: 0,
|
||||
aggregateID,
|
||||
data: { id: aggregateID, text: "replayed" },
|
||||
}
|
||||
|
||||
yield* events.replay(replayed, { publish: true })
|
||||
yield* events.replay(replayed, { publish: true })
|
||||
|
||||
expect(received).toMatchObject([{ id: replayed.id, seq: 0, data: replayed.data }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replay from a different owner leaves claimed sequence unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
const aggregateID = EventV2.ID.create()
|
||||
const received = new Array<EventV2.Payload>()
|
||||
yield* events.listen((event) => Effect.sync(() => received.push(event)))
|
||||
|
||||
yield* events.replay(
|
||||
{
|
||||
|
|
@ -509,7 +812,7 @@ describe("EventV2", () => {
|
|||
aggregateID,
|
||||
data: { id: aggregateID, text: "ignored" },
|
||||
},
|
||||
{ ownerID: "owner-2" },
|
||||
{ ownerID: "owner-2", publish: true },
|
||||
)
|
||||
const rows = yield* db
|
||||
.select()
|
||||
|
|
@ -526,6 +829,7 @@ describe("EventV2", () => {
|
|||
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(sequence).toEqual({ seq: 0, ownerID: "owner-1" })
|
||||
expect(received).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
315
packages/core/test/file-mutation.test.ts
Normal file
315
packages/core/test/file-mutation.test.ts
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer } from "effect"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
function provide(directory: string, filesystem = FSUtil.defaultLayer) {
|
||||
const activeLocation = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
)
|
||||
const planning = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
|
||||
const commits = FileMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(planning))
|
||||
return Effect.provide(Layer.mergeAll(planning, commits))
|
||||
}
|
||||
|
||||
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
||||
}
|
||||
|
||||
describe("FileMutation", () => {
|
||||
it.live("writes an existing internal file and returns a stable result", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "hello.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "before"))
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
|
||||
|
||||
expect(yield* (yield* FileMutation.Service).write({ plan, content: "after" })).toEqual({
|
||||
operation: "write",
|
||||
target: plan.target.canonical,
|
||||
resource: "hello.txt",
|
||||
existed: true,
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("after")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("writes a prospective internal file and creates parent directories", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "nested", "hello.txt") })
|
||||
const result = yield* (yield* FileMutation.Service).write({ plan, content: "hello" })
|
||||
|
||||
expect(result).toEqual({
|
||||
operation: "write",
|
||||
target: plan.target.canonical,
|
||||
resource: "src/nested/hello.txt",
|
||||
existed: false,
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(result.target, "utf8"))).toBe("hello")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves exactly one BOM for text writes and normalizes created text", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const preservedPath = path.join(directory, "preserved.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(preservedPath, "\uFEFFbefore"))
|
||||
const preserved = yield* (yield* LocationMutation.Service).resolve({ path: "preserved.txt" })
|
||||
const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
|
||||
const files = yield* FileMutation.Service
|
||||
|
||||
yield* files.writeTextPreservingBom({ plan: preserved, content: "\uFEFFafter" })
|
||||
yield* files.writeTextPreservingBom({ plan: created, content: "\uFEFF\uFEFF\uFEFFcreated" })
|
||||
|
||||
expect(yield* Effect.promise(() => fs.readFile(preservedPath, "utf8"))).toBe("\uFEFFafter")
|
||||
expect(yield* Effect.promise(() => fs.readFile(created.target.canonical, "utf8"))).toBe("\uFEFFcreated")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects create when a prospective target appears after planning", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "appeared.txt")
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: "appeared.txt" })
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "winner"))
|
||||
|
||||
expect(yield* (yield* FileMutation.Service).create({ plan, content: "replacement" }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "LocationMutation.RevalidationError",
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("winner")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("removes an existing internal file", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "remove.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "remove"))
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: "remove.txt" })
|
||||
const result = yield* (yield* FileMutation.Service).remove({ plan })
|
||||
|
||||
expect(result).toEqual({ operation: "remove", target: plan.target.canonical, resource: "remove.txt", existed: true })
|
||||
expect(yield* Effect.promise(() => fs.stat(targetPath).then(() => true, () => false))).toBe(false)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("writes an explicitly planned external target", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "external.txt")
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const result = yield* (yield* FileMutation.Service).write({ plan, content: "external" })
|
||||
|
||||
expect(result).toEqual({ operation: "write", target: plan.target.canonical, resource: plan.target.resource, existed: false })
|
||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("external")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("removes an explicitly planned external target", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "external.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "external"))
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const result = yield* (yield* FileMutation.Service).remove({ plan })
|
||||
|
||||
expect(result).toEqual({ operation: "remove", target: plan.target.canonical, resource: plan.target.resource, existed: true })
|
||||
expect(yield* Effect.promise(() => fs.stat(targetPath).then(() => true, () => false))).toBe(false)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("propagates revalidation rejection after an ancestor swap", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
const parent = path.join(directory, "parent")
|
||||
yield* Effect.promise(() => fs.mkdir(parent))
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: path.join("parent", "new.txt") })
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.rmdir(parent)
|
||||
await fs.symlink(outside, parent)
|
||||
})
|
||||
|
||||
expect(yield* (yield* FileMutation.Service).write({ plan, content: "escape" }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "LocationMutation.RevalidationError",
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.stat(path.join(outside, "new.txt")).then(() => true, () => false))).toBe(false)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("serializes concurrent writes to the same canonical target", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "shared.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "initial"))
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
const secondStarted = yield* Deferred.make<void>()
|
||||
let writes = 0
|
||||
const filesystem = instrumentWrites((write) =>
|
||||
Effect.gen(function* () {
|
||||
writes++
|
||||
if (writes === 1) {
|
||||
yield* Deferred.succeed(firstStarted, undefined)
|
||||
yield* Deferred.await(releaseFirst)
|
||||
} else {
|
||||
yield* Deferred.succeed(secondStarted, undefined)
|
||||
}
|
||||
yield* write
|
||||
}),
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const firstPlan = yield* mutation.resolve({ path: "shared.txt" })
|
||||
const secondPlan = yield* mutation.resolve({ path: "shared.txt" })
|
||||
const first = yield* files.write({ plan: firstPlan, content: "first" }).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
const second = yield* files.write({ plan: secondPlan, content: "second" }).pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Deferred.isDone(secondStarted)).toBe(false)
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Deferred.await(secondStarted)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("second")
|
||||
}).pipe(provide(directory, filesystem))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("allows only one concurrent conditional write based on the same bytes", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "shared.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "initial"))
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
let writes = 0
|
||||
const filesystem = instrumentWrites((write) =>
|
||||
Effect.gen(function* () {
|
||||
writes++
|
||||
if (writes === 1) {
|
||||
yield* Deferred.succeed(firstStarted, undefined)
|
||||
yield* Deferred.await(releaseFirst)
|
||||
}
|
||||
yield* write
|
||||
}),
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const plan = yield* mutation.resolve({ path: "shared.txt" })
|
||||
const expected = new TextEncoder().encode("initial")
|
||||
const first = yield* files.writeIfUnchanged({ plan, expected, content: "first" }).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
const second = yield* files.writeIfUnchanged({ plan, expected, content: "second" }).pipe(Effect.flip, Effect.forkChild)
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Fiber.join(first)
|
||||
expect(yield* Fiber.join(second)).toMatchObject({ _tag: "FileMutation.StaleContentError" })
|
||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("first")
|
||||
expect(writes).toBe(1)
|
||||
}).pipe(provide(directory, filesystem))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects a conditional write when target content is already stale", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "stale.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "current"))
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: "stale.txt" })
|
||||
|
||||
expect(
|
||||
yield* (yield* FileMutation.Service)
|
||||
.writeIfUnchanged({ plan, expected: new TextEncoder().encode("older"), content: "replacement" })
|
||||
.pipe(Effect.flip),
|
||||
).toMatchObject({ _tag: "FileMutation.StaleContentError", path: plan.target.canonical })
|
||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("current")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("allows distinct canonical targets to proceed independently", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
const secondFinished = yield* Deferred.make<void>()
|
||||
const secondPath = path.join(directory, "second.txt")
|
||||
let writes = 0
|
||||
const filesystem = instrumentWrites((write) =>
|
||||
++writes === 1
|
||||
? Deferred.succeed(firstStarted, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(releaseFirst)),
|
||||
Effect.andThen(write),
|
||||
)
|
||||
: write.pipe(Effect.andThen(Deferred.succeed(secondFinished, undefined))),
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const firstPlan = yield* mutation.resolve({ path: "first.txt" })
|
||||
const secondPlan = yield* mutation.resolve({ path: "second.txt" })
|
||||
const first = yield* files.write({ plan: firstPlan, content: "first" }).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
const second = yield* files.write({ plan: secondPlan, content: "second" }).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(secondFinished)
|
||||
expect(yield* Effect.promise(() => fs.readFile(secondPath, "utf8"))).toBe("second")
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
}).pipe(provide(directory, filesystem))
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function instrumentWrites(
|
||||
run: (write: Effect.Effect<void, FSUtil.Error>, target: string) => Effect.Effect<void, FSUtil.Error>,
|
||||
) {
|
||||
return Layer.effect(
|
||||
FSUtil.Service,
|
||||
Effect.gen(function* () {
|
||||
const filesystem = yield* FSUtil.Service
|
||||
return FSUtil.Service.of({
|
||||
...filesystem,
|
||||
writeWithDirs: (target, content, mode) => run(filesystem.writeWithDirs(target, content, mode), target),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(FSUtil.defaultLayer))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue