mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 02:26:24 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6b10158e3 | ||
|
|
e867a21ea5 | ||
|
|
64c0411edb |
@@ -48,7 +48,6 @@ jobs:
|
||||
node-version: "24"
|
||||
|
||||
- name: Setup Bun
|
||||
id: setup-bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Test Effect simplification rules
|
||||
@@ -86,7 +85,7 @@ jobs:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
- name: Verify compiled service lifecycle
|
||||
if: always() && steps.setup-bun.outcome == 'success'
|
||||
if: always()
|
||||
timeout-minutes: 10
|
||||
working-directory: packages/cli
|
||||
env:
|
||||
@@ -102,7 +101,7 @@ jobs:
|
||||
node-version: "26.4.0"
|
||||
|
||||
- name: Verify Node build
|
||||
if: always() && steps.setup-bun.outcome == 'success'
|
||||
if: always()
|
||||
timeout-minutes: 15
|
||||
working-directory: packages/cli
|
||||
env:
|
||||
|
||||
@@ -671,6 +671,7 @@
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/httpapi-codegen": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "3fb67508-0196-4bae-b2bd-c08ece7583fd",
|
||||
"prevIds": ["dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936"],
|
||||
"id": "be60f352-8da1-40e1-8d70-dc41121cfbc5",
|
||||
"prevIds": ["3fb67508-0196-4bae-b2bd-c08ece7583fd"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "account_state",
|
||||
@@ -1442,7 +1442,7 @@
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
|
||||
+2
@@ -44,6 +44,7 @@ import m41 from "./migration/20260811161259_execution_claim_attempts.js"
|
||||
import m42 from "./migration/20260812181746_session_inbox.js"
|
||||
import m43 from "./migration/20260812213948_worktree.js"
|
||||
import m44 from "./migration/20260819222447_session_viewed_state.js"
|
||||
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
|
||||
|
||||
export const migrations = [
|
||||
m00,
|
||||
@@ -91,4 +92,5 @@ export const migrations = [
|
||||
m42,
|
||||
m43,
|
||||
m44,
|
||||
m45,
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260823191254_nullable_workspace_binding",
|
||||
foreignKeys: false,
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`__new_workspace\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`provider\` text NOT NULL,
|
||||
\`binding\` text,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`last_used_at\` integer NOT NULL
|
||||
);
|
||||
`)
|
||||
yield* tx.run(
|
||||
`INSERT INTO \`__new_workspace\`(\`id\`, \`provider\`, \`binding\`, \`created_at\`, \`last_used_at\`) SELECT \`id\`, \`provider\`, \`binding\`, \`created_at\`, \`last_used_at\` FROM \`workspace\`;`,
|
||||
)
|
||||
yield* tx.run(`DROP TABLE \`workspace\`;`)
|
||||
yield* tx.run(`ALTER TABLE \`__new_workspace\` RENAME TO \`workspace\`;`)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default migration
|
||||
@@ -223,7 +223,7 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
CREATE TABLE \`workspace\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`provider\` text NOT NULL,
|
||||
\`binding\` text NOT NULL,
|
||||
\`binding\` text,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`last_used_at\` integer NOT NULL
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as FileSystemSearch from "./search.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import path from "path"
|
||||
import { Clock, Context, Duration, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { Clock, Context, Deferred, Duration, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { Fff } from "#fff"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { FileSystem } from "../filesystem.js"
|
||||
@@ -61,7 +61,7 @@ export const ripgrepLayer = Layer.effect(
|
||||
let index = emptyIndex()
|
||||
let initialized = false
|
||||
let settledAt = Number.NEGATIVE_INFINITY
|
||||
let refreshing = false
|
||||
let refreshing: Deferred.Deferred<void> | undefined
|
||||
const scan = Effect.gen(function* () {
|
||||
const next = emptyIndex()
|
||||
const previous = index
|
||||
@@ -84,21 +84,25 @@ export const ripgrepLayer = Layer.effect(
|
||||
})
|
||||
index = next
|
||||
initialized = true
|
||||
}).pipe(
|
||||
Effect.orDie,
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
settledAt = clock.currentTimeMillisUnsafe()
|
||||
refreshing = false
|
||||
}),
|
||||
),
|
||||
)
|
||||
const refresh = Effect.sync(() => {
|
||||
if (refreshing || clock.currentTimeMillisUnsafe() < settledAt + REFRESH_INTERVAL) return
|
||||
refreshing = true
|
||||
return scan
|
||||
}).pipe(Effect.flatMap((effect) => (effect ? effect.pipe(Effect.forkIn(scope)) : Effect.void)))
|
||||
yield* refresh
|
||||
}).pipe(Effect.orDie)
|
||||
const refresh = Effect.suspend(() => {
|
||||
if (refreshing) return initialized ? Effect.void : Deferred.await(refreshing)
|
||||
if (initialized && clock.currentTimeMillisUnsafe() < settledAt + REFRESH_INTERVAL) return Effect.void
|
||||
|
||||
const attempt = Deferred.makeUnsafe<void>()
|
||||
refreshing = attempt
|
||||
return scan.pipe(
|
||||
Effect.onExit((exit) =>
|
||||
Effect.sync(() => {
|
||||
settledAt = clock.currentTimeMillisUnsafe()
|
||||
if (refreshing === attempt) refreshing = undefined
|
||||
Deferred.doneUnsafe(attempt, exit)
|
||||
}),
|
||||
),
|
||||
Effect.forkIn(scope),
|
||||
Effect.flatMap(() => (initialized ? Effect.void : Deferred.await(attempt))),
|
||||
)
|
||||
})
|
||||
return Service.of({
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Environment } from "./environment/index.js"
|
||||
import { Location } from "./location.js"
|
||||
import { Project } from "./project.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
@@ -52,7 +53,7 @@ export interface Interface {
|
||||
* from the Location. Paths outside it require separate `external_directory`
|
||||
* approval. This does not approve the mutation.
|
||||
*/
|
||||
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
|
||||
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, Environment.Failed>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
|
||||
@@ -62,7 +63,7 @@ const slash = (value: string) => value.replaceAll("\\", "/")
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const environment = yield* Environment.Service
|
||||
const location = yield* Location.Service
|
||||
|
||||
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
|
||||
@@ -73,14 +74,14 @@ const layer = Layer.effect(
|
||||
resource: slash(path.relative(location.directory, absolute) || "."),
|
||||
} satisfies Target
|
||||
}
|
||||
// Probe through the Location environment so workspace-backed Locations classify
|
||||
// the target against the sandbox filesystem rather than the server host.
|
||||
const type =
|
||||
input.kind === "directory"
|
||||
? "Directory"
|
||||
: input.kind === "file"
|
||||
? "File"
|
||||
: (yield* fs.stat(absolute).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined)))
|
||||
?.type
|
||||
const externalDirectory = type === "Directory" ? absolute : path.dirname(absolute)
|
||||
input.kind ??
|
||||
(yield* Environment.typeFollowing(environment.files, absolute).pipe(
|
||||
Effect.catchTag("Environment.NotFound", () => Effect.undefined),
|
||||
))
|
||||
const externalDirectory = type === "directory" ? absolute : path.dirname(absolute)
|
||||
const externalResource = slash(path.join(externalDirectory, "*"))
|
||||
return {
|
||||
absolute,
|
||||
@@ -90,7 +91,10 @@ const layer = Layer.effect(
|
||||
directory: externalDirectory,
|
||||
resource: externalResource,
|
||||
save: slash(
|
||||
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
|
||||
path.join(
|
||||
(yield* Project.root(environment.files, AbsolutePath.make(externalDirectory))) ?? externalDirectory,
|
||||
"*",
|
||||
),
|
||||
),
|
||||
},
|
||||
} satisfies Target
|
||||
@@ -103,5 +107,5 @@ const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer.pipe(Layer.orDie),
|
||||
deps: [FSUtil.node, Location.node],
|
||||
deps: [Environment.node, Location.node],
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Environment } from "../environment/index.js"
|
||||
import { Permission } from "../permission.js"
|
||||
import { SessionEvent } from "../session/event.js"
|
||||
|
||||
@@ -28,12 +27,9 @@ You are NO LONGER in Plan mode. The previous Plan restrictions no longer apply.
|
||||
export const Plugin = define({
|
||||
id: "opencode.plan",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const environment = yield* Environment.Service
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.home, ".opencode", "plan")
|
||||
const enterReminder = enter(directory)
|
||||
yield* environment.files.mkdir(directory).pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.agent.transform((draft) => {
|
||||
draft.update(plan, (item) => {
|
||||
item.name = Agent.Name.make("Plan")
|
||||
|
||||
@@ -7,6 +7,7 @@ import path from "path"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Database } from "./database/database.js"
|
||||
import type { Files } from "./environment/index.js"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Git } from "./git.js"
|
||||
@@ -38,12 +39,25 @@ export interface Resolved {
|
||||
}
|
||||
|
||||
// Keep this filesystem-only; permission checks use it and should not execute VCS commands.
|
||||
export const root = Effect.fn("Project.root")(function* (fs: FSUtil.Interface, input: AbsolutePath) {
|
||||
return yield* fs.up({ targets: [".git", ".hg"], start: input, mode: "first" }).pipe(
|
||||
Effect.map((matches) => (matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined)),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
})
|
||||
// Probes go through the caller's Environment files so workspace-backed Locations walk the
|
||||
// Location filesystem rather than the server host. Any probe failure yields no root.
|
||||
export const root = Effect.fn("Project.root")((files: Files, input: AbsolutePath) =>
|
||||
Effect.gen(function* () {
|
||||
let current: string = input
|
||||
while (true) {
|
||||
for (const target of [".git", ".hg"]) {
|
||||
const found = yield* files.stat(path.join(current, target)).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catchTag("Environment.NotFound", () => Effect.succeed(false)),
|
||||
)
|
||||
if (found) return AbsolutePath.make(current)
|
||||
}
|
||||
const parent = path.dirname(current)
|
||||
if (parent === current) return undefined
|
||||
current = parent
|
||||
}
|
||||
}).pipe(Effect.orElseSucceed(() => undefined)),
|
||||
)
|
||||
|
||||
export interface Interface {
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
|
||||
|
||||
+121
-38
@@ -3,7 +3,7 @@ export * as Workspace from "./workspace.js"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Clock, Context, Duration, Effect, Exit, Layer, Ref, Schedule, Schema, Scope } from "effect"
|
||||
import { Clock, Context, Deferred, Duration, Effect, Exit, FiberSet, Layer, Ref, Schedule, Schema, Scope } from "effect"
|
||||
import { systemError } from "effect/PlatformError"
|
||||
import { make } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import type { Driver as EnvironmentDriver } from "./environment/driver.js"
|
||||
@@ -26,7 +26,12 @@ export class Info extends Schema.Class<Info>("Workspace.Info")({
|
||||
export class NotFound extends Schema.TaggedError<NotFound>()("Workspace.NotFound", { workspaceID: ID }) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly create: (provider: string) => Effect.Effect<Info, WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound>
|
||||
/** Instantly commits a logical workspace ID. No provider work happens here. */
|
||||
readonly create: (provider: string) => Effect.Effect<ID, WorkspaceDriver.ProviderNotFound>
|
||||
/** Starts or joins the shared attempt that makes the backing resource real, then returns it. */
|
||||
readonly provision: (
|
||||
workspaceID: ID,
|
||||
) => Effect.Effect<Info, NotFound | WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound>
|
||||
readonly connect: (
|
||||
workspaceID: ID,
|
||||
) => Effect.Effect<EnvironmentDriver, NotFound | WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound>
|
||||
@@ -51,6 +56,8 @@ interface Connection {
|
||||
readonly scope: Scope.Closeable
|
||||
}
|
||||
|
||||
type ReadinessError = NotFound | WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound
|
||||
|
||||
export const configured = (options: Options = {}) =>
|
||||
makeGlobalNode({
|
||||
service: Service,
|
||||
@@ -66,7 +73,10 @@ const layer = (options: Options) =>
|
||||
const registry = yield* WorkspaceDriver.RegistryService
|
||||
const lifetime = yield* Scope.Scope
|
||||
const connections = new Map<ID, Connection>()
|
||||
// Destroy cancels the racing provision body by settling the deferred.
|
||||
const attempts = new Map<ID, Deferred.Deferred<Info, ReadinessError>>()
|
||||
const locks = KeyedMutex.makeUnsafe<ID>()
|
||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
const idleThreshold = Duration.toMillis(options.idleThreshold ?? Duration.minutes(20))
|
||||
|
||||
const load = Effect.fn("Workspace.load")(function* (workspaceID: ID) {
|
||||
@@ -80,29 +90,75 @@ const layer = (options: Options) =>
|
||||
return row
|
||||
})
|
||||
|
||||
const saveBinding = (workspaceID: ID, binding: WorkspaceDriver.Binding) =>
|
||||
db.update(WorkspaceTable).set({ binding }).where(eq(WorkspaceTable.id, workspaceID)).run().pipe(Effect.orDie)
|
||||
|
||||
const info = (row: typeof WorkspaceTable.$inferSelect, binding: WorkspaceDriver.Binding) =>
|
||||
new Info({
|
||||
id: row.id,
|
||||
provider: row.provider,
|
||||
binding,
|
||||
createdAt: row.created_at,
|
||||
lastUsedAt: row.last_used_at,
|
||||
})
|
||||
|
||||
const provision = Effect.fn("Workspace.provision")((workspaceID: ID) =>
|
||||
Effect.suspend(() => {
|
||||
const existing = attempts.get(workspaceID)
|
||||
if (existing) return Deferred.await(existing)
|
||||
|
||||
const attempt = Deferred.makeUnsafe<Info, ReadinessError>()
|
||||
attempts.set(workspaceID, attempt)
|
||||
fork(
|
||||
locks
|
||||
.withLock(workspaceID)(
|
||||
Effect.gen(function* () {
|
||||
const row = yield* load(workspaceID)
|
||||
if (row.binding) return info(row, row.binding)
|
||||
const driver = yield* registry.get(row.provider)
|
||||
const result = yield* driver.create({ workspaceID })
|
||||
yield* saveBinding(workspaceID, result.binding)
|
||||
return info(row, result.binding)
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.raceFirst(Deferred.await(attempt)),
|
||||
Effect.onExit((exit) =>
|
||||
Effect.sync(() => {
|
||||
if (attempts.get(workspaceID) === attempt) attempts.delete(workspaceID)
|
||||
Deferred.doneUnsafe(attempt, exit)
|
||||
}),
|
||||
),
|
||||
Effect.exit,
|
||||
Effect.asVoid,
|
||||
),
|
||||
)
|
||||
return Deferred.await(attempt)
|
||||
}),
|
||||
)
|
||||
|
||||
const open = Effect.fn("Workspace.open")(function* (workspaceID: ID) {
|
||||
const existing = connections.get(workspaceID)
|
||||
if (existing) return existing
|
||||
|
||||
const row = yield* load(workspaceID)
|
||||
// Bindings are persisted before provision resolves and never nulled; a raced
|
||||
// destroy deletes the whole row and surfaces as NotFound from load above.
|
||||
if (!row.binding) return yield* Effect.die(`workspace ${workspaceID} has no binding after provision`)
|
||||
const driver = yield* registry.get(row.provider)
|
||||
const saveBinding = (value: WorkspaceDriver.Binding) =>
|
||||
db
|
||||
.update(WorkspaceTable)
|
||||
.set({ binding: value })
|
||||
.where(eq(WorkspaceTable.id, workspaceID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const persistBinding = (binding: WorkspaceDriver.Binding) => saveBinding(workspaceID, binding)
|
||||
const scope = yield* Scope.fork(lifetime)
|
||||
const environment = yield* driver.connect({ workspaceID, binding: row.binding, saveBinding }).pipe(
|
||||
Effect.provideService(Scope.Scope, scope),
|
||||
Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))),
|
||||
)
|
||||
const environment = yield* driver
|
||||
.connect({ workspaceID, binding: row.binding, saveBinding: persistBinding })
|
||||
.pipe(
|
||||
Effect.provideService(Scope.Scope, scope),
|
||||
Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))),
|
||||
)
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const connection: Connection = {
|
||||
driver,
|
||||
environment,
|
||||
saveBinding,
|
||||
saveBinding: persistBinding,
|
||||
lastActivity: yield* Ref.make(now),
|
||||
active: yield* Ref.make(0),
|
||||
scope,
|
||||
@@ -129,6 +185,7 @@ const layer = (options: Options) =>
|
||||
const lastActivity = yield* Ref.get(connection.lastActivity)
|
||||
if (now - lastActivity < idleThreshold) return
|
||||
const row = yield* load(workspaceID)
|
||||
if (!row.binding) return
|
||||
// Deliberate: a racing spawn blocks, then wakes cleanly. Unlocking mid-suspend could reattach a sandbox being terminated.
|
||||
yield* connection.driver.suspendForIdle({
|
||||
workspaceID,
|
||||
@@ -151,37 +208,41 @@ const layer = (options: Options) =>
|
||||
|
||||
return Service.of({
|
||||
create: Effect.fn("Workspace.create")(function* (provider) {
|
||||
const driver = yield* registry.get(provider)
|
||||
yield* registry.get(provider)
|
||||
const workspaceID = ID.create()
|
||||
const result = yield* driver.create({ workspaceID })
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
yield* db
|
||||
.insert(WorkspaceTable)
|
||||
.values({ id: workspaceID, provider, binding: result.binding, created_at: now, last_used_at: now })
|
||||
.values({ id: workspaceID, provider, binding: null, created_at: now, last_used_at: now })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return new Info({ id: workspaceID, provider, binding: result.binding, createdAt: now, lastUsedAt: now })
|
||||
return workspaceID
|
||||
}),
|
||||
provision,
|
||||
connect: Effect.fn("Workspace.connect")(function* (workspaceID) {
|
||||
const spawner = make((command) =>
|
||||
Effect.acquireRelease(
|
||||
locks.withLock(workspaceID)(
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* open(workspaceID).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
systemError({
|
||||
_tag: "Unknown",
|
||||
module: "Workspace",
|
||||
method: "spawn",
|
||||
description: `Failed to wake workspace ${workspaceID}`,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* Ref.set(connection.lastActivity, yield* Clock.currentTimeMillis)
|
||||
yield* Ref.update(connection.active, (active) => active + 1)
|
||||
return connection
|
||||
}),
|
||||
// A live connection implies the binding is already persisted, so skip the provision hop.
|
||||
Effect.suspend(() => (connections.has(workspaceID) ? Effect.void : provision(workspaceID))).pipe(
|
||||
Effect.andThen(
|
||||
locks.withLock(workspaceID)(
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* open(workspaceID)
|
||||
yield* Ref.set(connection.lastActivity, yield* Clock.currentTimeMillis)
|
||||
yield* Ref.update(connection.active, (active) => active + 1)
|
||||
return connection
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.mapError((cause) =>
|
||||
systemError({
|
||||
_tag: "Unknown",
|
||||
module: "Workspace",
|
||||
method: "spawn",
|
||||
description: `Failed to wake workspace ${workspaceID}`,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
),
|
||||
(connection) =>
|
||||
locks.withLock(workspaceID)(
|
||||
@@ -196,14 +257,32 @@ const layer = (options: Options) =>
|
||||
return { spawner }
|
||||
}),
|
||||
destroy: Effect.fn("Workspace.destroy")(function* (workspaceID) {
|
||||
// Settling the shared attempt cancels its racing provision body and fails
|
||||
// waiters with NotFound before teardown commits. Accepted tradeoffs: if the
|
||||
// locked teardown below fails, those waiters saw NotFound for a workspace
|
||||
// that still exists (the next provision retries it), and a provision racing
|
||||
// this window may briefly succeed before teardown destroys its fresh binding.
|
||||
const attempt = attempts.get(workspaceID)
|
||||
if (attempt) {
|
||||
attempts.delete(workspaceID)
|
||||
Deferred.doneUnsafe(attempt, Exit.fail(new NotFound({ workspaceID })))
|
||||
}
|
||||
yield* locks.withLock(workspaceID)(
|
||||
Effect.gen(function* () {
|
||||
const row = yield* load(workspaceID)
|
||||
const connection = connections.get(workspaceID)
|
||||
connections.delete(workspaceID)
|
||||
if (connection) yield* Scope.close(connection.scope, Exit.void)
|
||||
const driver = yield* registry.get(row.provider)
|
||||
yield* driver.destroy({ workspaceID, binding: row.binding })
|
||||
// Null binding still reaches the driver: an interrupted or crashed
|
||||
// provision may have created a resource that was never persisted. A
|
||||
// provider missing from the registry cannot block deleting a
|
||||
// never-provisioned row.
|
||||
yield* registry.get(row.provider).pipe(
|
||||
Effect.flatMap((driver) => driver.destroy({ workspaceID, binding: row.binding })),
|
||||
Effect.catchTag("WorkspaceDriver.ProviderNotFound", (error) =>
|
||||
row.binding ? Effect.fail(error) : Effect.void,
|
||||
),
|
||||
)
|
||||
yield* db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).run().pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
@@ -216,4 +295,8 @@ export const node = configured()
|
||||
|
||||
// TODO(workspace-plan): add the boot janitor and ~23h safety snapshot rotation in a later PR.
|
||||
// TODO(workspace-plan): make cold wake interruptible with a re-pin loop against janitor races.
|
||||
// TODO(workspace-plan): consider RcMap at end-of-series consolidation; idle suspend and destroy need distinct finalizers.
|
||||
// TODO(workspace-plan): consider extracting a keyed shared-attempt helper (join/cancel, drop-on-settle) beside
|
||||
// KeyedMutex at end-of-series consolidation; filesystem/search.ts and session/run-coordinator.ts hand-roll the same
|
||||
// shape. Audited stdlib alternatives (rc.111): RcMap fails twice (refcount release cancels in-flight work when the
|
||||
// last waiter leaves, and one finalizer path cannot express idle-suspend vs destroy); Cache interrupts the shared
|
||||
// lookup when its last awaiter is interrupted and cannot fail waiters with NotFound on invalidation.
|
||||
|
||||
@@ -24,6 +24,16 @@ export class ProviderNotFound extends Schema.TaggedError<ProviderNotFound>()("Wo
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
/**
|
||||
* Get-or-create the provider resource backing this logical workspace.
|
||||
*
|
||||
* MUST be idempotent per `workspaceID`: core retries the same ID after
|
||||
* failures and process crashes, including a crash between a successful
|
||||
* create and the binding being persisted, and another process may race the
|
||||
* same ID. Key the resource by `workspaceID` (a provider tag or a
|
||||
* deterministic name) and adopt an existing match instead of creating a
|
||||
* duplicate.
|
||||
*/
|
||||
readonly create: (input: {
|
||||
readonly workspaceID: Workspace.ID
|
||||
}) => Effect.Effect<{ readonly binding: Binding }, Error>
|
||||
@@ -37,9 +47,17 @@ export interface Interface {
|
||||
readonly binding: Binding
|
||||
readonly saveBinding: (binding: Binding) => Effect.Effect<void>
|
||||
}) => Effect.Effect<void, Error>
|
||||
/**
|
||||
* Release the provider resource for this workspace.
|
||||
*
|
||||
* `binding` is null when none was persisted: the workspace was never
|
||||
* provisioned, or provisioning was interrupted mid-create. Look up any
|
||||
* resource previously created for `workspaceID` and clean it up, treating
|
||||
* absence as success.
|
||||
*/
|
||||
readonly destroy: (input: {
|
||||
readonly workspaceID: Workspace.ID
|
||||
readonly binding: Binding
|
||||
readonly binding: Binding | null
|
||||
}) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { WorkspaceDriver } from "./driver.js"
|
||||
export const WorkspaceTable = sqliteTable("workspace", {
|
||||
id: text().$type<Workspace.ID>().primaryKey(),
|
||||
provider: text().notNull(),
|
||||
binding: text({ mode: "json" }).$type<WorkspaceDriver.Binding>().notNull(),
|
||||
binding: text({ mode: "json" }).$type<WorkspaceDriver.Binding>(),
|
||||
created_at: integer().notNull(),
|
||||
last_used_at: integer().notNull(),
|
||||
})
|
||||
|
||||
@@ -86,6 +86,8 @@ describe("FileSystemSearch", () => {
|
||||
Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
yield* Effect.sleep("10 millis")
|
||||
expect(observed).toBeUndefined()
|
||||
yield* search.find({ query: "src", type: "directory" })
|
||||
expect(observed?.limit).toBe(100_000)
|
||||
expect(observed?.exclude).toEqual([...Protected.names()].map((name) => `${name}/**`))
|
||||
expect((yield* search.find({ query: "src", type: "directory" }))[0]?.path).toBe(
|
||||
@@ -97,7 +99,6 @@ describe("FileSystemSearch", () => {
|
||||
|
||||
test("refreshes a stale ripgrep index atomically without blocking search", async () => {
|
||||
let scans = 0
|
||||
const initial = Effect.runSync(Deferred.make<void>())
|
||||
const started = Effect.runSync(Deferred.make<void>())
|
||||
const release = Effect.runSync(Deferred.make<void>())
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
@@ -127,7 +128,6 @@ describe("FileSystemSearch", () => {
|
||||
type: "file",
|
||||
})
|
||||
if (input.onEntry) yield* input.onEntry(entry)
|
||||
if (scans === 1) yield* Deferred.succeed(initial, undefined)
|
||||
return [entry]
|
||||
}),
|
||||
glob: () => Effect.succeed([]),
|
||||
@@ -140,7 +140,7 @@ describe("FileSystemSearch", () => {
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
yield* Deferred.await(initial)
|
||||
yield* search.find({ query: "old", type: "file" })
|
||||
expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts"))
|
||||
expect(scans).toBe(1)
|
||||
|
||||
@@ -163,7 +163,6 @@ describe("FileSystemSearch", () => {
|
||||
|
||||
test("reuses location-owned fuzzy targets across index refreshes", async () => {
|
||||
let scans = 0
|
||||
const first = Effect.runSync(Deferred.make<void>())
|
||||
const second = Effect.runSync(Deferred.make<void>())
|
||||
const prepare = spyOn(fuzzysort, "prepare")
|
||||
const cleanup = spyOn(fuzzysort, "cleanup")
|
||||
@@ -187,7 +186,7 @@ describe("FileSystemSearch", () => {
|
||||
scans++
|
||||
const entry = FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })
|
||||
if (input.onEntry) yield* input.onEntry(entry)
|
||||
yield* Deferred.succeed(scans === 1 ? first : second, undefined)
|
||||
if (scans > 1) yield* Deferred.succeed(second, undefined)
|
||||
return [entry]
|
||||
}),
|
||||
glob: () => Effect.succeed([]),
|
||||
@@ -200,7 +199,6 @@ describe("FileSystemSearch", () => {
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
yield* Deferred.await(first)
|
||||
yield* search.find({ query: "index", type: "file" })
|
||||
yield* TestClock.adjust("10 seconds")
|
||||
yield* search.find({ query: "index", type: "file" })
|
||||
|
||||
@@ -3,10 +3,13 @@ import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { hostEnvironmentLayer } from "./fixture/environment"
|
||||
import { location } from "./fixture/location"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
@@ -17,6 +20,30 @@ function provide(directory: string) {
|
||||
Location.node,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
],
|
||||
[Environment.node, hostEnvironmentLayer],
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
function provideMemory(directory: string, memory: Environment.MemoryDriver) {
|
||||
return Effect.provide(
|
||||
LayerNode.compile(LocationMutation.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make(directory), workspaceID: Workspace.ID.make("wrk_test") }),
|
||||
),
|
||||
),
|
||||
],
|
||||
[
|
||||
Environment.node,
|
||||
Layer.succeed(
|
||||
Environment.Service,
|
||||
Environment.Service.of({ files: Environment.makeFiles(memory), spawner: memory.spawner }),
|
||||
),
|
||||
],
|
||||
]),
|
||||
)
|
||||
}
|
||||
@@ -190,6 +217,48 @@ describe("LocationMutation", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("classifies an external target against the location environment, not the server host", () =>
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
const memory = Environment.makeMemoryDriver()
|
||||
const files = Environment.makeFiles(memory)
|
||||
yield* files.mkdir("/workspace/project")
|
||||
yield* files.mkdir("/remote/data")
|
||||
yield* memory.symlink("/remote/data", "/remote/link")
|
||||
yield* Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
// The directory exists only in the location environment; the server host has no /remote.
|
||||
expect((yield* mutation.resolve({ path: "/remote/data" })).externalDirectory).toMatchObject({
|
||||
directory: "/remote/data",
|
||||
resource: "/remote/data/*",
|
||||
})
|
||||
// A final symlink is followed when classifying the boundary.
|
||||
expect((yield* mutation.resolve({ path: "/remote/link" })).externalDirectory).toMatchObject({
|
||||
directory: "/remote/link",
|
||||
resource: "/remote/link/*",
|
||||
})
|
||||
}).pipe(provideMemory("/workspace/project", memory))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("derives the external save boundary from the location environment project root", () =>
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
const memory = Environment.makeMemoryDriver()
|
||||
const files = Environment.makeFiles(memory)
|
||||
yield* files.mkdir("/workspace/project")
|
||||
yield* files.mkdir("/remote/repo/.git")
|
||||
yield* Effect.gen(function* () {
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "/remote/repo/nested/file.txt" })
|
||||
expect(target.externalDirectory).toMatchObject({
|
||||
directory: "/remote/repo/nested",
|
||||
resource: "/remote/repo/nested/*",
|
||||
save: "/remote/repo/*",
|
||||
})
|
||||
}).pipe(provideMemory("/workspace/project", memory))
|
||||
}),
|
||||
)
|
||||
|
||||
test("ignores unknown mutation input fields", () => {
|
||||
expect(Object.keys(LocationMutation.ResolveInput.fields)).toEqual(["path", "kind"])
|
||||
expect(Schema.decodeUnknownSync(LocationMutation.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Message, ToolFailure } from "@opencode-ai/ai"
|
||||
import { DateTime, Effect, Stream, Types } from "effect"
|
||||
import { DateTime, Effect, Option, Stream, Types } from "effect"
|
||||
import type { SessionContext } from "@opencode-ai/plugin/effect/session"
|
||||
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
@@ -242,10 +242,10 @@ describe("plan plugin reminders", () => {
|
||||
})
|
||||
|
||||
describe("plan plugin mutations", () => {
|
||||
it.effect("creates the Plan directory", () =>
|
||||
it.effect("does not create the Plan directory during activation", () =>
|
||||
Effect.gen(function* () {
|
||||
const { files } = yield* run()
|
||||
expect((yield* files.stat(planDirectory)).type).toBe("directory")
|
||||
expect(Option.isNone(yield* files.stat(planDirectory).pipe(Effect.option))).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -7,19 +7,21 @@ import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
|
||||
import { WorkspaceTable } from "@opencode-ai/core/workspace/sql"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect } from "effect"
|
||||
import { Deferred, Effect, Fiber } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const calls: Array<{ readonly operation: string; readonly binding?: WorkspaceDriver.Binding }> = []
|
||||
const calls: Array<{ readonly operation: string; readonly binding?: WorkspaceDriver.Binding | null }> = []
|
||||
const memory = makeMemoryDriver()
|
||||
let failConnect = false
|
||||
let create: WorkspaceDriver.Interface["create"] = ({ workspaceID }) =>
|
||||
Effect.succeed({ binding: { workspaceID, generation: 0 } })
|
||||
|
||||
const driver = WorkspaceDriver.make({
|
||||
create: ({ workspaceID }) => {
|
||||
create: (input) => {
|
||||
calls.push({ operation: "create" })
|
||||
return Effect.succeed({ binding: { workspaceID, generation: 0 } })
|
||||
return create(input)
|
||||
},
|
||||
connect: ({ binding }) => {
|
||||
calls.push({ operation: "connect", binding })
|
||||
@@ -46,6 +48,18 @@ const it = testEffect(
|
||||
beforeEach(() => {
|
||||
calls.splice(0)
|
||||
failConnect = false
|
||||
create = ({ workspaceID }) => Effect.succeed({ binding: { workspaceID, generation: 0 } })
|
||||
})
|
||||
|
||||
const gateCreate = Effect.fnUntraced(function* () {
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
create = ({ workspaceID }) =>
|
||||
Deferred.succeed(started, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(release)),
|
||||
Effect.as({ binding: { workspaceID, generation: 0 } }),
|
||||
)
|
||||
return { started, release }
|
||||
})
|
||||
|
||||
it.effect("rejects unregistered workspace providers", () =>
|
||||
@@ -60,12 +74,178 @@ it.effect("rejects unregistered workspace providers", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("creates and persists an ID without provisioning", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const workspaceID = yield* workspace.create("fake")
|
||||
|
||||
expect(workspaceID.startsWith("wrk_")).toBe(true)
|
||||
expect(calls).toEqual([])
|
||||
expect(
|
||||
yield* Database.Service.use(({ db }) =>
|
||||
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get(),
|
||||
).pipe(Effect.orDie),
|
||||
).toMatchObject({ id: workspaceID, provider: "fake", binding: null })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("destroys an unprovisioned workspace through the driver with a null binding", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const workspaceID = yield* workspace.create("fake")
|
||||
|
||||
yield* workspace.destroy(workspaceID)
|
||||
expect(calls).toEqual([{ operation: "destroy", binding: null }])
|
||||
expect(
|
||||
yield* Database.Service.use(({ db }) =>
|
||||
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get(),
|
||||
).pipe(Effect.orDie),
|
||||
).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("starts eager provisioning in the background and lets callers join it", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const workspaceID = yield* workspace.create("fake")
|
||||
const gate = yield* gateCreate()
|
||||
|
||||
const eager = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(gate.started)
|
||||
const waiter = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.yieldNow
|
||||
expect(calls.map((call) => call.operation)).toEqual(["create"])
|
||||
|
||||
yield* Deferred.succeed(gate.release, undefined)
|
||||
const [eagerResult, waiterResult] = yield* Effect.all([Fiber.join(eager), Fiber.join(waiter)])
|
||||
expect(eagerResult).toEqual(waiterResult)
|
||||
expect(eagerResult.binding).toEqual({ workspaceID, generation: 0 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("starts lazy provisioning on the first spawn", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const workspaceID = yield* workspace.create("fake")
|
||||
const environment = yield* workspace.connect(workspaceID)
|
||||
const gate = yield* gateCreate()
|
||||
|
||||
expect(calls).toEqual([])
|
||||
const spawned = yield* Effect.scoped(environment.spawner.spawn(ChildProcess.make("lazy"))).pipe(
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* Deferred.await(gate.started)
|
||||
expect(calls.map((call) => call.operation)).toEqual(["create"])
|
||||
yield* Deferred.succeed(gate.release, undefined)
|
||||
yield* Fiber.await(spawned)
|
||||
expect(calls.map((call) => call.operation)).toEqual(["create", "connect"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("shares provisioning between concurrent first spawns", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const workspaceID = yield* workspace.create("fake")
|
||||
const environment = yield* workspace.connect(workspaceID)
|
||||
const gate = yield* gateCreate()
|
||||
|
||||
const spawned = yield* Effect.all(
|
||||
["first", "second"].map((command) =>
|
||||
Effect.scoped(environment.spawner.spawn(ChildProcess.make(command))).pipe(
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
),
|
||||
),
|
||||
)
|
||||
yield* Deferred.await(gate.started)
|
||||
yield* Effect.yieldNow
|
||||
expect(calls.map((call) => call.operation)).toEqual(["create"])
|
||||
|
||||
yield* Deferred.succeed(gate.release, undefined)
|
||||
yield* Effect.forEach(spawned, Fiber.await)
|
||||
expect(calls.map((call) => call.operation)).toEqual(["create", "connect"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps shared provisioning alive when a waiter is interrupted", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const workspaceID = yield* workspace.create("fake")
|
||||
const gate = yield* gateCreate()
|
||||
|
||||
const owner = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(gate.started)
|
||||
const waiter = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Fiber.interrupt(waiter)
|
||||
expect(calls.map((call) => call.operation)).toEqual(["create"])
|
||||
|
||||
yield* Deferred.succeed(gate.release, undefined)
|
||||
expect((yield* Fiber.join(owner)).binding).toEqual({ workspaceID, generation: 0 })
|
||||
expect(calls.map((call) => call.operation)).toEqual(["create"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("interrupts in-flight provisioning on destroy and fails waiters with NotFound", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const workspaceID = yield* workspace.create("fake")
|
||||
const gate = yield* gateCreate()
|
||||
|
||||
const waiter = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(gate.started)
|
||||
yield* workspace.destroy(workspaceID)
|
||||
|
||||
expect(yield* Fiber.join(waiter).pipe(Effect.flip)).toEqual(new Workspace.NotFound({ workspaceID }))
|
||||
expect(calls.map((call) => call.operation)).toEqual(["create", "destroy"])
|
||||
expect(calls.at(-1)?.binding).toBeNull()
|
||||
expect(
|
||||
yield* Database.Service.use(({ db }) =>
|
||||
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get(),
|
||||
).pipe(Effect.orDie),
|
||||
).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("shares a failed attempt and retries the same workspace ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const workspaceID = yield* workspace.create("fake")
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
let fail = true
|
||||
create = ({ workspaceID }) =>
|
||||
Deferred.succeed(started, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(release)),
|
||||
Effect.andThen(
|
||||
Effect.suspend(() =>
|
||||
fail
|
||||
? Effect.fail(new WorkspaceDriver.Error({ message: "create failed" }))
|
||||
: Effect.succeed({ binding: { workspaceID, generation: 0 } }),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const first = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(started)
|
||||
const second = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
const [firstExit, secondExit] = yield* Effect.all([Fiber.await(first), Fiber.await(second)])
|
||||
expect(firstExit._tag).toBe("Failure")
|
||||
expect(secondExit._tag).toBe("Failure")
|
||||
expect(calls.map((call) => call.operation)).toEqual(["create"])
|
||||
|
||||
fail = false
|
||||
expect((yield* workspace.provision(workspaceID)).binding).toEqual({ workspaceID, generation: 0 })
|
||||
expect(calls.map((call) => call.operation)).toEqual(["create", "create"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("persists the workspace lifecycle and reconnects after idle suspension", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const created = yield* workspace.create("fake")
|
||||
const workspaceID = yield* workspace.create("fake")
|
||||
const created = yield* workspace.provision(workspaceID)
|
||||
|
||||
expect(created.id.startsWith("wrk_")).toBe(true)
|
||||
expect(created.id).toBe(workspaceID)
|
||||
expect(created.binding).toEqual({ workspaceID: created.id, generation: 0 })
|
||||
|
||||
const environment = yield* workspace.connect(created.id)
|
||||
@@ -97,7 +277,7 @@ it.effect("persists the workspace lifecycle and reconnects after idle suspension
|
||||
it.effect("surfaces wake failures through the spawn error channel", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const created = yield* workspace.create("fake")
|
||||
const created = yield* workspace.provision(yield* workspace.create("fake"))
|
||||
const environment = yield* workspace.connect(created.id)
|
||||
yield* Effect.scoped(environment.spawner.spawn(ChildProcess.make("connect"))).pipe(Effect.exit)
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"effect": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/httpapi-codegen": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
|
||||
@@ -52,6 +52,9 @@ export type Interface = Omit<OpenCodeClient, "plugin" | "workspace"> & {
|
||||
readonly events: OpenCodeClient["event"]
|
||||
readonly workspace: {
|
||||
readonly create: (options: { readonly provider: string }) => ReturnType<Workspace.Interface["create"]>
|
||||
readonly provision: (options: {
|
||||
readonly workspaceID: Workspace.ID
|
||||
}) => ReturnType<Workspace.Interface["provision"]>
|
||||
readonly destroy: (options: { readonly workspaceID: Workspace.ID }) => ReturnType<Workspace.Interface["destroy"]>
|
||||
}
|
||||
readonly plugin: SdkPlugins.Interface["register"] & OpenCodeClient["plugin"]
|
||||
@@ -106,6 +109,7 @@ export const create: (
|
||||
events: client.event,
|
||||
workspace: {
|
||||
create: ({ provider }: { readonly provider: string }) => workspace.create(provider),
|
||||
provision: ({ workspaceID }: { readonly workspaceID: Workspace.ID }) => workspace.provision(workspaceID),
|
||||
destroy: ({ workspaceID }: { readonly workspaceID: Workspace.ID }) => workspace.destroy(workspaceID),
|
||||
},
|
||||
// The embedded host contributes plugins through the ordinary discovery flow:
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { expect } from "bun:test"
|
||||
import { LanguageModel, LLMClient, LLMResponse, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { makeMemoryDriver } from "@opencode-ai/core/environment/index"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
|
||||
import { Deferred, Effect, Latch, Layer, Option, Ref, Schema, Stream } from "effect"
|
||||
import { Deferred, Effect, Fiber, Latch, Layer, Option, Ref, Schema, Stream } from "effect"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import type { OpenCodeEvent } from "../src"
|
||||
@@ -455,7 +460,11 @@ it.live("configures workspace providers through the SDK facade", () =>
|
||||
},
|
||||
})
|
||||
const opencode = yield* fixture.sdk.OpenCode.create({ workspaceProviders: { fake: driver } })
|
||||
const workspace = yield* opencode.workspace.create({ provider: "fake" })
|
||||
const workspaceID = yield* opencode.workspace.create({ provider: "fake" })
|
||||
|
||||
expect(calls).toEqual([])
|
||||
|
||||
const workspace = yield* opencode.workspace.provision({ workspaceID })
|
||||
|
||||
expect(workspace.provider).toBe("fake")
|
||||
expect(workspace.binding).toEqual({ externalID: workspace.id })
|
||||
@@ -477,6 +486,211 @@ it.live("configures workspace providers through the SDK facade", () =>
|
||||
),
|
||||
)
|
||||
|
||||
const workspaceModelScenario = (fixture: Fixture, policy: "eager" | "lazy") =>
|
||||
Effect.gen(function* () {
|
||||
const calls: string[] = []
|
||||
const createStarted = yield* Deferred.make<void>()
|
||||
const createRelease = yield* Deferred.make<void>()
|
||||
const modelStarted = yield* Deferred.make<void>()
|
||||
yield* Effect.addFinalizer(() => Deferred.succeed(createRelease, undefined).pipe(Effect.asVoid))
|
||||
const model = LanguageModel.make({ id: "workspace-test", provider: "test", route: OpenAIChat.route })
|
||||
const client = TestLLM.clientLayer.pipe(
|
||||
Layer.provide(
|
||||
TestLLM.layer({
|
||||
fallback: TestLLM.text("ready", "answer"),
|
||||
transformRequest: (request) => {
|
||||
Deferred.doneUnsafe(modelStarted, Effect.void)
|
||||
return request
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
const models = Layer.mock(SessionRunnerModel.Service, {
|
||||
resolve: () =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(model, {
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
cost: [],
|
||||
limit: { context: 100_000, output: 1_000 },
|
||||
}),
|
||||
),
|
||||
})
|
||||
const driver = WorkspaceDriver.make({
|
||||
create: ({ workspaceID }) => {
|
||||
calls.push("create")
|
||||
return Deferred.succeed(createStarted, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(createRelease)),
|
||||
Effect.as({ binding: { workspaceID } }),
|
||||
)
|
||||
},
|
||||
connect: () => {
|
||||
calls.push("connect")
|
||||
return Effect.succeed(makeMemoryDriver())
|
||||
},
|
||||
suspendForIdle: () => Effect.void,
|
||||
destroy: () => Effect.void,
|
||||
})
|
||||
const configDirectory = path.join(fixture.directory, "config")
|
||||
yield* Effect.promise(() => fs.mkdir(configDirectory))
|
||||
const opencode = yield* fixture.sdk.OpenCode.create(
|
||||
{
|
||||
config: { directory: configDirectory, project: false, content: "{}" },
|
||||
workspaceProviders: { fake: driver },
|
||||
},
|
||||
{
|
||||
overrides: [
|
||||
[llmClient, client],
|
||||
[SessionRunnerModel.node, models],
|
||||
],
|
||||
},
|
||||
)
|
||||
const workspaceID = yield* opencode.workspace.create({ provider: "fake" })
|
||||
const provisioning =
|
||||
policy === "eager"
|
||||
? yield* opencode.workspace.provision({ workspaceID }).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
: undefined
|
||||
if (provisioning) {
|
||||
yield* Deferred.await(createStarted).pipe(
|
||||
Effect.timeoutOrElse({ duration: "4 seconds", orElse: () => Effect.die("provider create did not start") }),
|
||||
)
|
||||
}
|
||||
|
||||
const session = yield* opencode.sessions.create({
|
||||
location: fixture.sdk.Location.Ref.make({
|
||||
directory: fixture.sdk.AbsolutePath.make(fixture.directory),
|
||||
workspaceID,
|
||||
}),
|
||||
})
|
||||
yield* opencode.sessions.prompt({ sessionID: session.id, text: "Answer without using tools" })
|
||||
yield* Deferred.await(modelStarted).pipe(
|
||||
Effect.timeoutOrElse({ duration: "8 seconds", orElse: () => Effect.die("model stream did not start") }),
|
||||
)
|
||||
|
||||
if (!provisioning) {
|
||||
expect(calls).toEqual([])
|
||||
return
|
||||
}
|
||||
expect(provisioning.pollUnsafe()).toBeUndefined()
|
||||
expect(calls).toEqual(["create"])
|
||||
yield* Deferred.succeed(createRelease, undefined)
|
||||
expect((yield* Fiber.join(provisioning)).binding).toEqual({ workspaceID })
|
||||
expect(calls).toEqual(["create"])
|
||||
})
|
||||
|
||||
it.live(
|
||||
"starts model execution while eager workspace provisioning is blocked",
|
||||
() => withEmbedded("opencode-embedded-workspace-eager-", (fixture) => workspaceModelScenario(fixture, "eager")),
|
||||
15_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"starts model execution without provisioning a lazy workspace",
|
||||
() => withEmbedded("opencode-embedded-workspace-lazy-", (fixture) => workspaceModelScenario(fixture, "lazy")),
|
||||
15_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"blocks the model-selected first tool on lazy provisioning",
|
||||
() =>
|
||||
withEmbedded("opencode-embedded-workspace-tool-", (fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const calls: string[] = []
|
||||
const createStarted = yield* Deferred.make<void>()
|
||||
const createRelease = yield* Deferred.make<void>()
|
||||
yield* Effect.addFinalizer(() => Deferred.succeed(createRelease, undefined).pipe(Effect.asVoid))
|
||||
const model = LanguageModel.make({ id: "workspace-tool-test", provider: "test", route: OpenAIChat.route })
|
||||
// The first tool-advertising request selects the shell tool; everything else
|
||||
// (including title generation, which carries no tools) answers with text.
|
||||
let toolIssued = false
|
||||
const respond = (request: LLMRequest) => {
|
||||
const wantsTool = !toolIssued && request.tools.some((tool) => tool.name === "shell")
|
||||
if (!wantsTool) return TestLLM.text("done", "answer")
|
||||
toolIssued = true
|
||||
return TestLLM.tool("call-shell", "shell", { command: "echo hi" })
|
||||
}
|
||||
const client = Layer.succeed(
|
||||
LLMClient.Service,
|
||||
LLMClient.Service.of({
|
||||
stream: (request) => Stream.fromIterable(respond(request)),
|
||||
generate: (request) =>
|
||||
Stream.fromIterable(respond(request)).pipe(
|
||||
Stream.runFold(LLMResponse.empty, LLMResponse.reduce),
|
||||
Effect.flatMap((state) => {
|
||||
const response = LLMResponse.complete(state)
|
||||
if (response) return Effect.succeed(response)
|
||||
return Effect.die("test response ended without a terminal finish event")
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
const models = Layer.mock(SessionRunnerModel.Service, {
|
||||
resolve: () =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(model, {
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
cost: [],
|
||||
limit: { context: 100_000, output: 1_000 },
|
||||
}),
|
||||
),
|
||||
})
|
||||
const driver = WorkspaceDriver.make({
|
||||
create: ({ workspaceID }) => {
|
||||
calls.push("create")
|
||||
return Deferred.succeed(createStarted, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(createRelease)),
|
||||
Effect.as({ binding: { workspaceID } }),
|
||||
)
|
||||
},
|
||||
connect: () => {
|
||||
calls.push("connect")
|
||||
return Effect.succeed(makeMemoryDriver())
|
||||
},
|
||||
suspendForIdle: () => Effect.void,
|
||||
destroy: () => Effect.void,
|
||||
})
|
||||
const configDirectory = path.join(fixture.directory, "config")
|
||||
yield* Effect.promise(() => fs.mkdir(configDirectory))
|
||||
const opencode = yield* fixture.sdk.OpenCode.create(
|
||||
{
|
||||
config: { directory: configDirectory, project: false, content: "{}" },
|
||||
workspaceProviders: { fake: driver },
|
||||
},
|
||||
{
|
||||
overrides: [
|
||||
[llmClient, client],
|
||||
[SessionRunnerModel.node, models],
|
||||
],
|
||||
},
|
||||
)
|
||||
const workspaceID = yield* opencode.workspace.create({ provider: "fake" })
|
||||
const session = yield* opencode.sessions.create({
|
||||
location: fixture.sdk.Location.Ref.make({
|
||||
directory: fixture.sdk.AbsolutePath.make(fixture.directory),
|
||||
workspaceID,
|
||||
}),
|
||||
})
|
||||
expect(calls).toEqual([])
|
||||
|
||||
yield* opencode.sessions.prompt({ sessionID: session.id, text: "Run echo" })
|
||||
// The model-selected shell tool is the first execution-plane demand: it alone
|
||||
// starts provisioning and blocks inside the tool call until the provider is ready.
|
||||
yield* Deferred.await(createStarted).pipe(
|
||||
Effect.timeoutOrElse({ duration: "8 seconds", orElse: () => Effect.die("first tool did not provision") }),
|
||||
)
|
||||
expect(calls).toEqual(["create"])
|
||||
|
||||
yield* Deferred.succeed(createRelease, undefined)
|
||||
yield* opencode.sessions.wait({ sessionID: session.id })
|
||||
// Provisioning settled, the workspace connected, and the turn completed. The
|
||||
// memory driver rejects the actual spawn, which surfaces to the model as an
|
||||
// ordinary tool error before the final text response.
|
||||
expect(calls).toEqual(["create", "connect"])
|
||||
expect(toolIssued).toBe(true)
|
||||
}),
|
||||
),
|
||||
15_000,
|
||||
)
|
||||
|
||||
it.live("preserves unknown workspace provider errors", () =>
|
||||
withEmbedded("opencode-embedded-workspace-provider-", (fixture) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,22 +1,94 @@
|
||||
[data-component="card"][data-kind="tool-error-card"] {
|
||||
--card-pad-y: 0px;
|
||||
--card-line-pad: 4px;
|
||||
--card-line-pad: 0px;
|
||||
--card-pad-r: 0px;
|
||||
|
||||
&::before {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Figma's leading-none would clip Inter descenders at 13px; keep the compact metric. */
|
||||
[data-slot="basic-tool-tool-title"] {
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-subtitle"] {
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="tool-error-card-dot"] {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-tight);
|
||||
letter-spacing: 0.05px;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="tool-error-card-summary"],
|
||||
[data-slot="tool-error-card-message"] {
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-state-fg-danger);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
[data-slot="tool-error-card-summary"] {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="tool-error-card-message"] {
|
||||
/* Text column indent: 16px icon + 8px gap. */
|
||||
padding-inline-start: 24px;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-info-main"] {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-trigger-content"] {
|
||||
/* Reserve the 14px arrow + 6px gap. */
|
||||
max-width: calc(100% - 20px);
|
||||
}
|
||||
|
||||
[data-slot="collapsible-arrow"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-inline-start: 6px;
|
||||
/* Always visible; the base collapsible reveals arrows only on hover. */
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-slot="collapsible-arrow"],
|
||||
[data-slot="collapsible-arrow-icon"] {
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
> [data-component="collapsible"].tool-collapsible {
|
||||
gap: 0px;
|
||||
/* Figma's 4px gap minus the 1.5px the compact line box adds above the summary em. */
|
||||
gap: 2.5px;
|
||||
|
||||
> [data-slot="collapsible-trigger"] {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
> [data-slot="collapsible-content"] {
|
||||
border-inline-start: none;
|
||||
@@ -31,7 +103,8 @@
|
||||
}
|
||||
|
||||
> [data-component="collapsible"].tool-collapsible[data-open="true"] {
|
||||
gap: 4px;
|
||||
/* The compact line box alone yields Figma's spacing under the header. */
|
||||
gap: 0px;
|
||||
}
|
||||
|
||||
[data-component="tool-error-card-icon"] [data-slot="icon-svg"] {
|
||||
@@ -41,11 +114,19 @@
|
||||
[data-slot="tool-error-card-content"] {
|
||||
position: relative;
|
||||
padding-left: 24px;
|
||||
margin-bottom: 8px;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
[data-slot="tool-error-card-content"] [data-slot="card-description"] {
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
> [data-component="collapsible"].tool-collapsible[data-open="true"] [data-slot="tool-error-card-content"] {
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,18 @@ Tool call failure summary styled like a tool trigger.
|
||||
- Collapsible; click header to expand/collapse.
|
||||
`
|
||||
|
||||
const samples = [
|
||||
const samples: { tool: string; error: string; subtitle?: string; defaultOpen?: boolean }[] = [
|
||||
{
|
||||
tool: "shell",
|
||||
subtitle: "sleep 30",
|
||||
error: "Tool execution interrupted",
|
||||
},
|
||||
{
|
||||
tool: "shell",
|
||||
subtitle: "sleep 30",
|
||||
error: "Tool execution interrupted",
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
tool: "patch",
|
||||
error:
|
||||
@@ -62,8 +73,9 @@ export default {
|
||||
},
|
||||
},
|
||||
args: {
|
||||
tool: "patch",
|
||||
tool: samples[0].tool,
|
||||
error: samples[0].error,
|
||||
subtitle: samples[0].subtitle,
|
||||
},
|
||||
argTypes: {
|
||||
tool: {
|
||||
@@ -73,9 +85,12 @@ export default {
|
||||
error: {
|
||||
control: "text",
|
||||
},
|
||||
subtitle: {
|
||||
control: "text",
|
||||
},
|
||||
},
|
||||
render: (props: { tool: string; error: string }) => {
|
||||
return <ToolErrorCard tool={props.tool} error={props.error} />
|
||||
render: (props: { tool: string; error: string; subtitle?: string }) => {
|
||||
return <ToolErrorCard tool={props.tool} error={props.error} subtitle={props.subtitle} />
|
||||
},
|
||||
}
|
||||
|
||||
@@ -83,7 +98,16 @@ export const All = {
|
||||
render: () => {
|
||||
return (
|
||||
<div style="display: flex; flex-direction: column; gap: 12px; max-width: 720px;">
|
||||
<For each={samples}>{(item) => <ToolErrorCard tool={item.tool} error={item.error} />}</For>
|
||||
<For each={samples}>
|
||||
{(item) => (
|
||||
<ToolErrorCard
|
||||
tool={item.tool}
|
||||
error={item.error}
|
||||
subtitle={item.subtitle}
|
||||
defaultOpen={item.defaultOpen}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -70,19 +70,16 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
|
||||
return value
|
||||
})
|
||||
|
||||
const subtitle = createMemo(() => {
|
||||
if (split.subtitle) return split.subtitle
|
||||
const parts = tail().split(": ")
|
||||
if (parts.length <= 1) return i18n.t("ui.toolErrorCard.failed")
|
||||
const head = (parts[0] ?? "").trim()
|
||||
const summary = createMemo(() => {
|
||||
const head = (tail().split(": ")[0] ?? "").trim()
|
||||
if (!head) return i18n.t("ui.toolErrorCard.failed")
|
||||
return head[0] ? head[0].toUpperCase() + head.slice(1) : i18n.t("ui.toolErrorCard.failed")
|
||||
return head[0].toUpperCase() + head.slice(1)
|
||||
})
|
||||
|
||||
const body = createMemo(() => {
|
||||
const detail = createMemo(() => {
|
||||
const parts = tail().split(": ")
|
||||
if (parts.length <= 1) return cleaned()
|
||||
return parts.slice(1).join(": ").trim() || cleaned()
|
||||
if (parts.length <= 1) return ""
|
||||
return parts.slice(1).join(": ").trim()
|
||||
})
|
||||
|
||||
const copy = async () => {
|
||||
@@ -100,27 +97,36 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
|
||||
<div data-component="tool-trigger">
|
||||
<div data-slot="basic-tool-tool-trigger-content">
|
||||
<span data-slot="basic-tool-tool-indicator" data-component="tool-error-card-icon">
|
||||
<Icon name="circle-ban-sign" size="small" style={{ "stroke-width": 1.5 }} />
|
||||
{/* 20px-viewBox path at 16px: 1.25 renders the 1px stroke Figma specifies. */}
|
||||
<Icon name="circle-ban-sign" style={{ "stroke-width": 1.25 }} />
|
||||
</span>
|
||||
<div data-slot="basic-tool-tool-info">
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title">{name()}</span>
|
||||
<Show
|
||||
when={split.href && split.subtitle}
|
||||
fallback={<span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>}
|
||||
>
|
||||
<a
|
||||
data-slot="basic-tool-tool-subtitle"
|
||||
class="clickable subagent-link"
|
||||
href={split.href!}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
split.onSubtitleClick?.(event)
|
||||
}}
|
||||
<Show when={split.subtitle}>
|
||||
<Show
|
||||
when={split.href}
|
||||
fallback={<span data-slot="basic-tool-tool-subtitle">{split.subtitle}</span>}
|
||||
>
|
||||
{subtitle()}
|
||||
</a>
|
||||
<a
|
||||
data-slot="basic-tool-tool-subtitle"
|
||||
class="clickable subagent-link"
|
||||
href={split.href!}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
split.onSubtitleClick?.(event)
|
||||
}}
|
||||
>
|
||||
{split.subtitle}
|
||||
</a>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={open()}>
|
||||
<span data-slot="tool-error-card-dot" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<span data-slot="tool-error-card-summary">{summary()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,33 +135,38 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
|
||||
<Collapsible.Arrow />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<div data-slot="tool-error-card-content">
|
||||
<Show when={open()}>
|
||||
<div data-slot="tool-error-card-copy">
|
||||
<Tooltip
|
||||
appearance="standard"
|
||||
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.toolErrorCard.copyError")}
|
||||
placement="top"
|
||||
gutter={4}
|
||||
>
|
||||
<IconButton
|
||||
icon={<Icon name={copied() ? "check" : "copy"} />}
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void copy()
|
||||
}}
|
||||
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.toolErrorCard.copyError")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={body()}>{(value) => <CardDescription>{value()}</CardDescription>}</Show>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
<Show when={!open()}>
|
||||
<div data-slot="tool-error-card-message">{summary()}</div>
|
||||
</Show>
|
||||
<Show when={detail()}>
|
||||
<Collapsible.Content>
|
||||
<div data-slot="tool-error-card-content">
|
||||
<Show when={open()}>
|
||||
<div data-slot="tool-error-card-copy">
|
||||
<Tooltip
|
||||
appearance="standard"
|
||||
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.toolErrorCard.copyError")}
|
||||
placement="top"
|
||||
gutter={4}
|
||||
>
|
||||
<IconButton
|
||||
icon={<Icon name={copied() ? "check" : "copy"} />}
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void copy()
|
||||
}}
|
||||
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.toolErrorCard.copyError")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
<CardDescription>{detail()}</CardDescription>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Show>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -29,20 +29,23 @@ describe("current content default open", () => {
|
||||
expect(currentContentDefaultOpen(tool("patch"), false, false)).toBe(true)
|
||||
})
|
||||
|
||||
test("collapses failed patches", () => {
|
||||
const patch: SessionMessageAssistantTool = {
|
||||
test("collapses errored tools regardless of disclosure preferences", () => {
|
||||
const errored = (name: string): SessionMessageAssistantTool => ({
|
||||
type: "tool",
|
||||
id: "tool_patch",
|
||||
name: "patch",
|
||||
id: `tool_${name}`,
|
||||
name,
|
||||
state: {
|
||||
status: "error",
|
||||
input: {},
|
||||
error: { type: "ToolError", message: "Verification failed" },
|
||||
error: { type: "ToolError", message: "Tool execution interrupted" },
|
||||
metadata: {},
|
||||
},
|
||||
time: { created: 1, completed: 2 },
|
||||
}
|
||||
expect(currentContentDefaultOpen(patch, false, false)).toBe(false)
|
||||
})
|
||||
expect(currentContentDefaultOpen(errored("shell"), true, true)).toBe(false)
|
||||
expect(currentContentDefaultOpen(errored("execute"), true, true)).toBe(false)
|
||||
expect(currentContentDefaultOpen(errored("edit"), true, true)).toBe(false)
|
||||
expect(currentContentDefaultOpen(errored("patch"), false, false)).toBe(false)
|
||||
})
|
||||
|
||||
test("opens deletion-only patches", () => {
|
||||
|
||||
@@ -35,8 +35,10 @@ export function currentContentDefaultOpen(
|
||||
editExpanded: boolean,
|
||||
) {
|
||||
if (content.type !== "tool") return undefined
|
||||
// Errored tools render the error card, which starts collapsed.
|
||||
if (content.state.status === "error") return false
|
||||
if (content.name === "shell" || content.name === "execute") return shellExpanded
|
||||
if (content.name === "patch") return content.state.status !== "error"
|
||||
if (content.name === "patch") return true
|
||||
if (content.name !== "edit" && content.name !== "write") return undefined
|
||||
if (!editExpanded) return false
|
||||
const files = currentToolMetadata(content).files
|
||||
|
||||
@@ -772,6 +772,7 @@ export function ToolDisplay(
|
||||
if (typeof value === "string" && value) return value
|
||||
return taskId()
|
||||
})
|
||||
const errorSubtitle = createMemo(() => toolErrorSubtitle(props, i18n))
|
||||
const error = createMemo(() => toolDisplayError(props, i18n.t("ui.toolErrorCard.failed")))
|
||||
const render = createMemo(() => ToolRegistry.render(props.tool) ?? GenericTool)
|
||||
|
||||
@@ -799,7 +800,7 @@ export function ToolDisplay(
|
||||
defaultOpen={props.defaultOpen}
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
subtitle={taskSubtitle()}
|
||||
subtitle={taskSubtitle() ?? errorSubtitle()}
|
||||
href={taskHref()}
|
||||
onSubtitleClick={(event) => {
|
||||
if (!data.navigateToSession) return
|
||||
@@ -822,6 +823,32 @@ export function ToolDisplay(
|
||||
)
|
||||
}
|
||||
|
||||
// Each branch must stay in sync with its tool trigger's subtitle expression so
|
||||
// failed rows read like their non-error counterparts ("Shell sleep 30").
|
||||
function toolErrorSubtitle(props: ToolProps, i18n: UiI18n) {
|
||||
const text = (value: unknown) => (typeof value === "string" && value ? value : undefined)
|
||||
if (props.tool === "shell") return text(props.input.command) ?? text(props.metadata.command)
|
||||
if (props.tool === "execute") return text(props.input.code)
|
||||
if (props.tool === "read") return getFilename(readToolPath(props.input) ?? "")
|
||||
if (props.tool === "edit" || props.tool === "write") return getFilename(text(props.input.path) ?? "")
|
||||
if (props.tool === "list" || props.tool === "glob" || props.tool === "grep")
|
||||
return displayDirectory(text(props.input.path) ?? "/")
|
||||
if (props.tool === "webfetch") return text(props.input.url)
|
||||
if (props.tool === "websearch") return text(props.input.query)
|
||||
if (props.tool === "skill") return skillToolName(props.input, props.metadata)
|
||||
if (props.tool === "patch") {
|
||||
const count = patchFileGroups(props.metadata.files).length
|
||||
if (count === 0) return undefined
|
||||
return `${count} ${i18n.plural("ui.common.file", count)}`
|
||||
}
|
||||
if (props.tool === "question") {
|
||||
const count = Array.isArray(props.input.questions) ? props.input.questions.filter(questionInfo).length : 0
|
||||
if (count === 0) return undefined
|
||||
return `${count} ${i18n.plural("ui.common.question", count)}`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function toolDisplayError(props: ToolProps & { error?: string }, fallback: string) {
|
||||
if (props.status === "error") return props.error
|
||||
if (props.tool !== "execute") return undefined
|
||||
|
||||
Reference in New Issue
Block a user